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 | 內容 |
|---|---|---|
| Private | private database | 你自己建立的資料(你擁有) |
| Shared | shared 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 = true | Core 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 有十幾處:DailyTimelineView、QuickAddEventView、SettingsView.importJSON、CareEventSeeder 等等。每個地方都要記得加這行、很容易漏。
放在 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 一定要顯式 assign | context.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 陷阱。