Post

Core Data + CloudKit 整個不同步:dual-store 必須綁 named configuration

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,實際一筆都沒上雲。

走過的死胡同

先貼我試過、後來證明不是根因的那些:

  1. aps-environment entitlement——Debug 要 development、Release 要 production。我看到 updateTaskRequest 就以為是 APNs 註冊失敗。改來改去沒用。
  2. 檢查 iCloud 帳號狀態——寫了 diagnostic 函式印 CKContainer.accountStatus()userRecordID()。兩個都回 .available,可以排除帳號問題。
  3. Settings → General → Background App Refresh 開關——裝置總開關、app 單獨開關都確認 ON。無效。
  4. -com.apple.CoreData.CloudKitDebug 1 launch argument——新版 iOS 這個 flag 時常不生效,沒看到多印任何 log。
  5. 刪 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 事件湧入。

心得

  1. BGSystemTaskSchedulerErrorDomain Code=3 + updateTaskRequest failed 是假線索,Xcode Debug attach 下很常見。不要花太多時間在這上面。
  2. Mirror delegate 初始化失敗是完全 silent 的。別依賴 Apple 幫你印 error。
  3. 自己寫 eventChangedNotification 的 listener 是最可靠的 sync 狀態指標。完全沒 event 通知 = mirror 沒 init。
  4. xcdatamodel configuration 跟 store description configuration 必須一致,dual-store 架構缺了這步就是死局。
  5. 改完 configuration 必須刪 app 重裝,不然舊 schema SQLite 擋著。

Apple 的文件在 Setting Up Core Data with CloudKit 有提到 dual-store + configuration 的做法,但沒明講「不綁 configuration 會 silent fail」。踩到過一次就刻在骨子裡了。

This post is licensed under CC BY 4.0 by the author.