← 所有文章

單一真實來源:SwiftData、MCP、iCloud

Get Bananas有三個呼叫方都能寫入同一份購物清單。人類在iOS app中點擊清單列。Apple Intelligence透過AppIntent轉送Siri請求。Claude Code工作階段透過stdio呼叫MCP工具。在使用者心中,這份清單是一個經典的單一存在;問題在於它存放在哪裡,以及當這些呼叫方意見分歧時,誰能勝出。

綜合篇命名了iOS app的三個介面:人類、Apple Intelligence、代理。每個介面都需要讀寫相同的領域狀態。這項需求正是太多app出貨時所犯下的架構錯誤的根源:每個介面都有自己的儲存體,介面之間漸行漸遠,使用者最後看到三個版本的清單,端視他最近碰過哪一個。能夠存活下來的模式是單一真實來源,並在介面與基底之間建立明確的同步路徑。

本文點名了基底的選項、每種選擇所強制帶來的衝突解決規則,以及當這三類呼叫方都能寫入時仍能撐住的架構。實作示範採用Get Bananas的實際佈局:SwiftData作為app內狀態、iCloud Drive中的JSON檔案作為跨程序同步,以及在iOS沙盒之外讀寫同一份檔案的MCP伺服器。1

TL;DR

  • 三種基底彼此搭配:SwiftData(程序內、快速、有結構型別)、iCloud Drive(跨程序、檔案式、可同步)、NSUbiquitousKeyValueStore(跨裝置、鍵值對、僅適合小量資料)。
  • 依領域選擇哪個基底是真實來源。衝突解決是這項選擇必然帶來的後果,不是另一個獨立問題。
  • 三個介面(人類、Apple Intelligence、代理)各自透過領域層與基底互動。衝突解決政策就住在領域層裡。
  • 為每個可變實體加上lastModified時間戳記,是最便宜的衝突解決原語;「最後寫入者勝出」就是最便宜的政策。需要更強保證的app得用明確的合併邏輯來換取。
  • 跑在app程序之外的MCP伺服器無法讀取SwiftData。橋樑是一層序列化(放在iCloud Drive的JSON檔案、App Group容器等),讓app內的SwiftData讀取者與程序外的MCP伺服器都能對話。

三種基底

Apple為已出貨的iOS app提供三種原生持久化基底,在跨程序與跨裝置場景中都會出現。每一種都有特定形狀;若沒有計畫地混用,就會產生漂移問題。

SwiftData。 由Core Data支撐的程序內持久化儲存。2 速度快、有結構型別、可透過@Query查詢,並與SwiftUI的觀察系統整合。儲存體由app程序所擁有。App擴充功能(小工具、意圖、分享擴充)可透過App Group以明確設定共享一個SwiftData容器;但任意一個跑在開發者機器上、處於app簽署上下文之外的外部MCP程序,無法安全地伸進SwiftData容器內。資料列可透過PersistentIdentifier(程序內)或@Attribute(.unique)自然鍵(跨程序、跨裝置)來定址。遷移透過VersionedSchemaMigrationPlan宣告(在SwiftData的真正成本是結構紀律中已說明)。

iCloud Drive。 檔案式跨裝置同步,透過使用者iCloud容器中的FileManager URL呈現。3 檔案會出現在使用者所登入的每一台裝置上。衝突解決是檔案層級的:iCloud使用NSFileVersion追蹤同時發生的編輯,app則透過讀取衝突日誌來決定要保留什麼。iCloud Drive中的檔案可從iOS app程序之外定址:Mac上的MCP伺服器可以打開iOS app所讀取的同一個JSON檔案。這個基底正是Get Bananas的MCP整合得以運作的關鍵。

NSUbiquitousKeyValueStore 跨裝置鍵值儲存。Apple目前的公開限制是每個app總計1MB、每個值1MB、1024個鍵、每個鍵128個UTF-16字元,寫入速率受到節流。4 衝突解決內建其中:系統會將寫入序列化,並在變更時通知所有裝置。適合小量、低頻率的狀態(設定、最後選擇的分頁、整數計數器);不適合高寫入頻率的資料,或是節流會成為瓶頸的工作負載。Return用它來做跨裝置的計時器狀態(使用者在iPhone上開始計時,可在Apple Watch看到);在五個Apple平台、三個共享檔案中已說明。

第四種基底,CloudKit,看起來是顯然之選,但本系列的app已明確拒絕將其用於跨程序的MCP整合。CloudKit提供強大的跨裝置同步與具備衝突感知的記錄,而Apple確實也推出了CloudKit JS與CloudKit Web Services,讓非Apple環境也能與公開及私人的CloudKit資料庫對話。誠實的取捨是整合成本,而非不可能:Node.js的MCP伺服器要存取私人CloudKit資料庫,必須串接CloudKit Web Services的驗證、結構定義與請求簽名,跟「打開一個JSON檔案」相比,是頗具份量的工程工作。Get Bananas選擇iCloud Drive加上JSON檔案,因為MCP伺服器是Node.js程序,需要讀寫iOS app所看到的相同資料,而一般的檔案I/O正是阻力最小的路徑。1

抉擇:由哪個基底持有真相

問題不是該用哪個基底。問題是哪個基底是哪個領域的真實來源。其他基底要麼快取它、要麼鏡像它,要麼閃到一邊去。

本系列app在生產環境中存活下來的決策矩陣:

領域形狀 真實來源 為什麼
設定、偏好、簡單旗標 NSUbiquitousKeyValueStore 跨裝置同步自動處理;碰撞會被序列化;小量資料剛好放得下
每台裝置的暫存狀態 UserDefaults(無同步) 裝置區域的;不應在另一台裝置全新安裝後仍然存在
程序內可查詢的集合 SwiftData 快速的@Query、SwiftUI觀察、結構型別;僅限程序內
必須觸及外部程序的程序內集合 iCloud Drive JSON檔案(匯出至磁碟) iOS的SwiftData讀取者與外部MCP伺服器都能讀取該檔案
大量的個別使用者內容(照片、音訊、文件) iCloud Drive(每檔案) 使用者的iCloud是自然的儲存體;CloudKit可疊在上面以提供更豐富的同步
跨裝置的工作階段層級狀態(計時器在iPhone上跑、在Watch上可見) NSUbiquitousKeyValueStore 大小符合;需要跨裝置推送語意

這個抉擇形塑了衝突解決政策。對於本機app加外部MCP的橋接而言,SwiftData在程序間沒有內建的衝突解決;若兩個呼叫方寫入同一列,最後一個try context.save()勝出。SwiftData由CloudKit支撐並使用持久化歷程時可帶有更豐富的跨裝置語意,但那個介面是iOS端的,對外部Node.js MCP的情況沒有幫助。iCloud Drive會將衝突以NSFileVersion條目的形式呈現;app必須走訪它們並挑出贏家。NSUbiquitousKeyValueStore則在值層級內建衝突解決。

Get Bananas的架構

Get Bananas的實際佈局:

                     ┌────────────────────────────────────┐
                     │           User's mental model       │
                     │         "my shopping list"          │
                     └─────────────────┬──────────────────┘
                                       │
                          ┌────────────┴───────────┐
                          │                        │
                  ┌───────▼────────┐      ┌───────▼─────────┐
                  │   iOS app      │      │  MCP server      │
                  │  (Get Bananas) │      │  (Node.js)       │
                  └───────┬────────┘      └───────┬─────────┘
                          │                        │
              ┌───────────┴────────┐               │
              │                    │               │
       ┌──────▼──────┐    ┌────────▼──────────┐   │
       │  SwiftData   │    │  iCloud Drive     │◀──┘
       │ (in-process) │◀──▶│  shopping_list.   │
       │              │    │       json        │
       └──────────────┘    └───────────────────┘

  In-app reads/writes flow through SwiftData.
  Cross-process reads/writes flow through the JSON file.
  An iCloud sync layer (iCloudBackupManager) reconciles the two.

這個架構有三條規則。

SwiftData是程序內查詢的真實來源。 iOS app每次UI渲染、每個由@Query支撐的清單、每次搜尋,都從SwiftData讀取。寫入先經過SwiftData;模型上下文儲存;SwiftUI重新渲染。

JSON檔案是跨程序狀態的真實來源。 每當iOS app儲存到SwiftData時,iCloud備份管理員會將目前狀態匯出到使用者iCloud Drive容器中的JSON檔案。每當MCP伺服器寫入時,它寫入同一個JSON檔案。這個檔案就是橋樑。

同步流程在iOS app啟動時與每次跨程序寫入後執行。 目前生產環境的同步邏輯(SyncManager.applyExport)在每次同步流程中都將JSON備份視為權威:它讀取JSON檔案、依UUID將每一列對應到SwiftData、用備份的值覆寫既有列、加入備份有但SwiftData沒有的列,並刪除SwiftData有但備份沒有的列(同時備有防呆,以防空白備份檔案抹除本地資料庫)。政策是同步時備份勝出,而非依時間戳記做每列最後寫入者勝出。再加上iOS app每次儲存後都會重新匯出,實務中的穩態收斂相當快:最近寫入的程序產生了下一次同步要讀取的JSON。

這個架構以複雜性換取跨程序的觸及。純SwiftData的app完全不需要這些;沒有MCP伺服器的app不需要JSON橋接;沒有跨裝置同步的app不需要這個調和器。Get Bananas三個都需要,因為三類呼叫方(透過iOS的人類、透過iOS上App Intents的Apple Intelligence、來自Mac開發者機器透過MCP的代理)全都會碰同一份購物清單。

升級之路:每列最後寫入者勝出

「同步時備份勝出」這個出貨政策很便宜,且在單使用者、單一同時寫入者的情境中能運作。但當iOS app與MCP伺服器接續地寫入JSON檔案時,它就會吃緊:最近寫入的程序會覆蓋對方的變更,即使是實際上未發生衝突的列也一樣。目前的緩解措施是iOS app在每次SwiftData儲存後重新匯出,讓JSON檔案大致與最新的app內狀態保持一致。穩態沒問題;真正並行的情況可能會丟工作。

最便宜的升級是以lastModified: Date?欄位為鍵的每列最後寫入者勝出。ShoppingItem模型已經有這個欄位以保障遷移安全(在SwiftData的真正成本是結構紀律中已說明),但JSON匯出與MCP伺服器目前都未序列化或尊重它。將lastModified貫穿匯出與applyExport,會把合併政策從「備份勝出」改為「較新者勝出」:

  • 雙方都有值,其中一方較新。 較新者勝出。對方的列被更新。
  • 雙方都有值,且打成平手。 用主鍵作為決勝,或用介面決勝(iOS app在平手時勝出,以偏向使用者最近的app內互動)。
  • 一方有值,另一方沒有。 有值的一方勝出。
  • 雙方都沒有值。 兩列都是lastModified時代之前的資料。調和器為下一次蓋上Date()戳記。

這項政策便宜、容易推理,大約在1%的情況下會出錯(對同一列不同欄位的並行編輯)。對於購物清單,那1%無關緊要;對於文件編輯器,那絕對重要。需要更強保證的app會在這個基礎上疊加欄位層級合併、CRDT或operational transforms;Get Bananas至今還不需要那種複雜度,所以每列LWW在路線圖上,而更豐富的合併則否。

三類呼叫方與基底

三個介面中的呼叫方對應到基底決策上:

人類介面透過SwiftData寫入。 使用者在iOS app點擊核取方塊,經由SwiftUI層觸發到一個領域函式,該函式變更SwiftData列、在模型上蓋上lastModified = Date(),並儲存模型上下文。iCloud匯出將目前狀態寫入JSON檔案。MCP伺服器在它下一次讀取時取得新狀態。

Apple Intelligence介面透過SwiftData寫入。 透過Siri喚起的AppIntent在iOS app程序內執行,並抵達人類介面所使用的同一個領域函式。SwiftData狀態變動、模型的lastModified更新、JSON匯出捕捉到新狀態。

代理介面透過JSON檔案寫入。 來自Mac上Claude Code工作階段的MCP工具呼叫直接變更JSON檔案(以檔案鎖定處理來自iOS app的並行寫入)。下次iOS app啟動或同步時,SyncManager.applyExport會讀取該檔案、依UUID走訪項目、用備份的值更新雙方都有的列、加入備份有的列,並刪除備份省略的列(搭配空備份防呆)。出貨政策是同步時備份勝出;升級之路是把lastModified加入JSON,讓政策能轉為較新者勝出

這種不對稱是真實且刻意的。人類與Apple Intelligence兩個介面都在iOS app程序內執行,並原生使用SwiftData。代理介面跑在iOS app程序之外,使用JSON檔案,因為那是它能觸及的基底。調和器把這兩半串在一起。

何時這個模式是錯的答案

幾種JSON橋接模式行不通的情況。

高寫入頻率的資料。 一個每秒多筆編輯的即時文件編輯器,無法承擔每次寫入都將整個集合序列化到JSON檔案的成本。正確的答案是針對真實後端使用operational transforms或CRDT。

強一致性需求。 金融交易的帳本不能容忍JSON檔案上的最後寫入者勝出。正確的答案是CloudKit(或伺服器端資料庫)搭配明確的交易語意。

多使用者協作,且使用者要即時看到彼此的編輯。 iCloud Drive同步是最終一致而非即時的。使用者在一台裝置關閉app,在另一台裝置打開時,看到的是已同步的狀態;使用者要即時看到另一位使用者的游標在文件中移動,則辦不到。正確的答案是即時協作框架(yjs、automerge,或自訂的WebSocket層)。

代理與使用者是不同身分的情況。 Get Bananas的模式假設代理(MCP呼叫方)與人類使用者(iOS app使用者)是同一個人,只是跨程序操作。若代理是代表不同身分行事(共享清單、管理員、自動化機器人),那麼使用者iCloud Drive中的JSON檔案就是錯誤的基底;需要的是具備明確認證的多使用者持久化。

這個模式適用於單使用者、最終一致、跨程序的情況。多數帶有MCP整合的app正是這個情況;有些則不是。

我會怎麼換個方式打造

本系列app已出貨或希望當初有出貨的三個模式。

讓JSON序列化是明確的,而非隱含的。 Get Bananas的第一版在儲存掛鉤中,把每次SwiftData寫入都匯出成JSON。第二版改成在狀態穩定後,由app明確呼叫的步驟。這項改變減少了重複寫入,並讓「跨程序狀態何時被發布」變得清楚。每次變動都隱式儲存的掛鉤,對任何不算瑣碎的集合都會產生過多I/O。

為JSON檔案的結構加上版本。 JSON檔案有自己的結構,獨立於SwiftData的VersionedSchema。當SwiftData結構改變時(例如新增一個欄位),JSON序列化必須跟上。便宜的修正是在JSON最上方放一個schemaVersion: Int欄位;調和器讀取它並套用正確的詮釋。沒有版本化的話,v2的iOS app讀取由舊版MCP伺服器寫入的v1 JSON檔案時,會撞上沉默的資料毀損。

對JSON檔案上鎖,別假設會自動協調。 iOS app與MCP伺服器都會寫入JSON檔案。沒有NSFileCoordinator(程序內、iOS端)與檔案鎖(程序外、開發者機器上),並行寫入會產生毀損的檔案。Get Bananas的MCP伺服器對JSON使用flock風格的檔案鎖;iOS app的寫入使用NSFileCoordinator;這個檔案在實務中很少有競爭,但安全帶很便宜。

這個模式對iOS 26+出貨的app意味著什麼

三個重點。

  1. 每個領域挑一個真實來源。其他基底快取、鏡像或閃到一邊。 SwiftData用於程序內查詢、iCloud Drive JSON用於跨程序橋接、NSUbiquitousKeyValueStore用於小量跨裝置狀態。衝突解決是這個選擇下游的後果。

  2. lastModified加上最後寫入者勝出是便宜的基本款。 多數app不需要更強的保證。需要欄位層級合併或CRDT的1%情況加入起來昂貴;在資料形狀真的需要之前,別付這個成本。

  3. 調和器是承重的部件。 當SwiftData與JSON檔案不一致時,調和器決定。調和器在app啟動時、跨程序寫入後、iCloud同步事件後執行。規則簡單;紀律是真的去執行它。

完整的Apple生態系列:用於Apple Intelligence介面的具型別App Intents;用於代理介面的MCP伺服器;兩者之間的路由問題;用於app內裝置端LLM功能的Foundation Models;執行時與工具LLM的區別;iOS app三個介面的綜合;用於iOS鎖定畫面狀態機的Live Activities;Apple Watch上的watchOS執行時合約;用於人類介面基底的SwiftUI內部結構;用於visionOS場景的RealityKit空間心智模型;用於持久化的SwiftData結構紀律;用於視覺層的Liquid Glass模式;用於跨裝置觸及的多平台出貨。中樞在Apple生態系列。若想了解更廣泛的iOS搭配AI代理的脈絡,請參閱iOS Agent Development指南

FAQ

為何不用CloudKit做跨程序同步?

CloudKit提供強大的跨裝置同步與具衝突感知的記錄,而Apple的CloudKit JS / CloudKit Web Services確實能讓非Apple技術堆疊存取私人CloudKit資料庫。限制在於整合成本:使用CloudKit的Node.js MCP伺服器必須處理CloudKit的驗證(伺服器對伺服器的金鑰或使用者層級的權杖)、結構宣告與簽署過的請求。iCloud Drive加上JSON檔案是普通的檔案I/O,這是萬用翻譯機。當團隊願意付出整合成本,並想要CloudKit更強的同步與衝突語意時,CloudKit是正確的選擇;當「打開一個檔案」對資料形狀來說已經夠用時,JSON橋接是正確的選擇。

兩個呼叫方同時寫入時,你怎麼處理衝突?

Get Bananas的出貨政策是「同步時備份勝出」:SyncManager.applyExport依UUID走訪項目並用備份覆寫本地列,並備有防呆,以防空備份抹除良好的本地資料。升級之路是以lastModified為鍵的每列最後寫入者勝出,這個欄位模型已經帶著,但目前還沒透過JSON橋接序列化。把它加上,可解決約99%確實有一方較新的衝突;剩下的1%(對同一列不同欄位的並行編輯)罕見到至今的app都跳過欄位層級合併或CRDT。需要更強一致性的app會在上面疊加更豐富的合併。

若iCloud Drive是跨程序狀態的真實來源,SwiftData擺在哪?

SwiftData是程序內查詢的真實來源。iOS app每次UI渲染、每個@Query、每次搜尋都讀SwiftData。SwiftData速度快、有結構型別,並與SwiftUI的觀察系統整合。當iOS app寫入時,變更先進SwiftData,再匯出到JSON檔案。JSON檔案是跨程序讀取的真實來源(MCP伺服器的視角);SwiftData是程序內讀取的真實來源(iOS UI的視角)。兩者透過調和器保持一致。

那把購物清單本身放進NSUbiquitousKeyValueStore如何?

NSUbiquitousKeyValueStore每個app總計上限1MB、每個值1MB,寫入受到節流,且序列化在一個1024鍵的字典上。一份有數百個項目加歷史記錄的購物清單,以位元組計可能放得下,但讓每個項目的變更穿過節流是錯誤的形狀;批次集合更新會與app儲存的所有其他東西爭搶速率預算。集合的正確基底是SwiftData(程序內)或iCloud Drive(跨程序)。把NSUbiquitousKeyValueStore保留給小量、低頻率的鍵值狀態:設定、最後選擇的分頁、計數器、功能旗標覆寫。

我怎麼知道我的app中新領域該挑哪個基底?

依序回答三個問題。第一:有沒有iOS app程序之外的東西需要讀寫這個領域?如果有,你需要iCloud Drive(檔案式、普通檔案I/O)或CloudKit(透過Apple的框架,或讓非Apple技術堆疊使用CloudKit Web Services),或自己控制的伺服器。如果沒有,SwiftData是預設。第二:這需要在使用者的裝置之間同步嗎?如果需要,基底必須支援(iCloud Drive支援,SwiftData本身不支援,除非搭配iCloud同步)。第三:資料量多大、變更多頻繁?小量加低頻率住在NSUbiquitousKeyValueStore;其他都需要真正的持久化層。

參考資料


  1. 作者於兩個代理生態系、一份購物清單的分析,2026年4月29日,以及Get Bananas專案中位於Banana List/iCloudBackupManager.swift的iCloud Drive JSON同步層。該架構將SwiftData與位於使用者iCloud Drive容器中、由外部MCP伺服器讀寫的JSON檔案配對。 

  2. Apple Developer,“SwiftData”“Adding and editing persistent data in your app”@Model巨集、@Attribute限制,以及與Core Data的NSManagedObjectModel的關係。作者於SwiftData的真正成本是結構紀律的分析涵蓋了用於安全結構演進的VersionedSchemaMigrationPlan。 

  3. Apple Developer,“Synchronizing documents in the iCloud environment”。檔案式跨裝置同步、透過NSFileVersion的衝突解決,以及用於對共享檔案做安全程序內寫入的NSFileCoordinator API。 

  4. Apple Developer,“NSUbiquitousKeyValueStore”。跨裝置鍵值儲存。Apple的目前限制:每個app總計1MB、每個值1MB、1024個鍵、每個鍵128個UTF-16字元、寫入速率受節流。作者於五個Apple平台、三個共享檔案的分析涵蓋了Return使用此API出貨的跨裝置計時器模式。 

相關文章

MCP 伺服器與 iOS 應用程式並存:兩個__TERM_23__生態系統,一份清單

Get Bananas 在 iOS、macOS、watchOS 與 visionOS 上執行,同時也作為 MCP 伺服器存在於 Claude Desktop 中。橋樑是 iCloud Drive 加上一個 JSON 檔案。

7 分鐘閱讀

App Intents 與 MCP 之爭:路由問題

兩種協定,一個 App。App Intents 將你的 App 開放給 Apple Intelligence。MCP 則將同一個領域開放給 Claude、ChatGPT 等其他平台。這就是路由問題。

4 分鐘閱讀

你的代理有個你沒審查過的中間人

研究人員測試了 28 個LLM API路由器。其中 17 個接觸了 AWS 的誘餌憑證,1 個從私鑰中抽走了 ETH。路由器層就是新的攻擊面。

2 分鐘閱讀