Post

Core Data + CloudKit 雙 store 的 store routing 地雷

Core Data + CloudKit 雙 store 的 store routing 地雷

Core Data + CloudKit 雙 store 的 store routing 地雷

Core Data + CloudKit 為了支援跨帳號共享,app 必須設定兩個 persistent store:private store(自己的資料)和 shared store(別人 share 給我的資料)。這個架構看起來很自然,但新建 managed object 時沒顯式指定要寫哪個 store,會觸發這條 Xcode console 狂噴的訊息:

1
CoreData: fault: Illegal attempt to work with the core-data or default zone in the shared database

而且不會馬上 crash——app 能跑、能顯示、能互動,但 CloudKit 同步背景一直出錯、console 刷到你以為 app 在喊救命。

為什麼要雙 store

Apple 的 NSPersistentCloudKitContainer 把 CloudKit 的兩個 database(private / shared)各自對應一個 persistent store:

Store對應 CloudKit database內容
Privateprivate database你自己建立的資料(你擁有)
Sharedshared database別人 share 給你的 zone(你是 participant)

ViewContext 掛在 container 上、同時連接兩個 store。所以 @FetchRequest 會自動 union 兩邊資料,View 層看起來就是一份資料,不用管背後哪個 store。

設定兩個 store

Persistence.swift 要這樣寫:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import CoreData
import CloudKit

struct PersistenceController {
    static let shared = PersistenceController()
    let container: NSPersistentCloudKitContainer

    init() {
        container = NSPersistentCloudKitContainer(name: "MeowCare")

        let baseURL = NSPersistentContainer.defaultDirectoryURL()

        // Private store
        let privateDesc = NSPersistentStoreDescription(
            url: baseURL.appendingPathComponent("MeowCare-Private.sqlite")
        )
        privateDesc.setOption(true as NSNumber,
            forKey: NSPersistentHistoryTrackingKey)
        privateDesc.setOption(true as NSNumber,
            forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
        let privateOpts = NSPersistentCloudKitContainerOptions(
            containerIdentifier: "iCloud.com.yourapp")
        privateOpts.databaseScope = .private
        privateDesc.cloudKitContainerOptions = privateOpts

        // Shared store
        let sharedDesc = NSPersistentStoreDescription(
            url: baseURL.appendingPathComponent("MeowCare-Shared.sqlite")
        )
        sharedDesc.setOption(true as NSNumber,
            forKey: NSPersistentHistoryTrackingKey)
        sharedDesc.setOption(true as NSNumber,
            forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
        let sharedOpts = NSPersistentCloudKitContainerOptions(
            containerIdentifier: "iCloud.com.yourapp")
        sharedOpts.databaseScope = .shared
        sharedDesc.cloudKitContainerOptions = sharedOpts

        container.persistentStoreDescriptions = [privateDesc, sharedDesc]
        container.loadPersistentStores { _, error in
            if let error = error as NSError? {
                fatalError("Failed to load store: \(error)")
            }
        }

        container.viewContext.automaticallyMergesChangesFromParent = true
        container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
    }

    var privateStore: NSPersistentStore? {
        container.persistentStoreCoordinator.persistentStores.first {
            $0.url?.lastPathComponent == "MeowCare-Private.sqlite"
        }
    }

    var sharedStore: NSPersistentStore? {
        container.persistentStoreCoordinator.persistentStores.first {
            $0.url?.lastPathComponent == "MeowCare-Shared.sqlite"
        }
    }
}

幾個不能省的 options:

Option作用
NSPersistentHistoryTrackingKey = trueCore Data 記錄所有 local change,CloudKit 才知道要同步什麼到雲端
NSPersistentStoreRemoteChangeNotificationPostOptionKey = true別的 device 推下來時本地能收到 notification
cloudKitContainerOptions.databaseScope = .private / .shared告訴 Core Data 這個 store 對應 CloudKit 哪個 database
viewContext.automaticallyMergesChangesFromParent = true背景 sync 完成後,viewContext 自動 refresh,@FetchRequest 才會更新 UI

新建 object 的歧義

問題出現在這邊。寫一段很平常的 code:

1
2
3
let event = CareEvent(context: viewContext, eventType: .bloodGlucose)
event.value = 180
try? viewContext.save()

viewContext.insert() 不知道 event 要放到哪個 store。ViewContext 橫跨 private + shared 兩個 store,Core Data 的 heuristic 不可靠:有時候放 private(正常)、有時候放 shared(就爆)。放到 shared store 後,CloudKit 試圖把這個 object 同步到 shared database 時發現它沒有對應的 shared zone,就吐出:

1
Illegal attempt to work with the core-data or default zone in the shared database

shared database 的設計是「只存別人 share 給我的 zone」,沒有「你自己的預設 zone」這種概念。Core Data 試圖把新 object 放到 shared database 的 default zone——那個 zone 不存在

修法:在 init 裡顯式 assign

最乾淨的修法是在 CareEvent 的 convenience init 裡顯式 assign 到 private store

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@objc(CareEvent)
public class CareEvent: NSManagedObject {
    @NSManaged public var id: String?
    @NSManaged public var occurredAt: Date?
    // ...

    convenience init(context: NSManagedObjectContext,
                     eventType: EventType,
                     occurredAt: Date = .now) {
        self.init(context: context)
        // 關鍵:顯式告訴 Core Data「這個 object 屬於 private store」
        if let privateStore = PersistenceController.shared.privateStore {
            context.assign(self, to: privateStore)
        }
        self.id = UUID().uuidString
        self.eventTypeRaw = eventType.rawValue
        self.occurredAt = occurredAt
        self.updatedAt = .now
    }
}

原則:owner 的新建一律寫 private store;shared store 只承接別人 share 過來的 record

這樣新 object 明確落在 private store,CloudKit 把它同步到 private database 的 custom zone,不會誤觸 shared database 的 default zone。

為什麼放在 init 裡,不放在 caller 身上

也可以在 caller 寫:

1
2
let event = CareEvent(context: viewContext, ...)
viewContext.assign(event, to: PersistenceController.shared.privateStore!)

但 caller 有十幾處:DailyTimelineViewQuickAddEventViewSettingsView.importJSONCareEventSeeder 等等。每個地方都要記得加這行、很容易漏。

放在 convenience init 裡是把 invariant 押在 object 建立的入口處。只要用這個 init 建立,就一定 assign 正確。未來新增的 caller 也不用記得這件事。

缺點:耦合到 singleton PersistenceController.shared。trade-off 是:這個 class 本來就不 unit-testable(NSPersistentCloudKitContainer),耦合 singleton 不是大問題;換來的是「永遠不會忘記 assign」的安全感。

延伸:Recipient 寫回 shared zone

我們目前的設計是:只有 owner 寫 private、recipient 只讀。真要做 recipient 寫回 shared zone,邏輯會複雜不少:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
convenience init(context: NSManagedObjectContext,
                 eventType: EventType,
                 occurredAt: Date = .now) {
    self.init(context: context)

    // 判斷自己是 owner 還是 participant
    let controller = PersistenceController.shared
    let targetStore: NSPersistentStore?
    switch CloudKVS.dataOwnershipRole {
    case .owner:
        targetStore = controller.privateStore
    case .participant:
        // 要寫回哪個 shared zone?需要 shared zone 的參考
        targetStore = controller.sharedStore
    case .none:
        targetStore = controller.privateStore
    }
    if let store = targetStore {
        context.assign(self, to: store)
    }
    // ...
}

而且 participant 寫入 shared store 時還要處理 share 的 permission.readOnly 就不能寫)、server-side conflict 解決、以及 shared zone 的 ownership 等。這是比 owner-only 更大的工程,留給另一階段做。

Takeaways

教訓內容
雙 store 是 Core Data + CloudKit 共享的入場券databaseScope = .private / .shared 各設一個 store
新建 object 一定要顯式 assigncontext.assign(object, to: privateStore),放在 convenience init 裡防呆
Illegal attempt ... default zone in the shared database 不會讓 app crash但 CloudKit sync 背景持續出錯、是潛在的 data integrity 問題,不可忽略
舊資料可能殘留如果發生過誤寫,砍 app 重裝清本地 store 是最快路線
Owner-only 最單純,recipient 寫回是另一檔事想做雙向編輯,要處理 share permission、zone ownership、conflict 解決

雙 store 設計看似複雜,但一旦 init 層加上 context.assign,後續開發幾乎不用再想 store routing 的事,是 set-it-and-forget-it 型的修正。下一篇繼續談另一個 Core Data + CloudKit 的 subtle 問題:Core Data @NSManaged 的 Optional 陷阱

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