Post

SwiftData 到 Core Data 的遷移實戰:為了真正能跨帳號共享

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 缺的關鍵能力:

需求SwiftDataCore Data + NSPersistentCloudKitContainer
私人資料 CloudKit 同步cloudKitDatabase: .automaticNSPersistentStoreDescription.cloudKitContainerOptions
自動接受 share要自己寫 CKAcceptSharesOperationacceptShareInvitations(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 產 @NSManaged extension,你寫自己的 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 備份直接能匯入新版本。

受影響的檔案分類

把專案遍一遍會發現改動集中在幾個明確層級

層級檔案改動量
ModelCareEvent.swift, CareEvent+CloudKit.swift, CareEventGroup.swift重寫/刪除
PersistencePersistence.swift(新檔)新寫
App rootMeowCareApp.swift, SceneDelegate.swift調整 container 注入
View 數據訪問6 個 View@Query@FetchRequestmodelContextmanagedObjectContext
SharingViewModel.swift, CloudSharingView.swift, SettingsView.swift換用 container 的 share API
Preview / SeedPreviewTrait+SampleData.swift, CareEventSeeder.swift換 container
Chart / 商業邏輯DayBandChart.swift, ChartSummaryView.swift 的繪圖邏輯不動
Export/ImportCareEventExport.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()
    }
}

差異只有四點:

  1. import SwiftDataimport CoreData
  2. @Query@FetchRequest(sortDescriptors 語法不同)
  3. @Environment(\.modelContext)@Environment(\.managedObjectContext)
  4. CareEvent(eventType:) + modelContext.insertCareEvent(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 能跑、自己手機一切正常後,才發現幾個深水雷:

  1. Transformable 欄位跨帳號同步不穩 — Owner 看到數字,Recipient 只有時間跟類型。專篇
  2. 雙 store 下新建 object 會誤寫 shared store default zone — Console 狂噴 illegal attempt。專篇
  3. @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 同步邊緣案例,這些就是後續幾篇的主題。

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