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-services→CloudKitcom.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.