Post

SwiftData + iCloud Zone Sharing 完整做法

SwiftData + iCloud Zone Sharing 完整做法

1. 為什麼不能直接用 SwiftData 內建的 CloudKit 同步

SwiftData 有內建的 CloudKit 同步(設定 cloudKitDatabase: .automatic),但它有幾個限制:

  • 只支援 Private Database,沒辦法做「分享給別人」
  • 資料會自動同步到 Default Zone,你沒辦法控制哪些資料要分享、哪些不要
  • 如果你需要「A 建立資料,B 也能看到並編輯」,內建的做不到

所以如果你的需求是「兩個人共用同一份資料」,就必須:

  • SwiftData 只負責本地存儲(cloudKitDatabase: .none
  • 自己用 CloudKit API 處理遠端同步和分享

2. 整體架構

1
2
3
4
5
6
7
8
┌─────────────┐     手動同步      ┌──────────────┐
│  SwiftData   │ ◄──────────────► │   CloudKit    │
│  (本地存儲)   │                  │  (遠端+分享)   │
└─────────────┘                  └──────────────┘
       ↑                                ↑
       │                                │
   ModelContext                   CKContainer
   insert/delete/save            save/fetch/share

重點:SwiftData 和 CloudKit 是兩個獨立的儲存系統,你要自己寫轉換邏輯。


3. 設定 ModelContainer:關掉自動同步

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@main
struct MeowCareApp: App {
    var modelContainer: ModelContainer = {
        let schema = Schema([FoodLog.self, CareEvent.self])
        let modelConfiguration = ModelConfiguration(
            schema: schema,
            isStoredInMemoryOnly: false,
            cloudKitDatabase: .none  // 關鍵:不要讓 SwiftData 自己同步
        )
        do {
            return try ModelContainer(for: schema, configurations: [modelConfiguration])
        } catch {
            fatalError("Could not create ModelContainer: \(error)")
        }
    }()
}

.none 就是告訴 SwiftData:「你只管本地,雲端的事我自己來。」


4. Model 要能跟 CKRecord 互轉

SwiftData 的 @Model 不能直接丟上 CloudKit,你要手動寫轉換。

4.1 SwiftData Model

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Model
final class CareEvent {
    var id: String = UUID().uuidString
    var occurredAt: Date = Date.now
    var updatedAt: Date = Date.now
    var eventTypeRaw: String = EventType.note.rawValue
    var value: Double?
    var noteText: String?

    // CloudKit record 不存進 SwiftData
    @Transient var associatedRecord: CKRecord?

    init(eventType: EventType, occurredAt: Date = .now) {
        self.eventTypeRaw = eventType.rawValue
        self.occurredAt = occurredAt
        self.updatedAt = .now
    }
}

@Transient 很重要,CKRecord 不能被 SwiftData 序列化,所以要標記為暫存。

4.2 CKRecord → Model

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
extension CareEvent {
    convenience init?(record: CKRecord) {
        guard let occurredAt = record["occurredAt"] as? Date,
              let eventTypeRaw = record["eventTypeRaw"] as? String,
              let eventType = EventType(rawValue: eventTypeRaw) else {
            return nil
        }
        self.init(eventType: eventType, occurredAt: occurredAt)
        self.id = record.recordID.recordName
        self.updatedAt = record["updatedAt"] as? Date ?? Date.now
        self.value = record["value"] as? Double
        self.noteText = record["noteText"] as? String
        self.associatedRecord = record
    }
}

4.3 Model → CKRecord

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
extension CareEvent {
    func toCKRecord(in zoneID: CKRecordZone.ID) -> CKRecord {
        let recordID = CKRecord.ID(recordName: id, zoneID: zoneID)
        // 如果有舊的 record 就更新,沒有就建新的
        let record = associatedRecord ?? CKRecord(recordType: "SharedCareEvent", recordID: recordID)

        record["occurredAt"] = occurredAt as CKRecordValue
        record["updatedAt"] = Date.now as CKRecordValue
        record["eventTypeRaw"] = eventTypeRaw as CKRecordValue
        record["value"] = value as CKRecordValue?
        record["noteText"] = noteText as CKRecordValue?

        return record
    }
}

重點:更新時要重用 associatedRecord,不然 CloudKit 會當成新 record 而不是更新。


5. 用自訂 Zone,不要用 Default Zone

CloudKit 的分享功能只支援自訂 Zone,Default Zone 不能分享。

1
2
3
4
5
6
7
static let defaultZoneName = "MeowCareZone"

private func ensureZone() async throws -> CKRecordZone.ID {
    let zone = CKRecordZone(zoneName: Self.defaultZoneName)
    let saved = try await database.save(zone)
    return saved.zoneID
}

每次存資料前先確保 Zone 存在,CloudKit 如果已經有同名的 Zone 不會重複建立。


6. CRUD 操作:同時寫 CloudKit + SwiftData

新增

1
2
3
4
5
6
7
8
9
10
11
func saveCareEvent(_ event: CareEvent, modelContext: ModelContext) async throws {
    let zoneID = try await ensureZone()
    let record = event.toCKRecord(in: zoneID)
    let savedRecord = try await database.save(record)

    // CloudKit 存成功後,再存本地
    event.id = savedRecord.recordID.recordName
    event.associatedRecord = savedRecord
    modelContext.insert(event)
    try modelContext.save()
}

更新

1
2
3
4
5
6
7
8
func updateCareEvent(_ event: CareEvent, modelContext: ModelContext) async throws {
    let zoneID = try await ensureZone()
    event.updatedAt = .now
    let record = event.toCKRecord(in: zoneID)
    let savedRecord = try await database.save(record)
    event.associatedRecord = savedRecord
    try modelContext.save()
}

刪除

1
2
3
4
5
6
7
8
9
10
11
func deleteCareEvent(_ event: CareEvent, modelContext: ModelContext) async throws {
    if let record = event.associatedRecord {
        try await database.deleteRecord(withID: record.recordID)
    } else {
        let zoneID = CKRecordZone.ID(zoneName: Self.defaultZoneName)
        let recordID = CKRecord.ID(recordName: event.id, zoneID: zoneID)
        _ = try? await database.deleteRecord(withID: recordID)
    }
    modelContext.delete(event)
    try modelContext.save()
}

7. 從 CloudKit 同步回本地

根據使用者的角色(owner / participant)決定從哪個 database 拉資料:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func syncFromCloud(modelContext: ModelContext) async throws {
    let role = UserDefaults.standard.dataOwnershipRole

    switch role {
    case .owner:
        // 從自己的 Private Database 拉
        let zones = try await database.allRecordZones()
            .filter { $0.zoneID != CKRecordZone.default().zoneID }
        let cloudEvents = try await fetchCareEvents(scope: .private, in: zones)
        mergeCloudEvents(cloudEvents, into: modelContext)

    case .participant:
        // 從別人分享的 Shared Database 拉
        let sharedZones = try await container.sharedCloudDatabase.allRecordZones()
        let cloudEvents = try await fetchCareEvents(scope: .shared, in: sharedZones)
        mergeCloudEvents(cloudEvents, into: modelContext)

    case .none:
        break
    }
}

合併邏輯:比對 ID,只插入本地沒有的。

1
2
3
4
5
6
7
8
9
10
11
private func mergeCloudEvents(_ groups: [CareEventGroup], into modelContext: ModelContext) {
    let existing = (try? modelContext.fetch(FetchDescriptor<CareEvent>())) ?? []
    let existingIDs = Set(existing.map(\.id))

    for group in groups {
        for event in group.careEvents where !existingIDs.contains(event.id) {
            modelContext.insert(event)
        }
    }
    try? modelContext.save()
}

8. 分享功能:Zone Sharing

8.1 建立或取得 CKShare

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func fetchOrCreateShare(careEventGroup: CareEventGroup) async throws -> (CKShare, CKContainer) {
    guard let existingShare = careEventGroup.zone.share else {
        // 第一次分享:建立新的 CKShare
        let share = CKShare(recordZoneID: careEventGroup.zone.zoneID)
        share[CKShare.SystemFieldKey.title] = "MeowCare: \(careEventGroup.name)"
        _ = try await database.modifyRecords(saving: [share], deleting: [])
        UserDefaults.standard.dataOwnershipRole = .owner
        return (share, container)
    }

    // 已經有分享:取回現有的
    guard let share = try await database.record(for: existingShare.recordID) as? CKShare else {
        throw ViewModelError.invalidRemoteShare
    }
    return (share, container)
}

8.2 用 UICloudSharingController 顯示分享畫面

1
2
3
4
5
6
7
8
9
10
11
struct CloudSharingView: UIViewControllerRepresentable {
    let container: CKContainer
    let share: CKShare

    func makeUIViewController(context: Context) -> some UIViewController {
        let controller = UICloudSharingController(share: share, container: container)
        controller.availablePermissions = [.allowReadWrite, .allowPrivate]
        controller.delegate = context.coordinator
        return controller
    }
}

8.3 接受分享:SceneDelegate

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func windowScene(_ windowScene: UIWindowScene,
                 userDidAcceptCloudKitShareWith metadata: CKShare.Metadata) {
    guard metadata.containerIdentifier == Config.containerIdentifier else { return }

    let role = UserDefaults.standard.dataOwnershipRole
    if role == .participant { return }

    let container = CKContainer(identifier: Config.containerIdentifier)
    let operation = CKAcceptSharesOperation(shareMetadatas: [metadata])

    operation.acceptSharesResultBlock = { result in
        if case .success = result {
            UserDefaults.standard.dataOwnershipRole = .participant
        }
    }

    operation.qualityOfService = .utility
    container.add(operation)
}

要讓這段生效,必須在 AppDelegate 裡指定 SceneDelegate

1
2
3
4
5
6
7
func application(_ application: UIApplication,
                 configurationForConnecting session: UISceneSession,
                 options: UIScene.ConnectionOptions) -> UISceneConfiguration {
    let config = UISceneConfiguration(name: nil, sessionRole: session.role)
    config.delegateClass = SceneDelegate.self
    return config
}

9. 必要的專案設定

Entitlements

  • com.apple.developer.icloud-servicesCloudKit
  • com.apple.developer.icloud-container-identifiers → 你的 container ID

Info.plist

1
2
3
4
5
6
<key>CKSharingSupported</key>
<true/>
<key>UIBackgroundModes</key>
<array>
    <string>remote-notification</string>
</array>

CKSharingSupported 一定要加,不然分享畫面不會出現。這個很容易忘記。


10. 資料擁有權角色管理

用一個 enum 來管理「我是分享者」還是「我是接受者」:

1
2
3
4
5
enum DataOwnershipRole: String {
    case owner       // 我建立了分享
    case participant // 我接受了別人的分享
    case none        // 還沒設定
}

存在 UserDefaults,app 啟動時根據角色決定從哪個 database 同步。


11. 這個做法的取捨

好處

  • 完全控制同步時機和邏輯
  • 可以做 Zone Sharing(兩個人共用資料)
  • SwiftData 的 local query 還是很快

代價

  • 要自己寫 Model ↔ CKRecord 轉換
  • 要自己處理衝突(目前用 ID 比對,後到的不覆蓋)
  • 沒有 CloudKit 的自動 push 更新,要手動 refresh

如果你的 app 不需要分享功能,用 cloudKitDatabase: .automatic 就好,省很多事。

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