CloudKit 分享對方只收到空 Session:多 Session 累積與 CKShare 子樹範圍限制
症狀
MeowCare 是我跟先生共用的貓咪照護記錄 app,我(owner)匯入了 985 筆 CareEvent,建立 CKShare 傳連結給他(partner)。他點開連結接受分享後,app 的角色正確顯示「分享參與者」,Shared store 也真的收到了 record:
1
2
Private — Event: 0, Session: 0
Shared — Event: 0, Session: 1
Session 有、Event 沒有。時間軸整個空白。
資料模型
MeowCare 用 parent/child 結構讓 CloudKit Sharing 一次搬整個子樹:
1
2
3
4
5
CareSession (parent)
└─ events: [CareEvent] (to-many)
CareEvent (child)
└─ session: CareSession? (to-one inverse)
分享程式碼:
1
2
3
4
5
let session = controller.ensureDefaultSession(in: context)
// ...
container.share([session], to: nil) { _, share, _, error in
// ...
}
NSPersistentCloudKitContainer.share([session], to: nil) 會把傳入的 session 加上所有透過 relationship 可達的 CareEvent 一起搬進新建的 shared zone。文件明確寫這點:
When you share a managed object, Core Data automatically includes all of its related records in the share, based on the relationships you’ve defined in your data model.
所以理論上傳一個 session,985 筆 events 都該一起被帶過去。實際上沒有,這就是詭異的地方。
診斷
在 Settings 加了一個 debug 區塊,分 store 印兩個 entity 的 count:
1
2
3
4
5
6
7
8
9
10
private func count(entity: String, inStoreNamed storeName: String) -> Int {
guard let store = context.persistentStoreCoordinator?
.persistentStores
.first(where: { $0.url?.lastPathComponent == storeName }) else {
return 0
}
let req = NSFetchRequest<NSFetchRequestResult>(entityName: entity)
req.affectedStores = [store]
return (try? context.count(for: req)) ?? 0
}
在 owner 手機看:
1
Private — Event: 985, Session: 10 ← 什麼!10 個 session?
發現 10 個 CareSession。正常應該只有 1 個。問題真的來了。
根因:多次 Xcode Run 累積出 N 個 session
原本的 ensureDefaultSession 是這樣寫:
1
2
3
4
5
6
7
8
9
10
@discardableResult
func ensureDefaultSession(in context: NSManagedObjectContext) -> CareSession {
let req = NSFetchRequest<CareSession>(entityName: "CareSession")
req.fetchLimit = 1
req.affectedStores = [privateStore]
if let existing = try? context.fetch(req).first {
return existing
}
return CareSession(context: context, name: "MeowCare")
}
看起來沒問題:找到就用、找不到就建。但這個函式在每次 app 啟動時會被呼叫,而啟動時 CloudKit import 還沒完成。時序上:
- App 啟動
ensureDefaultSession被呼叫- 本地 Core Data 還沒收到 remote session(CloudKit import 是異步)
- 找不到 session → 建新的
- 稍後 CloudKit import 把之前的 session 拉回本地
- 現在本地有 2 個 session
每次 Xcode Run(特別是 Delete App 重裝之後)都會加一個。累積 10 次就是 10 個 session。
為什麼 share 只帶走空 session
每個 session 獨立存在,events 掛在建立事件的當下「最新找到或新建的那個 session」上。結果就是:
- Session #1: 300 events
- Session #2: 200 events
- …
- Session #10: 0 events(剛建出來、還沒有 event 掛進去)
我呼叫 container.share([session], to: nil) 時,ensureDefaultSession 回傳的是最新建的 Session #10。它真的是空的。CKShare 很盡責地把這個空 session 搬進 shared zone,partner 很盡責地收到,UI 也很盡責地顯示 0 events——因為真的沒有。
修法:consolidate
改寫 ensureDefaultSession 做合併。有幾個版本要小心:
第一版(差點釀大禍)
1
2
3
4
5
6
7
8
9
10
11
// ❌ 這版有 bug
let sessions = (try? context.fetch(req)) ?? []
let primary = sessions.first // 最舊的那個
for duplicate in sessions.dropFirst() {
if let events = duplicate.events as? Set<CareEvent> {
for event in events {
event.session = primary
}
}
context.delete(duplicate)
}
用最舊的 session 當 primary,搬 events、刪 duplicates。邏輯看起來對,但有個致命問題:被 CKShare 引用的那個 session 可能不是最舊的。
如果我已經對 Session #5 建了 CKShare,consolidation 把 Session #5 刪掉,CloudKit 的 shared zone metadata 會指向一個已經不存在的 record。結果整個 zone 進入 zombie 狀態:
1
CoreData: fault: Zone metadata is missing it's encoded share data but is marked for a mutation: <CKRecordZoneID: zoneName=com.apple.coredata.cloudkit.share.xxx, ownerName=__defaultOwner__>
encodedShareAsset = nil 但 needsShareUpdate = 1,mirror 一直想 recover 但找不到來源,container.share 之後會 hang 到 timeout。
第二版(正確做法)
先用 container.fetchShares(matching:) 鎖定哪些 session 被 share 引用,優先把這些當 primary:
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
let sessions = (try? context.fetch(req)) ?? []
guard !sessions.isEmpty else {
// 沒 session 就建新的
return CareSession(context: context, name: "MeowCare")
}
// 優先挑「已經被 CKShare 引用的 session」當 primary
let sharedMap = (try? container.fetchShares(
matching: sessions.map(\.objectID)
)) ?? [:]
let primary: CareSession
if let sharedSession = sessions.first(
where: { sharedMap[$0.objectID] != nil }
) {
primary = sharedSession
} else {
primary = sessions[0] // 沒 share 就用最舊的
}
// 把其他 session 的 events 搬到 primary,刪 duplicate
for duplicate in sessions where duplicate !== primary {
if let events = duplicate.events as? Set<CareEvent> {
for event in events {
event.session = primary
}
}
context.delete(duplicate)
}
return primary
關鍵是 fetchShares(matching:) 這招。傳 objectID 陣列進去,回傳 [NSManagedObjectID: CKShare] 字典,key 裡有出現的就是被 share 引用的 session。
Partner 端也要 consolidate
partner 的 private store 也會因為同樣的機制累積 session(只是他不會分享)。上面這段 consolidation 在 partner 端也照跑,把他的私有 session 合併成 1 個。無傷大雅但乾淨。
修好後實際測試
Owner 手機 Clean Build + Run:
1
2
Private — Event: 985, Session: 1 ← 終於變 1
Shared — Event: 0, Session: 0
重建分享連結、傳給 partner、他接受:
Partner 手機(等 30~60 秒 CloudKit import 跑完):
1
2
3
Private — Event: 0, Session: 1
Shared — Event: 985, Session: 1
FetchRequest 合計 Event: 985
時間軸終於顯示全部資料。
心得
ensureDefaultSession不能只做 fetch-or-create,CloudKit import 的時序差會造成累積。要做 consolidation。- Consolidate 時必須用
fetchShares(matching:)鎖定 shared session,否則誤刪會製造 zombie zone、整個 mirror 掛掉。 container.share(_:to:)搬子樹是物理搬動——records 真的從com.apple.coredata.cloudkit.zone搬到新建的 share zone。所以「哪個 session 是 shared session、哪些 events 掛在它下面」這件事 100% 決定 partner 收到什麼。- 自己寫 store-by-store 的 count debug UI 非常值得,不然「partner 收到但沒資料」這種情境只能瞎猜。
- App 的 data model 如果有 parent entity 聚合(像 CareSession),consolidation 這一層邏輯要寫在 Core Data stack 最底層;View 層、import 層、share 層都要假設 parent 永遠唯一。
Apple 文件對「多個 same-type parent 累積」這件事沒正面處理過。這是 Core Data + CloudKit + CKShare 組合起來的實務坑,用到這整條鏈的 app 都會踩。