Core Data + CloudKit 整個不同步:dual-store 必須綁 named configuration
症狀
Core Data + NSPersistentCloudKitContainer 的 app,同時掛了 private 跟 shared 兩個 store(做 CloudKit Sharing 需要)。Xcode Debug build 上:
- 本地 save 成功,UI 看得到記錄
- Xcode Console 持續噴:
1 2
updateTaskRequest failed for com.apple.coredata.cloudkit.activity.export.<UUID> Error updating background task request: Error Domain=BGSystemTaskSchedulerErrorDomain Code=3 "(null)"
- 但完全沒有
CoreData: CloudKit: ...或NSCloudKitMirroringDelegate: ...開頭的 log - iCloud Console 裡 Development 環境 Logs 篩
Database = PRIVATE,查 30 分鐘內:0 events
也就是 app 以為自己在 sync,實際一筆都沒上雲。
走過的死胡同
先貼我試過、後來證明不是根因的那些:
- 改
aps-environmententitlement——Debug 要development、Release 要production。我看到updateTaskRequest就以為是 APNs 註冊失敗。改來改去沒用。 - 檢查 iCloud 帳號狀態——寫了 diagnostic 函式印
CKContainer.accountStatus()跟userRecordID()。兩個都回.available,可以排除帳號問題。 Settings → General → Background App Refresh開關——裝置總開關、app 單獨開關都確認 ON。無效。- 開
-com.apple.CoreData.CloudKitDebug 1launch argument——新版 iOS 這個 flag 時常不生效,沒看到多印任何 log。 - 刪 app 重裝——SQLite 檔清了,症狀一模一樣。
BGSystemTaskSchedulerErrorDomain Code=3 在 Xcode Debug attach 情境下是常見雜訊,看了會誤導。真正的問題根本不是這個。
如何確認 mirror 有沒有真的在動
因為 CloudKitDebug flag 不可靠,我寫了個 CloudSyncMonitor 直接監聽 NSPersistentCloudKitContainer.eventChangedNotification,每一個 event 開始/結束都 print 出來:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
observer = NotificationCenter.default.addObserver(
forName: NSPersistentCloudKitContainer.eventChangedNotification,
object: nil, queue: .main
) { notification in
guard let event = notification.userInfo?[
NSPersistentCloudKitContainer.eventNotificationUserInfoKey
] as? NSPersistentCloudKitContainer.Event else { return }
let typeName: String
switch event.type {
case .setup: typeName = "setup"
case .import: typeName = "import"
case .export: typeName = "export"
@unknown default: typeName = "unknown"
}
let phase = event.endDate == nil ? "began" : "ended"
print("[CloudSyncMonitor] \(typeName) \(phase)")
}
原本 hang 的情境下,這段完全沒有任何輸出。連 setup began 都沒有——代表 mirror delegate 壓根沒初始化。這是真正的指標。
根因:dual-store 少了 named configuration
Apple 的 Core Data + CloudKit Sharing 設計要求:一個 container 同時掛兩個 store(.private + .shared databaseScope)時,每個 store 必須綁到 xcdatamodel 裡的具名 configuration。
原本 Persistence.swift 是這樣寫的:
1
2
3
4
5
let privateDesc = NSPersistentStoreDescription(url: privateURL)
// configuration 留 nil(= 預設),代表此 store 包含 model 內所有 entity
// ...
let sharedDesc = NSPersistentStoreDescription(url: sharedURL)
// 同上,configuration 留 nil
兩個 store 都用 default(unnamed)configuration。Mirror delegate 看到兩個 store 搶同一個 default configuration 下的同一批 entity,沒辦法決定哪個 entity 屬於哪個 store 的 CloudKit zone,就安靜地不初始化(不丟 error、不印 log)。Pain point 是連 fatalError 都沒有,完全 silent。
修法
Step 1:xcdatamodel 加兩個 configuration
xcdatamodel 的 XML(Contents 檔案)在 </entity> 後、</model> 前加:
1
2
3
4
5
6
7
8
<configuration name="Private">
<memberEntity name="CareSession"/>
<memberEntity name="CareEvent"/>
</configuration>
<configuration name="Shared">
<memberEntity name="CareSession"/>
<memberEntity name="CareEvent"/>
</configuration>
兩個 configuration 的 memberEntity 列表完全相同——因為一樣的 entity 在兩個 store 都要存在(owner 自己的在 private,別人 share 給你的在 shared)。
Step 2:NSPersistentStoreDescription 綁 configuration
1
2
3
4
5
6
7
let privateDesc = NSPersistentStoreDescription(url: privateURL)
privateDesc.configuration = "Private"
// ... set HistoryTracking + RemoteChangeNotification + cloudKitContainerOptions
let sharedDesc = NSPersistentStoreDescription(url: sharedURL)
sharedDesc.configuration = "Shared"
// ... 同上
Step 3:刪 app 重裝(關鍵)
這步容易被忽略。舊的 SQLite 檔還是沒 configuration 資訊的 schema,單純 Build + Run 會因為 schema mismatch 而 silently 繼續沿用舊 schema——mirror delegate 照樣不 init。
必須在裝置上長按 app icon → Remove App → Delete App,完整刪掉 sandbox,Clean Build 後重裝,讓 Core Data 用新 schema 重建 SQLite。
修完之後
Xcode Console 第一次開 app 就看到:
1
2
3
4
5
6
[CloudSyncMonitor] setup began
[CloudSyncMonitor] setup ended
[CloudSyncMonitor] setup began ← 第二個 store 也 setup
[CloudSyncMonitor] setup ended
[CloudSyncMonitor] import began
[CloudSyncMonitor] import ended
匯入資料後:
1
2
[CloudSyncMonitor] export began
[CloudSyncMonitor] export ended
iCloud Console → Development → Logs 篩 Database = PRIVATE → 看到大量 RecordSave 事件湧入。
心得
BGSystemTaskSchedulerErrorDomain Code=3+updateTaskRequest failed是假線索,Xcode Debug attach 下很常見。不要花太多時間在這上面。- Mirror delegate 初始化失敗是完全 silent 的。別依賴 Apple 幫你印 error。
- 自己寫
eventChangedNotification的 listener 是最可靠的 sync 狀態指標。完全沒 event 通知 = mirror 沒 init。 - xcdatamodel configuration 跟 store description configuration 必須一致,dual-store 架構缺了這步就是死局。
- 改完 configuration 必須刪 app 重裝,不然舊 schema SQLite 擋著。
Apple 的文件在 Setting Up Core Data with CloudKit 有提到 dual-store + configuration 的做法,但沒明講「不綁 configuration 會 silent fail」。踩到過一次就刻在骨子裡了。