SwiftData 到 Core Data 的遷移實戰:為了真正能跨帳號共享
SwiftData 到 Core Data 的遷移實戰:為了真正能跨帳號共享
寫完 CKShare Silent Failures on TestFlight 之後,發現即使把 share 流程修好,SwiftData 本身對共享的支援仍不到位——收件人接受分享後看不到資料,因為 SwiftData 的 cloudKitDatabase: .automatic 只同步使用者自己的 private database,shared database 的 record 根本不進 SwiftData。
這篇記錄把整個 app 從 SwiftData 搬到 Core Data + NSPersistentCloudKitContainer 的過程。結論:遷移成本比想像中小,UI 層幾乎不動;真正被換掉的是 model 層和 CloudKit 同步機制。
為什麼非得換
WWDC 2023 推的 SwiftData 在本地 persistence 跟私人 CloudKit 同步上相當夠用。但是在跨帳號共享(CKShare)這塊,Apple 自己的範例跟文件幾乎還是用 NSPersistentCloudKitContainer。SwiftData 缺的關鍵能力:
| 需求 | SwiftData | Core Data + NSPersistentCloudKitContainer |
|---|---|---|
| 私人資料 CloudKit 同步 | cloudKitDatabase: .automatic | NSPersistentStoreDescription.cloudKitContainerOptions |
| 自動接受 share | 要自己寫 CKAcceptSharesOperation | acceptShareInvitations(from:into:) |
Shared database 自動同步到 @FetchRequest | 不支援 | 內建 |
| Silent push 觸發 background sync | 自己寫 CKSubscription | 內建 |
share(_:to:) 一鍵建立分享 | 沒有等效 API | 有 |
對「Apple 備忘錄等級的自動同步」這個目標而言,SwiftData 要自己刻的東西太多;Core Data 這三個內建能力剛好對應需求。
架構決策
遷移最容易被忽略的問題不是怎麼寫 code,而是如何處理既有使用者的資料。我們在 Development/Production schema 已經有 CD_CareEvent record type 在流通了。
決策 1:保留同一個 iCloud container
三個選項:
| 方案 | 優缺點 |
|---|---|
同 container,沿用 CD_CareEvent | 雲端資料可能直接讀到;但 SwiftData 和 Core Data 產生的 schema 細節可能對不上、容易靜默失敗 |
同 container,新 entity name(例如 CareRecord) | 新舊並存不衝突;但舊資料變孤兒 |
換新 container (iCloud.rainbow.blooming.MeowCare2) | 零歷史包袱;但需要在 Developer portal 開新 container、entitlements 改全套、使用者端要重新 bind |
選方案 1:保留同一個 container、保留 CareEvent entity name。SwiftData 和 Core Data 產生的 CD_CareEvent record type 名字相同,但欄位結構不相容(xxxRaw Transformable vs 新 Core Data 的 xxx Double——這件事踩到坑,另一篇細說)。舊記錄直接變孤兒、新記錄走新欄位,靠 JSON 匯出匯入銜接既有資料。
決策 2:Codegen 用 Manual/None
Xcode 的 xcdatamodeld 提供三種 codegen:
- Class Definition:Xcode 自動產完整 class,你什麼都不能加
- Category/Extension:Xcode 產
@NSManagedextension,你寫自己的 class - Manual/None:完全手寫,包括
@NSManaged屬性
選 Manual/None。Category/Extension 看似兩全其美,但它自動產的 @NSManaged 對 Optional 的處理太保守,所有 String/Date 都會變成 Optional(即使 xcdatamodeld 設 Non-Optional)。手動宣告有彈性,我們可以刻意選擇哪些欄位 Optional、哪些 Non-Optional。
1
2
3
4
5
6
7
@objc(CareEvent)
public class CareEvent: NSManagedObject {
@NSManaged public var id: String? // 即使 XML Non-Optional,
@NSManaged public var occurredAt: Date? // 實務上仍選擇 Optional
@NSManaged public var valueDouble: NSNumber?
// ...
}
為什麼全 Optional?另一篇 Core Data @NSManaged 的 Optional 陷阱 專門講。
決策 3:JSON 匯出匯入當作遷移橋樑
app 原本就有「匯出全部資料為 JSON」的功能,用來做異機備份。這次遷移剛好可以重複利用:
1
2
3
4
5
6
升級前(1.0.1 SwiftData 版):
使用者在 Settings → 匯出全部資料,存檔備份
升級後(1.1.0 Core Data 版):
使用者在 Settings → 匯入 JSON,挑剛剛的備份檔
→ 新 Core Data 架構重新建 object、走新 CloudKit schema
CareEventExport 是獨立的 Codable struct,跟底層 persistence 完全解耦,這讓 JSON 格式在遷移前後保持一致、使用者的 JSON 備份直接能匯入新版本。
受影響的檔案分類
把專案遍一遍會發現改動集中在幾個明確層級:
| 層級 | 檔案 | 改動量 |
|---|---|---|
| Model | CareEvent.swift, CareEvent+CloudKit.swift, CareEventGroup.swift | 重寫/刪除 |
| Persistence | Persistence.swift(新檔) | 新寫 |
| App root | MeowCareApp.swift, SceneDelegate.swift | 調整 container 注入 |
| View 數據訪問 | 6 個 View | @Query → @FetchRequest、modelContext → managedObjectContext |
| Sharing | ViewModel.swift, CloudSharingView.swift, SettingsView.swift | 換用 container 的 share API |
| Preview / Seed | PreviewTrait+SampleData.swift, CareEventSeeder.swift | 換 container |
| Chart / 商業邏輯 | DayBandChart.swift, ChartSummaryView.swift 的繪圖邏輯 | 不動 |
| Export/Import | CareEventExport.swift, SettingsView 的 JSON 處理 | 不動 |
最後兩項是關鍵——所有顯示層邏輯、Swift Charts、JSON 格式完全不動。這就是為什麼這種遷移可以控制在 1~2 天完成。
View 層替換模板
SwiftData 寫法:
1
2
3
4
5
6
7
8
9
10
11
12
import SwiftData
struct DailyTimelineView: View {
@Environment(\.modelContext) private var modelContext
@Query(sort: \CareEvent.occurredAt) private var allEvents: [CareEvent]
func add() {
let event = CareEvent(eventType: .bloodGlucose)
modelContext.insert(event)
try? modelContext.save()
}
}
Core Data 寫法:
1
2
3
4
5
6
7
8
9
10
11
12
13
import CoreData
struct DailyTimelineView: View {
@Environment(\.managedObjectContext) private var context
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \CareEvent.occurredAt, ascending: true)]
) private var allEvents: FetchedResults<CareEvent>
func add() {
let event = CareEvent(context: context, eventType: .bloodGlucose)
try? context.save()
}
}
差異只有四點:
import SwiftData→import CoreData@Query→@FetchRequest(sortDescriptors 語法不同)@Environment(\.modelContext)→@Environment(\.managedObjectContext)CareEvent(eventType:)+modelContext.insert→CareEvent(context:eventType:)(convenience init 直接帶 context)
@Bindable 換 @ObservedObject 也是 editview 的重點:
1
2
3
4
5
// SwiftData
@Bindable var event: CareEvent
// Core Data
@ObservedObject var event: CareEvent
@ObservedObject 搭配 NSManagedObject 時,$event.propertyName 的 binding 依然運作——因為 NSManagedObject 會在 @NSManaged 屬性被改動時自動發出 objectWillChange(經由 KVO),SwiftUI 能正確觸發重繪。
Persistence.swift 的雙 store 設定
Core Data + 跨帳號共享需要兩個 persistent store:private 跟 shared。這個設定才是整個遷移的精髓:
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
struct PersistenceController {
static let shared = PersistenceController()
let container: NSPersistentCloudKitContainer
init(inMemory: Bool = false) {
container = NSPersistentCloudKitContainer(name: "MeowCare")
let privateDesc = NSPersistentStoreDescription(
url: storeDirectory.appendingPathComponent("MeowCare-Private.sqlite")
)
privateDesc.setOption(true as NSNumber,
forKey: NSPersistentHistoryTrackingKey)
privateDesc.setOption(true as NSNumber,
forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
let privateOpts = NSPersistentCloudKitContainerOptions(
containerIdentifier: Config.containerIdentifier)
privateOpts.databaseScope = .private
privateDesc.cloudKitContainerOptions = privateOpts
let sharedDesc = NSPersistentStoreDescription(
url: storeDirectory.appendingPathComponent("MeowCare-Shared.sqlite")
)
// ...
let sharedOpts = NSPersistentCloudKitContainerOptions(
containerIdentifier: Config.containerIdentifier)
sharedOpts.databaseScope = .shared
sharedDesc.cloudKitContainerOptions = sharedOpts
container.persistentStoreDescriptions = [privateDesc, sharedDesc]
container.loadPersistentStores { desc, error in
if let error = error as NSError? {
fatalError("Failed to load store: \(error)")
}
}
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
}
}
兩個關鍵 option:
NSPersistentHistoryTrackingKey— CloudKit 知道本地有哪些變動要推上去NSPersistentStoreRemoteChangeNotificationPostOptionKey— 別的 device 推下來時本地能收到通知
viewContext.automaticallyMergesChangesFromParent = true 是雙 store 自動 refresh UI 的必要條件;少了它對方改完資料本機 @FetchRequest 不會自動更新。
踩到的後續坑
遷移完成、app 能 build 能跑、自己手機一切正常後,才發現幾個深水雷:
- Transformable 欄位跨帳號同步不穩 — Owner 看到數字,Recipient 只有時間跟類型。專篇
- 雙 store 下新建 object 會誤寫 shared store default zone — Console 狂噴 illegal attempt。專篇
@NSManaged非 Optional 欄位遇 CloudKit legacy nil 會 crash —_unconditionallyBridgeFromObjectiveC。專篇
這些雷各自拿出一篇來講。每一個都屬於「看 log 不會立刻知道原因、但踩過一次就能建立肌肉記憶」的那種。
Takeaways
| 教訓 | 內容 |
|---|---|
| 早點評估 framework 在目標情境的成熟度 | SwiftData 對私人資料夠好、對共享不足;選架構時要對齊實際需求 |
| JSON 匯出匯入當遷移橋樑 | 不論底層 persistence 如何改,獨立的 Codable struct 能讓使用者無痛升級 |
| UI 層應該跟 persistence 解耦 | Chart、View rendering、商業邏輯 不該感知是 SwiftData 還是 Core Data |
| Codegen 選 Manual/None 換取掌控 | 對 Optional/Non-Optional 有精確控制,避免 Xcode 默認值帶來驚喜 |
| 雙 store 設定是 Core Data 共享的入場券 | databaseScope = .private + .shared 缺一不可 |
遷移本身沒特別難、有計劃就能在 1~2 天跑完。真正難的是遷移完成後才冒出來的 CloudKit 同步邊緣案例,這些就是後續幾篇的主題。