單一真實來源: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)自然鍵(跨程序、跨裝置)來定址。遷移透過VersionedSchema與MigrationPlan宣告(在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意味著什麼
三個重點。
-
每個領域挑一個真實來源。其他基底快取、鏡像或閃到一邊。 SwiftData用於程序內查詢、iCloud Drive JSON用於跨程序橋接、
NSUbiquitousKeyValueStore用於小量跨裝置狀態。衝突解決是這個選擇下游的後果。 -
lastModified加上最後寫入者勝出是便宜的基本款。 多數app不需要更強的保證。需要欄位層級合併或CRDT的1%情況加入起來昂貴;在資料形狀真的需要之前,別付這個成本。 -
調和器是承重的部件。 當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;其他都需要真正的持久化層。
參考資料
-
作者於兩個代理生態系、一份購物清單的分析,2026年4月29日,以及Get Bananas專案中位於
Banana List/iCloudBackupManager.swift的iCloud Drive JSON同步層。該架構將SwiftData與位於使用者iCloud Drive容器中、由外部MCP伺服器讀寫的JSON檔案配對。 ↩↩ -
Apple Developer,“SwiftData”與“Adding and editing persistent data in your app”。
@Model巨集、@Attribute限制,以及與Core Data的NSManagedObjectModel的關係。作者於SwiftData的真正成本是結構紀律的分析涵蓋了用於安全結構演進的VersionedSchema與MigrationPlan。 ↩ -
Apple Developer,“Synchronizing documents in the iCloud environment”。檔案式跨裝置同步、透過
NSFileVersion的衝突解決,以及用於對共享檔案做安全程序內寫入的NSFileCoordinatorAPI。 ↩ -
Apple Developer,“NSUbiquitousKeyValueStore”。跨裝置鍵值儲存。Apple的目前限制:每個app總計1MB、每個值1MB、1024個鍵、每個鍵128個UTF-16字元、寫入速率受節流。作者於五個Apple平台、三個共享檔案的分析涵蓋了Return使用此API出貨的跨裝置計時器模式。 ↩