CloudKit 高级篇:构建自定义同步库 发布于 · 2026-09-12 · # CloudKit # CloudKit CloudKit 高级篇:构建自定义同步库 本篇教程面向已掌握 CloudKit 中级用法(CKRecord、CKDatabase、CKOperation、Zone 与 Subscription 的基本操作)的开发者。我们将深入解析 iOS 17 引入的 CKSyncEngine 框架,并以此为基石,从零构建一个将 SwiftData 与 CloudKit 双向同步的自定义同步库。 CKSyncEngine 深度解析 1.1 CKSyncEngine 是什么 CKSyncEngine 就像一个专业的物流调度中心。你只需要告诉它"有哪些货物要发"和"收到了什么货",它就会负责安排运输路线、处理运输异常、管理运输节奏。你不需要自己盯着每辆卡车的出发时间,也不需要手动重试因暴雪延误的包裹——调度中心会替你搞定这一切。 Apple 在 iOS 17 中引入了 CKSyncEngine,用于替代过去手动管理 CKOperation 队列的同步流程。在过去,开发者需要自行编写大量样板代码:创建 CKModifyRecordsOperation、设置 completion handler、处理分页、管理 server change token、创建数据库订阅、处理重试逻辑等等。CKSyncEngine 将这些统统封装在内部,暴露出一个基于事件回调(event-based)的简洁接口。 如果你曾经使用过 IceCream 这样的开源 CloudKit 同步库,会发现它和 CKSyncEngine 的设计理念存在本质差异。IceCream 通过自动监听 Core Data 的 NSManagedObjectContextDidSave 通知,在内部自动将变更转换为 CloudKit 操作并同步。开发者几乎不需要写同步代码,"魔法"在框架内部完成。CKSyncEngine 则采取了完全不同的路线:它要求开发者自行追踪本地数据库变化,主动调用 state.add(pendingRecordZoneChanges:) 告诉引擎"有哪些记录需要同步"。这种设计虽然增加了少量样板代码,但换来了对同步流程的完全掌控——你可以精确决定哪些变更需要同步、何时同步、以何种顺序同步。 // 类比:CKSyncEngine 与 IceCream 的区别 // IceCream:你把包裹扔到门口,快递员自己来取,你不用管 // CKSyncEngine:你需要自己填好快递单并交到调度中心,调度中心负责运输 // 注意:CKSyncEngine 要求开发者主动注册待发送的变更, // 而非像 IceCream 那样自动监听 Core Data 变更 这里有一个极其重要的限制:Apple 明确指出不要用 CKSyncEngine 同步 Public Database。CKSyncEngine 的设计目标是 Private Database 和 Shared Database 的双向同步,它依赖 CKDatabaseSubscription 来监听远程变更,而 Public Database 的数据模型和访问模式与 Private Database 截然不同。如果你需要查询 Public Database 中的数据,应该使用 CKQueryOperation 等传统 API,这部分内容将在第 3 章详细讲解。 1.2 CKSyncEngine 的核心架构 CKSyncEngine 的架构可以概括为以下示意图: 本地数据存储 <--> CKSyncEngine <--> iCloud 服务器 | v CKSyncEngineDelegate (你的代码在这里参与) 在这个架构中,CKSyncEngine 居于中间位置,扮演着"调度中枢"的角色。左侧是你的本地数据存储(可以是 SwiftData、Core Data、SQLite 或任何你选择的持久化方案),右侧是 iCloud 服务器。CKSyncEngine 负责在两者之间搬运数据,但它本身不直接接触你的本地数据——它只通过 CKSyncEngineDelegate 协议与你的代码交互。 你的代码通过两种方式参与同步过程。其一,当引擎需要发送变更时,会通过 nextRecordZoneChangeBatch(_:syncEngine:) 方法向你索要具体的记录数据。其二,当引擎从服务器拉取到变更、发送完成、状态更新等事件发生时,会通过 handleEvent(_:syncEngine:) 方法通知你,由你决定如何处理。 1.3 Configuration 配置 创建 CKSyncEngine 实例的第一步是构建一个 CKSyncEngine.Configuration。Configuration 是一个结构体,需要指定三个核心参数:要同步的数据库、上次保存的序列化状态、以及委托对象。 // 创建 CKSyncEngine.Configuration // 类比:填写物流中心的"开户申请表",指定仓库地址、历史档案和联络人 // 注意:delegate 参数是必填的,不能传 nil var configuration = CKSyncEngine.Configuration( database: container.privateCloudDatabase, // 指定要同步的数据库 stateSerialization: savedState, // 上次保存的状态(用于断点续传) delegate: self // 委托对象,处理同步事件 ) configuration.automaticallySync = true // 开启自动同步 let syncEngine = CKSyncEngine(configuration) database 参数指定引擎要同步的目标数据库,通常是 container.privateCloudDatabase 或 container.sharedCloudDatabase。stateSerialization 参数接受一个可选的 CKSyncEngine.State.Serialization?——如果是首次启动(没有历史状态),传入 nil 即可;如果之前保存过状态,传入反序列化后的状态对象,引擎就能从中断处继续同步。delegate 参数必须是实现了 CKSyncEngineDelegate 协议的对象,不能为 nil。 automaticallySync 属性默认为 true,表示引擎会在系统条件良好时(有网络、有电量、已登录 iCloud 账号)自动调度同步任务。如果你希望完全手动控制同步时机,可以将其设为 false,然后通过 fetchChanges(_:) 和 sendChanges(_:) 手动触发。Configuration 还有一个可选的 subscriptionID 属性,用于自定义数据库订阅的标识符,通常使用默认值即可。 1.4 CKSyncEngineDelegate 协议 CKSyncEngineDelegate 协议只要求实现两个方法,但这两个方法承载了整个同步逻辑的核心。 第一个方法是 handleEvent(_:syncEngine:)。引擎在同步过程中会发出各种事件,你需要在这个方法中通过 switch 语句逐一处理。每个事件都携带一个关联值(associated value),其中包含事件的具体信息。 第二个方法是 nextRecordZoneChangeBatch(_:syncEngine:)。当引擎准备发送记录变更时,会调用这个方法向你索要下一批记录数据。你需要根据上下文中指定的 scope(范围),从本地存储中找到对应的记录,封装成 CKSyncEngine.RecordZoneChangeBatch 返回。返回 nil 表示没有更多变更需要发送。 CKSyncEngine.Event 是一个枚举,包含以下所有 case。理解每个事件的含义是掌握 CKSyncEngine 的关键: stateUpdate 事件:引擎更新了内部状态。这是最频繁触发的事件之一,你需要将事件中的 stateSerialization(类型为 CKSyncEngine.State.Serialization)持久化到磁盘,以便下次启动时支持断点续传。如果不持久化状态,每次启动都会从零开始同步,效率极低且可能导致重复处理。 accountChange 事件:用户的 iCloud 账户发生了变化。事件中的 changeType 是一个枚举,包含三种情况:signIn(currentUser:)(用户登录)、signOut(previousUser:)(用户登出)、switchAccounts(previousUser:currentUser:)(切换账号)。需要注意,当账户变化时,引擎会重置内部状态,包括所有待发送的变更队列。 willFetchChanges 事件:引擎即将开始从服务器拉取变更。可以用来在 UI 上显示"正在同步"的旋转指示器。 fetchedDatabaseChanges 事件:拉取到了数据库级别的变更。这类变更通常是 zone 级别的——某个 zone 被新建、修改或删除。事件包含 modifications(zone 修改列表)和 deletions(zone 删除列表)。当收到 zone 删除时,你需要清理本地存储中该 zone 对应的所有数据。 fetchedRecordZoneChanges 事件:拉取到了记录级别的变更。这是最核心的拉取事件,包含 modifications(记录新增或修改列表,每个 modification 包含一个 record 属性)和 deletions(记录删除列表,每个 deletion 包含一个 recordID 属性)。你需要将修改的记录合并到本地存储,将删除的记录从本地存储中移除。 didFetchChanges 事件:拉取操作完成。可以用来隐藏 UI 上的同步指示器。 willSendChanges 事件:引擎即将开始向服务器发送变更。 sentDatabaseChanges 事件:发送了数据库级别的变更(如创建或删除 zone)。事件中包含成功和失败的结果。 sentRecordZoneChanges 事件:发送了记录级别的变更。事件包含 savedRecords(成功保存的记录数组)、deletedRecordIDs(成功删除的记录 ID 数组)、failedRecordSaves(保存失败的记录数组,每个元素包含 record 和 error 属性)、failedRecordDeletes(删除失败的记录 ID 与错误的字典)。你需要根据这些结果更新本地状态,比如保存成功后要记录最新的 CKRecord 系统字段用于下次冲突检测。 didSendChanges 事件:发送操作完成。 下面是一个完整的事件处理实现示例: func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async { switch event { case .stateUpdate(let stateUpdateEvent): // 持久化同步状态,用于下次启动时断点续传 // 类比:物流中心每次发完货都会更新台账,下次开门营业时接着台账继续 // 注意:stateSerialization 必须持久化到磁盘,否则重启后会丢失同步进度 self.stateSerialization = stateUpdateEvent.stateSerialization self.saveStateToDisk() case .accountChange(let accountChangeEvent): // 处理账户变化:用户登录/登出/切换账号 // 注意:账户变化时引擎会自动重置内部状态,包括所有待发送变更队列 self.handleAccountChange(accountChangeEvent) case .fetchedDatabaseChanges(let fetchedDatabaseChangesEvent): // 处理数据库级别变更(如某个 zone 被删除) // 类比:物流中心收到通知说某个仓库被拆除了,需要清理对应的所有货物记录 for deletion in fetchedDatabaseChangesEvent.deletions { // deletion 包含 zoneID 和删除原因 // 需要清理本地存储中该 zone 对应的所有数据 self.purgeLocalData(in: deletion.zoneID) } case .fetchedRecordZoneChanges(let fetchedRecordZoneChangesEvent): // 处理从服务器拉取到的记录变更 // 这是拉取流程中最核心的事件 for modification in fetchedRecordZoneChangesEvent.modifications { // modification.record 是从服务器拉取到的 CKRecord // 需要将其合并到本地存储 self.mergeRecordFromServer(modification.record) } for deletion in fetchedRecordZoneChangesEvent.deletions { // deletion.recordID 是被删除记录的 ID // 需要从本地存储中删除对应记录 self.deleteLocalRecord(deletion.recordID) } case .sentRecordZoneChanges(let sentRecordZoneChangesEvent): // 处理发送结果:成功/失败 // 类比:物流中心发完货后回执,告诉你哪些包裹送达了、哪些被退回了 for savedRecord in sentRecordZoneChangesEvent.savedRecords { // 保存成功后,记录最新的 CKRecord 系统字段 // 注意:encodeSystemFields 用于保存 recordChangeTag 等系统字段, // 下次保存时需要复用这些字段,否则会触发 serverRecordChanged 冲突 self.updateLastKnownRecord(savedRecord) } for failedSave in sentRecordZoneChangesEvent.failedRecordSaves { // failedSave 包含 record 和 error 两个属性 // 根据错误类型处理失败 self.handleFailedRecordSave(failedSave) } for deletedRecordID in sentRecordZoneChangesEvent.deletedRecordIDs { // 删除成功,清理本地缓存的系统字段 self.removeLastKnownRecord(deletedRecordID) } case .willFetchChanges, .didFetchChanges, .willSendChanges, .didSendChanges: // 这些事件可用于更新 UI 同步状态指示器 // 类比:物流中心门口的"营业中/休息中"指示牌 self.updateSyncStatusIndicator(event) default: // 处理 willFetchRecordZoneChanges、didFetchRecordZoneChanges、 // sentDatabaseChanges 等其他事件 break } } 1.5 状态管理与断点续传 CKSyncEngine 内部维护着一系列关键数据:server change token(用于增量拉取)、subscription 标识符、最近一次的 userRecordID、待发送的变更队列等等。这些数据被封装在 CKSyncEngine.State 类中。State 类是一个引用类型(class),你不需要直接创建它——引擎在初始化时自动创建,通过 syncEngine.state 属性访问。 State 中你最常打交道的两个属性是 pendingRecordZoneChanges 和 pendingDatabaseChanges。pendingRecordZoneChanges 是一个 [CKSyncEngine.PendingRecordZoneChange] 数组,记录了所有待发送的记录变更。CKSyncEngine.PendingRecordZoneChange 是一个枚举,只有两个 case:.saveRecord(CKRecord.ID)(保存记录)和 .deleteRecord(CKRecord.ID)(删除记录)。pendingDatabaseChanges 类似,用于记录 zone 级别的变更,case 包括 .saveRecordZone(CKRecordZone.ID) 和 .deleteRecordZone(CKRecordZone.ID)。 CKSyncEngine.State.Serialization 是一个可序列化的结构体,包含了引擎恢复工作所需的全部状态快照。每当引擎内部状态发生变化时,它会发出 stateUpdate 事件,事件中携带最新的 Serialization 实例。你的职责是将这个 Serialization 持久化到磁盘——因为它实现了 Codable 协议,所以可以直接用 JSONEncoder 和 JSONDecoder 进行编码和解码。 // 状态的持久化与恢复 // 类比:物流中心每天下班前把台账拍照存档,第二天开门时翻出照片接着干 /// 获取状态文件的存储路径 private func getStateURL() -> URL { let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] return docsDir.appendingPathComponent("ck_sync_state.json") } /// 保存同步状态到磁盘 func saveStateToDisk() { guard let stateSerialization = self.stateSerialization else { return } let url = getStateURL() do { let data = try JSONEncoder().encode(stateSerialization) try data.write(to: url, options: [.atomic]) } catch { // 注意:状态保存失败不应导致 App 崩溃,但应记录日志以便排查 print("保存同步状态失败: \(error)") } } /// 从磁盘恢复同步状态 func loadStateFromDisk() -> CKSyncEngine.State.Serialization? { let url = getStateURL() guard let data = try? Data(contentsOf: url) else { return nil } // 注意:如果 Schema 发生变化导致反序列化失败,返回 nil 让引擎从头开始 return try? JSONDecoder().decode(CKSyncEngine.State.Serialization.self, from: data) } State 还提供了一个 hasPendingUntrackedChanges 布尔属性,表示是否存在引擎尚未追踪的本地变更。如果你在本地做了修改但还没来得及调用 add(pendingRecordZoneChanges:),这个值就是 true。另一个有用的属性是 zoneIDsWithUnfetchedServerChanges,它返回一个 [CKRecordZone.ID],告诉你哪些 zone 还有未拉取的服务端变更。 1.6 发送变更流程 发送变更的整体流程分为三步:注册待发送变更、引擎调度发送、接收发送结果。 第一步,当本地数据发生变化时,你需要主动调用 syncEngine.state.add(pendingRecordZoneChanges:) 注册待发送的变更。这就像在物流中心填写一张运单——你只是声明了"这票货要发",但货物本身还没有交给调度中心。如果此时没有正在进行的同步操作,引擎会自动调度一次发送。 第二步,引擎在准备好发送时,会调用你的 nextRecordZoneChangeBatch(_:syncEngine:) 方法索要记录数据。你需要从 syncEngine.state.pendingRecordZoneChanges 中取出待发送变更,根据上下文中的 scope 进行过滤,然后用 CKSyncEngine.RecordZoneChangeBatch 构建一个批次。RecordZoneChangeBatch 的初始化方法是一个 async 方法,它接受一个 recordProvider 闭包——引擎会遍历你传入的 pending changes,对每个 .saveRecord 类型的变更调用闭包,由闭包返回对应的 CKRecord。如果闭包返回 nil,该变更会被跳过。RecordZoneChangeBatch 会自动遵守 CloudKit 服务端每请求 250 条记录的限制,超出部分会留在 pendingRecordZoneChanges 中等待下一批发送。 第三步,发送完成后,引擎通过 sentRecordZoneChanges 事件通知你结果。你需要根据成功和失败的记录更新本地状态。 // 完整的发送流程示例 // 1. 本地保存了一条新笔记后,注册待发送变更 func saveNote(_ note: Note) { // 先保存到本地存储 self.localStore.save(note) // 告诉同步引擎有新记录要发送 // 类比:填好运单交给物流中心,物流中心会自己安排发车 let change: CKSyncEngine.PendingRecordZoneChange = .saveRecord(note.recordID) self.syncEngine.state.add(pendingRecordZoneChanges: [change]) // 引擎会自动调度发送,无需手动调用 sendChanges } // 2. 引擎索要记录数据时提供 func nextRecordZoneChangeBatch( _ context: CKSyncEngine.SendChangesContext, syncEngine: CKSyncEngine ) async -> CKSyncEngine.RecordZoneChangeBatch? { // 从上下文中获取本次发送的范围(可能限定到特定 zone) let scope = context.options.scope // 从待发送队列中筛选出属于本次 scope 的变更 let changes = syncEngine.state.pendingRecordZoneChanges.filter { scope.contains($0) } // 如果没有变更需要发送,返回 nil 告诉引擎本次发送结束 // 注意:返回 nil 会让引擎认为所有变更都已发送完毕 guard !changes.isEmpty else { return nil } // 构建 RecordZoneChangeBatch // 类比:把运单对应的货物装箱,每箱最多 250 件 // 注意:这个初始化方法是 async 的,recordProvider 闭包也是 async 的 let batch = await CKSyncEngine.RecordZoneChangeBatch( pendingChanges: changes ) { recordID in // 根据 recordID 从本地存储中找到对应数据,转换为 CKRecord guard let note = self.localStore.find(recordID: recordID.recordName) else { // 返回 nil 表示这条记录在本地已不存在,引擎会跳过它 return nil } // 注意:如果之前保存过该记录的系统字段(recordChangeTag 等), // 应该在此处复用,否则服务端会认为是新记录可能导致冲突 return note.toCKRecord(existingRecord: self.lastKnownRecords[recordID.recordName]) } return batch } // 3. 处理删除记录的场景 func deleteNote(_ note: Note) { // 先从本地存储删除 self.localStore.delete(note) // 注册待发送的删除变更 let change: CKSyncEngine.PendingRecordZoneChange = .deleteRecord(note.recordID) self.syncEngine.state.add(pendingRecordZoneChanges: [change]) } 1.7 拉取变更流程 拉取变更的流程比发送更简单,因为你不需要主动做太多事情——引擎会自动处理大部分工作。 CKSyncEngine 在初始化后会自动寻找与目标数据库关联的 CKDatabaseSubscription。如果找不到现成的订阅,它会自动创建一个。这个订阅的作用是让 iCloud 服务器在数据库发生变更时,通过静默推送通知(silent push notification)唤醒你的 App。收到推送后,引擎会自动调度一次拉取操作。 拉取操作开始时,引擎发出 willFetchChanges 事件。随后,对于拉取到的数据库级别变更(zone 被创建或删除),引擎发出 fetchedDatabaseChanges 事件。对于每个 zone 中的记录级别变更,引擎会先发出 willFetchRecordZoneChanges 事件(告诉你即将拉取哪个 zone 的变更),然后发出 fetchedRecordZoneChanges 事件(包含具体的记录变更),最后发出 didFetchRecordZoneChanges 事件(该 zone 拉取完成)。所有变更拉取完毕后,引擎发出 didFetchChanges 事件。 // 拉取变更流程示意 // 类比:物流中心收到通知说有新货到了,自动安排车去取货,取到后通知你入库 // 注意:你不需要手动创建 CKDatabaseSubscription,引擎会自动管理 // 注意:静默推送需要 App 具备 Remote Notifications 能力(Background Modes) // 拉取到的记录变更处理(在 handleEvent 方法中) // case .fetchedRecordZoneChanges(let event): // for modification in event.modifications { // // modification.record 是从服务器拉取到的 CKRecord // // 你需要将其合并到本地存储 // self.mergeRecordFromServer(modification.record) // } // for deletion in event.deletions { // // deletion.recordID 是被删除的记录 ID // self.deleteLocalRecord(deletion.recordID) // } 需要注意的是,CloudKit 不保证拉取到的变更顺序,但典型顺序是从旧到新。这意味着如果你在同一条记录上有多次变更,最后收到的修改就是最新的版本。 1.8 手动触发同步 虽然 automaticallySync = true 时引擎会自动同步,但有些场景需要你手动触发。比如用户下拉刷新时,你希望立即拉取最新数据;比如用户点击"立即备份"按钮时,你希望立即发送本地变更。 // 手动拉取(如下拉刷新) // 类比:你等不及物流中心的定期班次,直接打电话要求"马上派车去取货" do { try await syncEngine.fetchChanges(CKSyncEngine.FetchChangesOptions()) print("手动拉取完成") } catch { // 注意:手动触发的同步可能因网络问题失败,需要处理错误 print("手动拉取失败: \(error)") } // 手动发送(如"立即备份"按钮) do { try await syncEngine.sendChanges(CKSyncEngine.SendChangesOptions()) print("手动发送完成") } catch { print("手动发送失败: \(error)") } FetchChangesOptions 和 SendChangesOptions 都可以传入自定义参数。SendChangesOptions 接受一个 scope 参数,用于限定发送的范围——比如只发送某个 zone 的变更。operationGroup 参数用于将多个操作归组,便于在仪表盘中追踪。通常情况下,使用默认参数即可。 1.9 账户变更处理 当用户的 iCloud 账户状态发生变化时,CKSyncEngine 会发出 accountChange 事件。事件中的 changeType 是一个枚举,有三种 case,每种都需要不同的处理策略。 signIn(currentUser:) 表示用户登录了 iCloud 账户。此时本地可能有之前离线创建的数据需要上传。你需要确保这些本地变更已经注册到引擎的 pending 队列中,引擎会在条件允许时自动上传。如果这是用户首次在此设备上登录,本地存储可能为空,引擎会自动拉取该账户的所有云端数据。 switchAccounts(previousUser:currentUser:) 表示用户切换了账号。这是最复杂的场景。你需要先清除上一个账户的所有本地数据(因为新账户的数据与旧账户完全不同),然后让引擎拉取新账户的云端数据。注意,引擎在账户变化时会自动重置内部状态,包括所有 pending 变更和 server change token,所以你不需要手动清理引擎状态。 signOut(previousUser:) 表示用户登出了 iCloud 账户。此时你需要清除所有本地数据,因为没有账户就意味着没有云端数据可以同步。用户可能在下次登录时恢复数据,所以清除时要考虑是否保留某些本地草稿。 // 完整的账户变更处理逻辑 func handleAccountChange(_ event: CKSyncEngine.Event.AccountChange) { switch event.changeType { case .signIn(let currentUser): // 用户登录:将本地数据上传到新账户 // 类比:新租客入住,把他的行李从仓库搬进房间 // 注意:引擎会自动开始拉取新账户的云端数据 // 你只需要确保本地待发送的变更已经注册到 pending 队列 self.registerAllLocalChangesAsPending() self.updateUIForSignedInUser(currentUser) case .switchAccounts(let previousUser, let currentUser): // 切换账号:清除本地数据后拉取新账户数据 // 类比:租客换人了,先把旧租客的东西全部清出去,再搬新租客的东西进来 // 注意:引擎会自动重置内部状态(pending 队列、change token 等) // 你需要手动清除本地存储中属于旧账户的数据 Task { await self.purgeAllLocalData() // 清除完毕后,引擎会自动拉取新账户的云端数据 self.updateUIForAccountSwitch(from: previousUser, to: currentUser) } case .signOut(let previousUser): // 用户登出:清除本地数据 // 类比:租客退租了,房间清空 // 注意:登出后引擎不会执行任何同步操作 Task { await self.purgeAllLocalData() self.updateUIForSignedOutUser() } } } /// 将所有本地数据注册为待发送变更(用于登录后上传) private func registerAllLocalChangesAsPending() { // 遍历本地存储中的所有记录,将它们注册为 pending save 变更 // 注意:这是账户登录后的典型操作,确保离线期间创建的数据能够上传 let allRecordIDs = self.localStore.allRecordIDs() let changes = allRecordIDs.map { CKSyncEngine.PendingRecordZoneChange.saveRecord($0) } self.syncEngine.state.add(pendingRecordZoneChanges: changes) } 构建自定义 SwiftData-CloudKit 同步库 2.1 设计目标 我们将构建一个"翻译官+快递员"系统。翻译官负责在 SwiftData 的语言和 CloudKit 的语言之间双向翻译——把 SwiftData 模型的属性映射为 CKRecord 的字段,把 CKRecord 的字段映射回 SwiftData 模型。快递员则负责搬运——将本地变更送上云端,将云端变更取回本地。CKSyncEngine 已经充当了快递员的角色,我们只需要搭建翻译官层并将两者对接。 这个同步库需要支持以下功能:自动监听 SwiftData 数据变化并转换为 CloudKit 记录发送;双向同步(本地到云端、云端到本地);冲突解决(当本地和服务端的同一记录被同时修改时);状态持久化以支持断点续传;Public Database 的独立查询支持;跨 App 数据共享;以及远程配置(Feature Flags)能力。 2.2 整体架构设计 ┌──────────────────────────────────────────────────┐ │ 你的 App │ │ ┌───────────┐ ┌─────────────────────────────┐ │ │ │ SwiftUI │ │ CloudKitSyncManager │ │ │ │ View │ │ ┌──────────────────────┐ │ │ │ │ │ │ │ SwiftData 监听层 │ │ │ │ │ @Query │ │ │ (变更检测) │ │ │ │ │ │ │ └──────────┬───────────┘ │ │ │ └───────────┘ │ | │ │ │ ┌───────────┐ │ v │ │ │ │ SwiftData │ │ ┌──────────────────────┐ │ │ │ │ Model │<->│ │ Model<->CKRecord │ │ │ │ │ │ │ │ 转换层 │ │ │ │ │ ModelContext│ │ └──────────┬───────────┘ │ │ │ └───────────┘ │ | │ │ │ │ v │ │ │ │ ┌──────────────────────┐ │ │ │ │ │ CKSyncEngine │ │ │ │ │ │ (同步调度) │ │ │ │ │ └──────────┬───────────┘ │ │ │ │ | │ │ │ │ v │ │ │ │ ┌──────────────────────┐ │ │ │ │ │ iCloud 服务器 │ │ │ │ │ └──────────────────────┘ │ │ │ └─────────────────────────────┘ │ └──────────────────────────────────────────────────┘ 架构从上到下分为四层。SwiftData 监听层负责检测本地数据的增删改,这是整个同步流程的起点。当 SwiftData 的 ModelContext 执行 save 操作时,监听层捕获变更信息,将新增和修改的模型转换为 pending record zone changes,注册到 CKSyncEngine 的状态中。Model 与 CKRecord 转换层是翻译官的核心,负责在两种数据格式之间双向转换。CKSyncEngine 层负责调度网络请求,与 iCloud 服务器通信。最底层的 iCloud 服务器是数据的远端存储。 2.3 协议设计:可同步模型协议 要让同步库知道如何处理每种 SwiftData 模型,我们需要定义一个协议。这个协议就像一份"国际快递报关单",定义了模型如何与 CloudKit 记录互相转换的所有必要信息。 import SwiftData import CloudKit /// 所有需要同步的 SwiftData 模型必须实现此协议 /// 类比:像一份"国际快递报关单",定义了模型如何与 CloudKit 记录互相转换 protocol CloudKitSyncable: PersistentModel { /// 该模型的 CloudKit RecordType 名称 // 注意:RecordType 一旦部署到生产环境就不可更改 static var recordType: String { get } /// 该模型所属的 RecordZone 名称 // 注意:同一类型的记录应放在同一个 zone 中,便于批量拉取 static var zoneName: String { get } /// 模型的唯一标识符(用作 CKRecord 的 recordName) var syncID: String { get } /// 将模型数据写入 CKRecord // 注意:如果传入了 existingRecord,应在其基础上修改而非创建新记录 // 这样可以保留 recordChangeTag 等系统字段,避免冲突 func toCKRecord(existingRecord: CKRecord?) -> CKRecord /// 从 CKRecord 读取数据并更新模型 // 注意:这个方法是 mutating 的,因为要修改模型属性 mutating func update(from record: CKRecord) /// 用于冲突解决的合并策略 /// 返回 true 表示本地需要重新提交,false 表示接受服务端版本 func merge(with serverRecord: CKRecord) -> Bool } 这个协议继承自 PersistentModel(SwiftData 的基础协议),意味着所有实现它的类型天然具备 SwiftData 的持久化能力。recordType 和 zoneName 是类属性,因为它们与具体的实例无关——同一种模型的所有记录共享同一个 recordType 和 zoneName。syncID 是实例属性,每条记录有自己唯一的标识符。toCKRecord 方法接受一个可选的 existingRecord 参数:如果传入 nil,表示创建新记录;如果传入已有记录,表示在其基础上更新——这种设计确保了 recordChangeTag 等系统字段得以保留。update(from:) 是 mutating 方法,用于从 CKRecord 反向更新模型属性。merge(with:) 方法在冲突解决时被调用,返回布尔值表示是否需要重新提交本地版本。 下面是一个实现该协议的 SwiftData 模型示例: /// 笔记模型 - 实现 CloudKitSyncable 协议 @Model final class Note: CloudKitSyncable { static var recordType: String { "Note" } static var zoneName: String { "NoteZone" } var syncID: String { id.uuidString } var id: UUID var title: String var content: String var modifiedAt: Date var createdAt: Date init(id: UUID = UUID(), title: String, content: String) { self.id = id self.title = title self.content = content self.modifiedAt = Date() self.createdAt = Date() } func toCKRecord(existingRecord: CKRecord?) -> CKRecord { // 类比:把中文翻译成英文,让外国海关能看懂 let recordID = CKRecord.ID( recordName: syncID, zoneID: CKRecordZone.ID(zoneName: Note.zoneName, ownerName: CKCurrentUserDefaultName) ) // 注意:复用 existingRecord 以保留 recordChangeTag 等系统字段 let record = existingRecord ?? CKRecord(recordType: Note.recordType, recordID: recordID) record["title"] = title as NSString record["content"] = content as NSString record["modifiedAt"] = modifiedAt as NSDate record["createdAt"] = createdAt as NSDate return record } mutating func update(from record: CKRecord) { // 类比:把英文翻译回中文 // 注意:从 CKRecord 读取字段时,类型转换失败要有默认值 self.title = record["title"] as? String ?? "" self.content = record["content"] as? String ?? "" self.modifiedAt = record["modifiedAt"] as? Date ?? Date() self.createdAt = record["createdAt"] as? Date ?? Date() } func merge(with serverRecord: CKRecord) -> Bool { // 策略:按修改时间决定谁优先 // 类比:Git 的 merge,逐行比较保留两边的修改 let serverModDate = serverRecord["modifiedAt"] as? Date ?? .distantPast if serverModDate > self.modifiedAt { // 服务端更新,接受服务端版本,不需要重新提交 return false } else { // 本地更新,需要重新提交本地版本 return true } } } 2.4 SwiftData 变更监听 要让同步库自动感知本地数据变化,我们需要在 SwiftData 的 ModelContext 上安装一个"监控摄像头"。每当有人执行了 save 操作——无论是新增一条笔记、修改标题还是删除记录——摄像头都会自动记录下来,并通知同步引擎。 SwiftData 的 ModelContext 在底层使用 Core Data 实现,因此我们可以利用 Core Data 的 NSManagedObjectContextDidSave 通知来监听变更。这个通知的 userInfo 字典中包含了三组变更集合:inserted(新增的对象)、updated(修改的对象)和 deleted(删除的对象)。 /// 设置 SwiftData 变更监听 /// 类比:在银行装监控摄像头,每当有人存取款时自动记录 private func setupSwiftDataObserver() { // 监听 ModelContext 的保存通知 // 注意:SwiftData 的 ModelContext 底层使用 Core Data, // 所以可以使用 NSManagedObjectContextDidSave 通知 NotificationCenter.default.addObserver( forName: .NSManagedObjectContextDidSave, object: modelContext, queue: .main ) { [weak self] notification in guard let self = self else { return } // 从通知中提取变更 let inserted = notification.userInfo?["inserted"] as? Set ?? [] let updated = notification.userInfo?["updated"] as? Set ?? [] let deleted = notification.userInfo?["deleted"] as? Set ?? [] // 将变更注册到 CKSyncEngine self.processLocalChanges(inserted: inserted, updated: updated, deleted: deleted) } } /// 处理本地变更,将其注册为 CKSyncEngine 的 pending changes private func processLocalChanges( inserted: Set, updated: Set, deleted: Set ) { var pendingChanges: [CKSyncEngine.PendingRecordZoneChange] = [] // 新增和修改都对应 .saveRecord for managedObject in inserted { if let syncable = managedObject as? CloudKitSyncable { pendingChanges.append(.saveRecord( CKRecord.ID( recordName: syncable.syncID, zoneID: CKRecordZone.ID( zoneName: type(of: syncable).zoneName, ownerName: CKCurrentUserDefaultName ) ) )) } } for managedObject in updated { if let syncable = managedObject as? CloudKitSyncable { pendingChanges.append(.saveRecord( CKRecord.ID( recordName: syncable.syncID, zoneID: CKRecordZone.ID( zoneName: type(of: syncable).zoneName, ownerName: CKCurrentUserDefaultName ) ) )) } } // 删除对应 .deleteRecord for managedObject in deleted { if let syncable = managedObject as? CloudKitSyncable { pendingChanges.append(.deleteRecord( CKRecord.ID( recordName: syncable.syncID, zoneID: CKRecordZone.ID( zoneName: type(of: syncable).zoneName, ownerName: CKCurrentUserDefaultName ) ) )) } } // 批量注册到同步引擎 // 注意:add 方法会触发引擎自动调度发送(如果 automaticallySync 为 true) if !pendingChanges.isEmpty { syncEngine.state.add(pendingRecordZoneChanges: pendingChanges) } } 需要注意,SwiftData 在 iOS 17 中也可以使用 ModelContext 的原生 API 进行更精细的变更追踪,但 NSManagedObjectContextDidSave 通知依然是最可靠且广泛使用的方式。使用 [weak self] 避免循环引用也是必须的。 2.5 Model 与 CKRecord 转换层 转换层是翻译官的核心。它负责将 SwiftData 模型对象转换为 CloudKit 的 CKRecord,以及将 CKRecord 转换回 SwiftData 模型。虽然我们在协议中已经让每个模型自行实现 toCKRecord 和 update(from:),但还需要一个协调层来处理 zone ID 构造、已有记录复用等通用逻辑。 /// 将 SwiftData 模型转换为 CKRecord /// 类比:把 SwiftData 的"本地语言"翻译成 CloudKit 的"云端语言" func convertToCKRecord( _ model: T, existing: CKRecord? = nil ) -> CKRecord { let recordID = CKRecord.ID( recordName: model.syncID, zoneID: CKRecordZone.ID(zoneName: T.zoneName, ownerName: CKCurrentUserDefaultName) ) // 如果有已知的云端记录,在其基础上修改 // 注意:保留系统字段(如 recordChangeTag)是避免 serverRecordChanged 冲突的关键 let record = existing ?? CKRecord(recordType: T.recordType, recordID: recordID) // 调用模型自身的转换方法 return model.toCKRecord(existingRecord: record) } /// 将 CKRecord 转换回 SwiftData 模型并保存 /// 类比:把"云端语言"翻译回"本地语言" func mergeRecordFromServer( _ record: CKRecord, as modelType: T.Type, in context: ModelContext ) { // 尝试在本地存储中查找是否已有该记录 let fetchDescriptor = FetchDescriptor( predicate: #Predicate { $0.syncID == record.recordID.recordName } ) if let existingModel = try? context.fetch(fetchDescriptor).first { // 记录已存在,用服务端数据更新本地 // 注意:这里需要处理可变引用,因为 SwiftData @Model 是引用类型 existingModel.update(from: record) } else { // 记录不存在,创建新记录 // 注意:需要先创建默认实例,再从 CKRecord 更新 var newModel = T.createDefault() newModel.update(from: record) context.insert(newModel) } // 保存变更到本地存储 try? context.save() } /// CKRecord 系统字段的编码与存储 /// 类比:给每件货物贴上"上次通过海关的印章",下次通关时出示印章可以快速通过 func updateLastKnownRecord(_ record: CKRecord) { // 注意:encodeSystemFields 将 recordChangeTag 等系统字段编码为 Data // 下次保存该记录时,需要用这些字段创建 CKRecord,否则服务端会拒绝 let encoded = record.encodeSystemFields(with: NSKeyedArchiver()) lastKnownRecords[record.recordID.recordName] = record // 在实际项目中,建议将 encoded Data 持久化到本地存储 } 这里特别要强调 encodeSystemFields(with:) 的重要性。每条 CKRecord 在服务端都有一个 recordChangeTag 字段,它是乐观并发控制(optimistic concurrency control)的关键。当你保存记录时,服务端会检查你提交的 recordChangeTag 是否与其当前版本一致——如果一致,保存成功并更新 tag;如果不一致(说明在你拉取之后、提交之前,有其他设备修改了该记录),服务端返回 serverRecordChanged 错误。因此,每次成功保存后,你必须用返回的最新 CKRecord 更新本地缓存的系统字段,下次保存时复用这些字段。 2.6 冲突解决策略 冲突是分布式系统中最棘手的问题之一。当两台设备同时修改了同一条记录,并各自尝试保存到 CloudKit 时,后保存的一方会收到 serverRecordChanged 错误。这就好比两个人同时编辑同一份文档时的"合并 vs 覆盖"决策——你需要一套明确的策略来处理。 CKError.Code.serverRecordChanged 错误的关联值中包含一个 serverRecord 属性,它是服务端当前的记录版本。通过比较本地版本和服务端版本,你可以决定采用哪种策略。 三种常见策略各有适用场景。客户端优先(Client wins)适用于单用户设备——用户只在一台设备上操作,冲突极少发生,即使发生也以本地为准。服务端优先(Server wins)适用于只读为主的场景——本地修改的频率低,直接接受服务端版本更简单。自定义合并(Custom merge)适用于多设备协作编辑——需要逐字段比较,保留两端的修改,类似 Git 的 merge。 /// 处理 serverRecordChanged 冲突 /// 类比:两个人同时编辑同一份文档,需要决定以谁的版本为准 func handleFailedRecordSave(_ failedSave: CKSyncEngine.Event.SentRecordZoneChanges.FailedRecordSave) { let localRecord = failedSave.record let error = failedSave.error // 检查是否为冲突错误 // 注意:error 是 CKError 类型,可以直接检查 code guard error.code == .serverRecordChanged else { // 非冲突错误,交给通用错误处理 handleGenericError(error, for: localRecord) return } // 从错误中获取服务端版本的记录 guard let serverRecord = error.serverRecord else { // 注意:serverRecordChanged 错误理论上一定有 serverRecord, // 但防御性编程总是好的 return } // 策略:自定义字段级合并 // 类比:像 Git 的 merge,逐行比较保留两边的修改 let serverModDate = serverRecord["modifiedAt"] as? Date let localModDate = localRecord["modifiedAt"] as? Date if let serverModDate, let localModDate { if serverModDate > localModDate { // 服务端更新,接受服务端版本 // 注意:这里可以选择性地保留本地某些字段 // 比如保留本地的"草稿标记"字段 self.acceptServerRecord(serverRecord) } else { // 本地更新,重新提交本地版本 // 注意:需要用 serverRecord 的 recordChangeTag 重新创建记录 let mergedRecord = createMergedRecord(local: localRecord, server: serverRecord) self.syncEngine.state.add( pendingRecordZoneChanges: [.saveRecord(mergedRecord.recordID)] ) // 更新本地缓存的系统字段 self.lastKnownRecords[mergedRecord.recordID.recordName] = mergedRecord } } else { // 缺少修改时间字段,默认采用服务端优先策略 self.acceptServerRecord(serverRecord) } } /// 创建合并后的记录(保留服务端的 recordChangeTag + 使用本地的数据) private func createMergedRecord(local: CKRecord, server: CKRecord) -> CKRecord { // 注意:不能创建新的 CKRecord,因为新记录没有 recordChangeTag, // 服务端会将其视为新记录而非更新,导致再次冲突 // 正确做法是直接在 server record 上修改业务字段, // 这样 recordChangeTag 等系统字段会保留 // 类比:在对方已经盖章的文件上修改内容,而不是重新写一份 for key in local.allKeys() { server[key] = local[key] } return server } 2.7 错误处理与重试机制 CKSyncEngine 在错误处理方面做了大量自动化工作。对于以下瞬时错误(transient errors),引擎会自动重试,你完全不需要操心:networkFailure(网络故障)、networkUnavailable(网络不可用)、requestRateLimited(请求被限流)、serviceUnavailable(服务不可用)、zoneBusy(zone 繁忙)、notAuthenticated(未认证)、accountTemporarilyUnavailable(账户临时不可用)。引擎会在系统条件好转时自动重试,对于 requestRateLimited 错误,还会尊重服务端返回的 retryAfterSeconds 值,在指定时间后再重试。 但有一类错误需要你手动处理,因为它们需要应用特定的业务逻辑: serverRecordChanged:冲突错误,已在 2.6 节详细讲解。 zoneNotFound:你尝试操作的 zone 在服务端不存在。通常发生在服务端 zone 被删除(比如用户在其他设备上删除了所有数据)但本地仍在尝试同步时。处理方式是重新创建 zone 并重新注册所有本地记录为 pending changes。 unknownItem:你尝试修改或删除的记录在服务端不存在。通常发生在记录已被其他设备删除但本地还在尝试同步时。处理方式是清除本地对该记录的引用,或者重新创建记录。 /// 通用错误处理 func handleGenericError(_ error: CKError, for record: CKRecord) { switch error.code { case .zoneNotFound: // Zone 在服务端不存在,需要重新创建 // 类比:目的地的仓库被拆了,需要重新建一个再发货 // 注意:重新创建 zone 后,需要将所有本地记录重新注册为 pending self.handleZoneNotFound(record.recordID.zoneID) case .unknownItem: // 记录在服务端已不存在 // 类比:你要修改的货物已经被别人扔掉了 // 注意:需要判断是重新上传还是删除本地记录 self.handleUnknownItem(record) case .limitExceeded: // 注意:正常使用 RecordZoneChangeBatch 不会触发此错误, // 因为 batch 会自动遵守 250 条限制 // 如果仍然出现,说明 batch 构建逻辑有 bug print("limitExceeded 错误,检查 batch 构建逻辑") default: // 其他未预期的错误 // 注意:CKError 的 retryAfterSeconds 属性可用于需要手动重试的场景 if let retryAfter = error.retryAfterSeconds { print("错误: \(error.code.rawValue),建议 \(retryAfter) 秒后重试") Task { try? await Task.sleep(nanoseconds: UInt64(retryAfter * 1_000_000_000)) // 重试 self.syncEngine.state.add( pendingRecordZoneChanges: [.saveRecord(record.recordID)] ) } } else { print("未处理的 CKError: \(error.code.rawValue) - \(error.localizedDescription)") } } } /// 处理 zoneNotFound 错误 private func handleZoneNotFound(_ zoneID: CKRecordZone.ID) { // 重新创建 zone let pendingChange: CKSyncEngine.PendingDatabaseChange = .saveRecordZone(zoneID) syncEngine.state.add(pendingDatabaseChanges: [pendingChange]) // 将该 zone 下所有本地记录重新注册为 pending let allRecordIDs = localStore.allRecordIDs(in: zoneID.zoneName) let changes = allRecordIDs.map { CKSyncEngine.PendingRecordZoneChange.saveRecord( CKRecord.ID(recordName: $0, zoneID: zoneID) ) } syncEngine.state.add(pendingRecordZoneChanges: changes) } /// 处理 unknownItem 错误 private func handleUnknownItem(_ record: CKRecord) { // 如果本地记录仍然存在,说明需要重新创建服务端记录 // 如果本地记录已被删除,说明是删除操作遇到了 unknownItem,可以忽略 if localStore.find(recordID: record.recordID.recordName) != nil { // 重新注册为 pending save syncEngine.state.add( pendingRecordZoneChanges: [.saveRecord(record.recordID)] ) } // 否则忽略:本地已删除,服务端也删除了,无需操作 } 2.8 同步状态持久化 状态持久化是断点续传的基础。每次引擎发出 stateUpdate 事件时,你都需要将 stateSerialization 写入磁盘。App 启动时读取这个文件并传给 Configuration,引擎就能从上次中断的地方继续同步。 // 保存状态 // 类比:物流中心每天下班前把台账存档 func saveSyncState(_ state: CKSyncEngine.State.Serialization) { let url = getStateURL() do { let data = try JSONEncoder().encode(state) try data.write(to: url, options: [.atomic]) } catch { // 注意:状态保存失败不应崩溃,但应记录日志 print("保存同步状态失败: \(error)") } } // 恢复状态 // 类比:第二天开门时翻出昨天的台账 func loadSyncState() -> CKSyncEngine.State.Serialization? { let url = getStateURL() guard let data = try? Data(contentsOf: url) else { return nil } // 注意:如果反序列化失败(比如 App 更新后数据格式变化), // 返回 nil 让引擎从头开始同步 return try? JSONDecoder().decode( CKSyncEngine.State.Serialization.self, from: data ) } // 获取状态文件路径 private func getStateURL() -> URL { let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] return docsDir.appendingPathComponent("cloudkit_sync_state.json") } 需要注意状态文件的原子写入(options: [.atomic]),这样可以防止写入过程中被中断导致文件损坏。另外,CKSyncEngine.State.Serialization 实现了 Codable 协议,但不保证其编码格式在未来的 iOS 版本中保持不变。如果反序列化失败,返回 nil 是最安全的做法——引擎会从头开始同步,虽然第一次同步会慢一些,但数据不会丢失。 高级实战功能 3.1 跨 App 数据访问 跨 App 数据访问就像同一个房东的不同房子可以互相串门——只要你能证明你是房东(拥有同一个 iCloud Container 的访问权限),就可以在不同的 App 之间共享 CloudKit 数据。 典型应用场景是:你有两个 App,比如 Music Mate(管理歌曲信息)和 Setlists(管理演出曲目单),Setlists 需要读取 Music Mate 存储在 Public Database 中的歌曲数据。实现这个功能只需要两步。 第一步,在 Xcode 的 Signing & Capabilities 中,为 Setlists 勾选 Music Mate 的 iCloud Container。这样 Setlists 就获得了访问该 Container 的权限。 第二步,在代码中通过 Container ID 创建 CKContainer 实例,然后访问其 Public Database。 // 在 App B(Setlists)中访问 App A(Music Mate)的 Public Database // 类比:拿着房东给的钥匙,打开另一栋房子的门 let container = CKContainer(identifier: "iCloud.com.example.MusicMate") // 注意:Container ID 必须与 App A 在 Xcode 中配置的完全一致 // 可以在 App A 的 Signing & Capabilities -> iCloud 中找到 // 查询 App A 的 Public Database 中的歌曲数据 let predicate = NSPredicate(value: true) // 查询所有记录 let query = CKQuery(recordType: "Song", predicate: predicate) let operation = CKQueryOperation(query: query) operation.resultsLimit = 50 // 注意:跨 App 访问 Public Database 不需要用户登录同一个 iCloud 账号 // Public Database 的数据对所有有 Container 访问权限的 App 可见 container.publicCloudDatabase.add(operation) 需要注意,跨 App 数据访问仅适用于 Public Database。Private Database 的数据与用户的 iCloud 账户绑定,不同 App 即使共享同一个 Container,也只能访问各自创建的 Private Database 数据。如果你需要在多个 App 之间共享 Private Database 数据,应该考虑使用 CKShare(共享记录)机制。 3.2 远程配置(Feature Flags) 远程配置就像一个远程开关面板——你不需要发版更新 App,就能在服务端控制功能的开关。这在灰度发布、紧急下线功能、A/B 测试等场景中非常有用。 实现方式很简单:在 Public Database 中建一张配置表,App 启动时(或定期)拉取这张表的最新值,根据配置值决定是否启用某个功能。 /// 远程配置数据结构 struct FeatureFlags { var enableNewFeature: Bool // 是否启用新功能 var maxItemsPerList: Int // 每个列表的最大项目数 var maintenanceMode: Bool // 是否处于维护模式 } /// 从 Public Database 拉取远程配置 /// 类比:从公告栏上读取最新的运营指令 func fetchFeatureFlags() async throws -> FeatureFlags { // 注意:配置记录放在 Public Database 中,所有用户共享同一份配置 // recordName 使用固定值 "feature_flags",确保全局只有一份配置 let recordID = CKRecord.ID(recordName: "feature_flags") let record = try await container.publicCloudDatabase.record(for: recordID) // 注意:所有字段都使用 nil 合并运算符提供默认值, // 防止服务端配置缺失时 App 崩溃 return FeatureFlags( enableNewFeature: record["enableNewFeature"] as? Bool ?? false, maxItemsPerList: record["maxItemsPerList"] as? Int ?? 100, maintenanceMode: record["maintenanceMode"] as? Bool ?? false ) } /// 在 App 中使用远程配置 func applyFeatureFlags() { Task { do { let flags = try await fetchFeatureFlags() // 注意:UI 更新必须在主线程执行 await MainActor.run { if flags.maintenanceMode { // 显示维护模式页面 self.showMaintenanceView() } if flags.enableNewFeature { // 启用新功能入口 self.showNewFeatureEntry() } // 限制列表项目数 self.maxItemsPerList = flags.maxItemsPerList } } catch { // 注意:拉取配置失败时使用本地默认值,不应阻塞 App 启动 print("拉取远程配置失败,使用默认值: \(error)") } } } 在实际项目中,建议将 Feature Flags 的拉取结果缓存到本地(UserDefaults 或 SwiftData),这样即使下次启动时网络不可用,也能使用上次的配置值。同时可以配合定时轮询或静默推送,在配置变更时及时更新。 3.3 Public Database 查询优化 Public Database 中可能存储了大量数据,一次性全部加载既慢又耗内存。最佳实践是按需加载——先加载少量数据展示给用户,然后在后台持续请求剩余数据。这就像看新闻时先看标题列表,感兴趣的再点开看全文。 CloudKit 的 CKQueryOperation 通过 cursor 机制支持分页。每次查询返回一批结果和一个 cursor,如果还有更多数据,cursor 不为 nil。你可以用这个 cursor 创建新的 CKQueryOperation 继续查询下一页。 /// 分页加载 Public Database 数据 /// 类比:看新闻时先看标题列表,感兴趣的再点开看全文 class PaginationLoader { private let container: CKContainer private var currentCursor: CKQueryOperation.Cursor? private var allResults: [CKRecord] = [] private var isFetching = false init(container: CKContainer) { self.container = container } /// 加载第一页 func loadFirstPage(recordType: String) async throws -> [CKRecord] { allResults = [] currentCursor = nil return try await loadNextPage(recordType: recordType) } /// 加载下一页 func loadNextPage(recordType: String) async throws -> [CKRecord] { guard !isFetching else { return [] } isFetching = true defer { isFetching = false } let query = CKQuery( recordType: recordType, predicate: NSPredicate(value: true) ) // 注意:每次使用 cursor 或 query 创建 operation,但不能同时使用 let operation: CKQueryOperation if let cursor = currentCursor { // 有 cursor,从上次的位置继续查询 operation = CKQueryOperation(cursor: cursor) } else { // 没有 cursor,从头开始查询 operation = CKQueryOperation(query: query) } // 注意:resultsLimit 控制每页的大小,建议设为 50-100 operation.resultsLimit = 50 // 使用 Continuation 将回调式 API 转为 async/await // 注意:continuation 返回元组 (结果数组, 游标) let (pageResults, nextCursor) = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<([CKRecord], CKQueryOperation.Cursor?), Error>) in var results: [CKRecord] = [] operation.recordMatchedBlock = { _, result in switch result { case .success(let record): results.append(record) case .failure(let error): // 注意:单条记录查询失败不应中断整个分页 print("记录查询失败: \(error)") } } operation.queryResultBlock = { result in switch result { case .success(let cursor): // 注意:cursor 为 nil 表示没有更多数据了 continuation.resume(returning: (results, cursor)) case .failure(let error): continuation.resume(throwing: error) } } self.container.publicCloudDatabase.add(operation) } // 保存 cursor 以便下次加载下一页 self.currentCursor = nextCursor allResults.append(contentsOf: pageResults) return pageResults } /// 是否还有更多数据可以加载 var hasMoreData: Bool { currentCursor != nil } } 3.4 地理位置查询 CloudKit 提供了一个独特的地理位置查询功能:distanceToLocation。这个函数可以计算两个地理位置之间的距离,并用于排序和过滤。典型应用场景包括"查找附近的用户"、"查找附近的店铺"等。 使用时需要在 CKRecord 中存储 CLLocation 类型的字段,然后在查询中使用 distanceToLocation:fromLocation: 函数进行过滤和排序。 /// 地理位置查询:查找附近的地点 /// 类比:打开地图 App 搜索"附近的咖啡店",按距离从近到远排列 func fetchNearbyLocations( center: CLLocation, radiusInMeters: Double = 5000 // 默认搜索半径 5 公里 ) async throws -> [CKRecord] { let container = CKContainer.default() // 注意:distanceToLocation 是 CloudKit 特有的 NSPredicate 函数 // 用于计算 CKRecord 中存储的 CLLocation 字段与指定位置的距离 let predicate = NSPredicate( format: "distanceToLocation:fromLocation:(location, %@) < %f", center, radiusInMeters ) // 注意:location 是 CKRecord 中存储 CLLocation 的字段名 // 你需要在 CloudKit Dashboard 中确保该字段类型为 Location let query = CKQuery(recordType: "Venue", predicate: predicate) // 注意:CloudKit 不支持基于闭包的自定义排序 // 可以按 modificationDate 等系统字段排序,距离排序需要在客户端完成 query.sortDescriptors = [NSSortDescriptor(key: "modificationDate", ascending: false)] let operation = CKQueryOperation(query: query) operation.resultsLimit = 20 // 限制返回数量 let results: [CKRecord] = try await withCheckedThrowingContinuation { continuation in var records: [CKRecord] = [] operation.recordMatchedBlock = { _, result in if case .success(let record) = result { records.append(record) } } operation.queryResultBlock = { result in switch result { case .success: continuation.resume(returning: records) case .failure(let error): continuation.resume(throwing: error) } } container.publicCloudDatabase.add(operation) } // 在客户端按距离排序(CloudKit 不支持服务端按距离排序) return results.sorted { record1, record2 in let loc1 = record1["location"] as? CLLocation let loc2 = record2["location"] as? CLLocation let dist1 = loc1?.distance(from: center) ?? Double.infinity let dist2 = loc2?.distance(from: center) ?? Double.infinity return dist1 < dist2 } } /// 保存带有地理位置的记录 func saveVenue(name: String, location: CLLocation) async throws { let container = CKContainer.default() let recordID = CKRecord.ID(recordName: UUID().uuidString) let record = CKRecord(recordType: "Venue", recordID: recordID) // 注意:CLLocation 字段必须存储为 CLLocation 类型 record["name"] = name as CKRecordValue // CLLocation 本身符合 CKRecordValue 协议,可直接赋值 record["location"] = location try await container.publicCloudDatabase.save(record) } 3.5 Schema 不可回滚的应对策略 CloudKit 的 Schema 有一个严格的限制:一旦部署到生产环境,字段只能增加不能删除,也不能修改字段类型。这就像一栋已经验收的大楼——你可以加盖新楼层,但不能拆掉已有的承重墙。 当你发现某个字段不再需要时,不能直接从模型中删除它——因为生产环境中的记录仍然包含这个字段,如果 App 代码不再处理它,可能导致问题。正确的做法是将该字段标记为"废弃"(deprecated),在业务代码中不再使用它,但在模型定义中保留它。 @Model final class UserProfile: CloudKitSyncable { static var recordType: String { "UserProfile" } static var zoneName: String { "UserProfileZone" } var syncID: String { id.uuidString } var id: UUID var displayName: String var avatarURL: String? // 废弃字段:emailAddress // 注意:此字段已废弃,不再在业务逻辑中使用 // 但不能从 Schema 中删除,因为生产环境的记录中仍然包含该字段 var emailAddress: String? init(id: UUID = UUID(), displayName: String, avatarURL: String? = nil) { self.id = id self.displayName = displayName self.avatarURL = avatarURL // 注意:废弃字段设为 nil,新创建的记录不再填充该字段 self.emailAddress = nil } func toCKRecord(existingRecord: CKRecord?) -> CKRecord { let recordID = CKRecord.ID( recordName: syncID, zoneID: CKRecordZone.ID(zoneName: UserProfile.zoneName, ownerName: CKCurrentUserDefaultName) ) let record = existingRecord ?? CKRecord(recordType: UserProfile.recordType, recordID: recordID) record["displayName"] = displayName as NSString record["avatarURL"] = avatarURL as NSString? // 注意:不再写入 emailAddress 字段 // 如果 existingRecord 中已有该字段的值,服务端会保留它 // 但新保存的记录不会更新该字段 return record } mutating func update(from record: CKRecord) { self.displayName = record["displayName"] as? String ?? "" self.avatarURL = record["avatarURL"] as? String // 废弃字段处理 // 注意:仍然读取该字段以防旧数据中有值,但不在业务逻辑中使用 let _ = record["emailAddress"] as? String ?? "" // 不再在业务逻辑中使用 emailAddress } func merge(with serverRecord: CKRecord) -> Bool { let serverModDate = serverRecord["modifiedAt"] as? Date ?? .distantPast return serverModDate <= self.modifiedAt } var modifiedAt: Date = Date() } 如果确实需要从根本上移除某个字段,唯一的方法是创建一个全新的 RecordType,将数据迁移过去,然后废弃旧的 RecordType。但这通常代价很高,不值得为了清理一两个字段而执行。 完整同步库实现 现在,我们将前面所有组件整合为一个完整的同步库框架。这个框架可以直接作为项目的基础,根据实际需求进行扩展。 import Foundation import CloudKit import SwiftData import Observation /// 同步状态枚举 enum SyncState: Equatable { case idle // 空闲 case fetching // 正在拉取 case sending // 正在发送 case error(String) // 出错 } /// CloudKitSyncManager - 自定义 SwiftData-CloudKit 同步库的核心管理器 /// 类比:一个全自动的物流中心,负责本地仓库(SwiftData)和云端仓库(CloudKit)之间的双向运输 @MainActor class CloudKitSyncManager: ObservableObject { // MARK: - 属性 let container: CKContainer let modelContainer: ModelContainer let modelContext: ModelContext // 注意:syncEngine 使用隐式解包可选,因为它的初始化依赖于 self(作为 delegate) // 在 init 中所有非可选属性赋值完成后,self 才可用 private(set) var syncEngine: CKSyncEngine! @Published var syncState: SyncState = .idle // 存储每个记录的 lastKnownRecord,用于冲突检测 // 类比:每件货物的"上次通过海关的印章" private var lastKnownRecords: [String: CKRecord] = [:] // 缓存的同步状态序列化数据 private var stateSerialization: CKSyncEngine.State.Serialization? // MARK: - 初始化 init(modelContainer: ModelContainer, containerID: String) { self.container = CKContainer(identifier: containerID) self.modelContainer = modelContainer self.modelContext = modelContainer.mainContext // 恢复上次保存的同步状态 let savedState = Self.loadSyncState() // 创建 Configuration // 注意:delegate 参数是必填的,不能传 nil // 此时 self 的所有非可选存储属性已初始化完毕,可以安全引用 self var config = CKSyncEngine.Configuration( database: container.privateCloudDatabase, stateSerialization: savedState, delegate: self // CloudKitSyncManager 自身实现 CKSyncEngineDelegate ) config.automaticallySync = true self.syncEngine = CKSyncEngine(config) // 确保同步所需的 zone 存在 self.ensureZonesExist() // 开始监听 SwiftData 变更 self.setupSwiftDataObserver() } // MARK: - Zone 管理 /// 确保所有需要的 zone 都已创建 private func ensureZonesExist() { // 类比:物流中心开张前先确认所有仓库都建好了 // 注意:zone 创建是幂等操作,重复创建已存在的 zone 会返回错误但无副作用 let zoneIDs = Self.allZoneIDs() let changes = zoneIDs.map { CKSyncEngine.PendingDatabaseChange.saveRecordZone($0) } syncEngine.state.add(pendingDatabaseChanges: changes) } /// 获取所有需要同步的 zone ID private static func allZoneIDs() -> [CKRecordZone.ID] { // 注意:这里需要列出所有 CloudKitSyncable 模型的 zoneName // 在实际项目中,可以通过反射或注册机制自动收集 return [ CKRecordZone.ID(zoneName: "NoteZone", ownerName: CKCurrentUserDefaultName), ] } // MARK: - SwiftData 变更监听 /// 设置 SwiftData 变更监听 /// 类比:在银行装监控摄像头,每当有人存取款时自动记录 private func setupSwiftDataObserver() { NotificationCenter.default.addObserver( forName: .NSManagedObjectContextDidSave, object: modelContext, queue: .main ) { [weak self] notification in guard let self = self else { return } let inserted = notification.userInfo?["inserted"] as? Set ?? [] let updated = notification.userInfo?["updated"] as? Set ?? [] let deleted = notification.userInfo?["deleted"] as? Set ?? [] self.processLocalChanges(inserted: inserted, updated: updated, deleted: deleted) } } /// 处理本地变更,注册为 pending changes private func processLocalChanges( inserted: Set, updated: Set, deleted: Set ) { var pendingChanges: [CKSyncEngine.PendingRecordZoneChange] = [] for managedObject in inserted { guard let syncable = managedObject as? CloudKitSyncable else { continue } pendingChanges.append(.saveRecord( CKRecord.ID( recordName: syncable.syncID, zoneID: CKRecordZone.ID( zoneName: type(of: syncable).zoneName, ownerName: CKCurrentUserDefaultName ) ) )) } for managedObject in updated { guard let syncable = managedObject as? CloudKitSyncable else { continue } pendingChanges.append(.saveRecord( CKRecord.ID( recordName: syncable.syncID, zoneID: CKRecordZone.ID( zoneName: type(of: syncable).zoneName, ownerName: CKCurrentUserDefaultName ) ) )) } for managedObject in deleted { guard let syncable = managedObject as? CloudKitSyncable else { continue } pendingChanges.append(.deleteRecord( CKRecord.ID( recordName: syncable.syncID, zoneID: CKRecordZone.ID( zoneName: type(of: syncable).zoneName, ownerName: CKCurrentUserDefaultName ) ) )) } if !pendingChanges.isEmpty { syncEngine.state.add(pendingRecordZoneChanges: pendingChanges) } } // MARK: - Model <-> CKRecord 转换 /// 将 SwiftData 模型转换为 CKRecord private func convertToCKRecord( _ model: T, existing: CKRecord? = nil ) -> CKRecord { let recordID = CKRecord.ID( recordName: model.syncID, zoneID: CKRecordZone.ID(zoneName: T.zoneName, ownerName: CKCurrentUserDefaultName) ) let record = existing ?? CKRecord(recordType: T.recordType, recordID: recordID) return model.toCKRecord(existingRecord: record) } /// 将服务器拉取的 CKRecord 合并到本地 SwiftData 存储 private func mergeRecordFromServer(_ record: CKRecord) { // 注意:根据 recordType 分发到对应的模型处理器 // 在实际项目中,可以使用字典映射 recordType -> 处理函数 switch record.recordType { case "Note": mergeNoteRecord(record) default: print("未知的 recordType: \(record.recordType)") } } /// 合并 Note 记录 private func mergeNoteRecord(_ record: CKRecord) { let fetchDescriptor = FetchDescriptor( predicate: #Predicate { $0.syncID == record.recordID.recordName } ) if let existingNote = try? modelContext.fetch(fetchDescriptor).first { // 记录已存在,用服务端数据更新 existingNote.update(from: record) } else { // 注意:记录不存在,创建新记录并插入 let newNote = Note( id: UUID(uuidString: record.recordID.recordName) ?? UUID(), title: record["title"] as? String ?? "", content: record["content"] as? String ?? "" ) newNote.update(from: record) modelContext.insert(newNote) } // 更新 lastKnownRecord lastKnownRecords[record.recordID.recordName] = record try? modelContext.save() } /// 从本地存储删除记录 private func deleteLocalRecord(_ recordID: CKRecord.ID) { switch recordID.zoneID.zoneName { case "NoteZone": let fetchDescriptor = FetchDescriptor( predicate: #Predicate { $0.syncID == recordID.recordName } ) if let note = try? modelContext.fetch(fetchDescriptor).first { modelContext.delete(note) try? modelContext.save() } default: break } // 清理缓存的系统字段 lastKnownRecords.removeValue(forKey: recordID.recordName) } // MARK: - 冲突处理 /// 处理发送失败的记录 private func handleFailedRecordSave( _ failedSave: CKSyncEngine.Event.SentRecordZoneChanges.FailedRecordSave ) { let localRecord = failedSave.record let error = failedSave.error guard error.code == .serverRecordChanged else { handleGenericError(error, for: localRecord) return } guard let serverRecord = error.serverRecord else { return } // 自定义合并策略:按修改时间决定优先级 let serverModDate = serverRecord["modifiedAt"] as? Date let localModDate = localRecord["modifiedAt"] as? Date if let serverModDate, let localModDate, serverModDate <= localModDate { // 本地更新,用服务端的 recordChangeTag 重新提交 let mergedRecord = createMergedRecord(local: localRecord, server: serverRecord) syncEngine.state.add( pendingRecordZoneChanges: [.saveRecord(mergedRecord.recordID)] ) lastKnownRecords[mergedRecord.recordID.recordName] = mergedRecord } // 否则接受服务端版本,不做事 } /// 创建合并后的记录 private func createMergedRecord(local: CKRecord, server: CKRecord) -> CKRecord { // 注意:直接在 server record 上修改业务字段,保留其 recordChangeTag 等系统字段 // 不能创建新的 CKRecord,否则会丢失 recordChangeTag 导致再次冲突 for key in local.allKeys() { server[key] = local[key] } return server } /// 通用错误处理 private func handleGenericError(_ error: CKError, for record: CKRecord) { switch error.code { case .zoneNotFound: handleZoneNotFound(record.recordID.zoneID) case .unknownItem: handleUnknownItem(record) default: if let retryAfter = error.retryAfterSeconds { print("错误: \(error.code.rawValue),建议 \(retryAfter) 秒后重试") Task { [weak self] in try? await Task.sleep(nanoseconds: UInt64(retryAfter * 1_000_000_000)) self?.syncEngine.state.add( pendingRecordZoneChanges: [.saveRecord(record.recordID)] ) } } else { print("未处理的 CKError: \(error.code.rawValue)") } } } private func handleZoneNotFound(_ zoneID: CKRecordZone.ID) { syncEngine.state.add( pendingDatabaseChanges: [.saveRecordZone(zoneID)] ) } private func handleUnknownItem(_ record: CKRecord) { // 如果本地记录仍存在,重新注册为 pending save syncEngine.state.add( pendingRecordZoneChanges: [.saveRecord(record.recordID)] ) } // MARK: - 账户变更处理 private func handleAccountChange(_ event: CKSyncEngine.Event.AccountChange) { switch event.changeType { case .signIn: // 用户登录,引擎会自动拉取数据 break case .switchAccounts: // 切换账号:清除本地数据,引擎会自动拉取新账户数据 Task { await purgeAllLocalData() } case .signOut: // 用户登出:清除本地数据 Task { await purgeAllLocalData() } } } private func purgeAllLocalData() async { // 注意:清除所有本地 SwiftData 数据 // 引擎会自动重置内部状态(pending 队列、change token 等) try? modelContext.delete(model: Note.self) try? modelContext.save() lastKnownRecords.removeAll() } // MARK: - 状态持久化 private func getStateURL() -> URL { let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] return docsDir.appendingPathComponent("cloudkit_sync_state.json") } private func saveStateToDisk() { guard let stateSerialization = self.stateSerialization else { return } let url = getStateURL() do { let data = try JSONEncoder().encode(stateSerialization) try data.write(to: url, options: [.atomic]) } catch { print("保存同步状态失败: \(error)") } } private static func loadSyncState() -> CKSyncEngine.State.Serialization? { let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let url = docsDir.appendingPathComponent("cloudkit_sync_state.json") guard let data = try? Data(contentsOf: url) else { return nil } return try? JSONDecoder().decode( CKSyncEngine.State.Serialization.self, from: data ) } // MARK: - 手动触发同步 /// 手动拉取(如下拉刷新) func fetchChanges() async throws { try await syncEngine.fetchChanges(CKSyncEngine.FetchChangesOptions()) } /// 手动发送(如"立即备份"按钮) func sendChanges() async throws { try await syncEngine.sendChanges(CKSyncEngine.SendChangesOptions()) } /// 清理指定 zone 的本地数据 private func purgeLocalData(in zoneID: CKRecordZone.ID) { switch zoneID.zoneName { case "NoteZone": try? modelContext.delete(model: Note.self) try? modelContext.save() default: break } } } // MARK: - CKSyncEngineDelegate 实现 extension CloudKitSyncManager: CKSyncEngineDelegate { func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async { switch event { case .stateUpdate(let stateUpdateEvent): // 持久化同步状态 self.stateSerialization = stateUpdateEvent.stateSerialization self.saveStateToDisk() case .accountChange(let accountChangeEvent): self.handleAccountChange(accountChangeEvent) case .fetchedDatabaseChanges(let fetchedDatabaseChangesEvent): // 处理 zone 级别变更 for deletion in fetchedDatabaseChangesEvent.deletions { self.purgeLocalData(in: deletion.zoneID) } case .fetchedRecordZoneChanges(let fetchedRecordZoneChangesEvent): // 处理记录级别变更 for modification in fetchedRecordZoneChangesEvent.modifications { self.mergeRecordFromServer(modification.record) } for deletion in fetchedRecordZoneChangesEvent.deletions { self.deleteLocalRecord(deletion.recordID) } case .sentRecordZoneChanges(let sentRecordZoneChangesEvent): // 处理发送结果 for savedRecord in sentRecordZoneChangesEvent.savedRecords { self.lastKnownRecords[savedRecord.recordID.recordName] = savedRecord } for failedSave in sentRecordZoneChangesEvent.failedRecordSaves { self.handleFailedRecordSave(failedSave) } for deletedRecordID in sentRecordZoneChangesEvent.deletedRecordIDs { self.lastKnownRecords.removeValue(forKey: deletedRecordID.recordName) } case .willFetchChanges: self.syncState = .fetching case .didFetchChanges: self.syncState = .idle case .willSendChanges: self.syncState = .sending case .didSendChanges: self.syncState = .idle default: break } } func nextRecordZoneChangeBatch( _ context: CKSyncEngine.SendChangesContext, syncEngine: CKSyncEngine ) async -> CKSyncEngine.RecordZoneChangeBatch? { let scope = context.options.scope let changes = syncEngine.state.pendingRecordZoneChanges.filter { scope.contains($0) } guard !changes.isEmpty else { return nil } let batch = await CKSyncEngine.RecordZoneChangeBatch( pendingChanges: changes ) { [weak self] recordID in guard let self = self else { return nil } // 根据 recordID 从本地存储查找对应数据 return await self.fetchCKRecord(for: recordID) } return batch } /// 根据 recordID 从本地存储获取对应的 CKRecord private func fetchCKRecord(for recordID: CKRecord.ID) async -> CKRecord? { let recordName = recordID.recordName let zoneName = recordID.zoneID.zoneName switch zoneName { case "NoteZone": let fetchDescriptor = FetchDescriptor( predicate: #Predicate { $0.syncID == recordName } ) guard let note = try? modelContext.fetch(fetchDescriptor).first else { return nil } let existing = lastKnownRecords[recordName] return convertToCKRecord(note, existing: existing) default: return nil } } } 这个同步库框架包含了所有核心组件:SwiftData 变更监听、Model 与 CKRecord 双向转换、CKSyncEngine 事件处理、冲突解决、错误处理、状态持久化、账户变更处理和手动同步触发。使用时只需在 App 启动时创建 CloudKitSyncManager 实例,剩下的同步工作就会自动运行。 // 在 App 入口处初始化同步管理器 @main struct MyApp: App { let syncManager: CloudKitSyncManager init() { let modelContainer = try! ModelContainer( for: Note.self, configurations: ModelConfiguration(isStoredInMemoryOnly: false) ) // 注意:containerID 必须与 Xcode 中 iCloud Capability 配置的 Container ID 一致 self.syncManager = CloudKitSyncManager( modelContainer: modelContainer, containerID: "iCloud.com.example.MyApp" ) } var body: some Scene { WindowGroup { ContentView() .environmentObject(syncManager) .modelContainer(syncManager.modelContainer) } } } 生产环境注意事项 在将 CloudKit 同步库部署到生产环境之前,有几个关键事项需要特别注意。 真机测试是必须的。 模拟器不支持远程推送通知,这意味着 CKSyncEngine 依赖的 CKDatabaseSubscription 静默推送在模拟器上无法正常工作。模拟器上的自动同步只能依赖应用在前台时引擎的定期检查,无法模拟真实场景下的后台同步行为。因此,所有同步功能的测试都必须在真机上进行,包括多设备同步、后台推送唤醒、账户切换等场景。 Schema 部署要谨慎。 在 Development 环境中,你可以自由地修改 Schema——添加字段、删除字段、修改字段类型,CloudKit 会自动同步这些变更到开发环境的数据库中。但一旦将 Schema 部署到 Production 环境,限制就变得严格:字段只能增加不能删除,字段类型不可更改。因此,在部署到生产环境之前,务必在 Development 环境中充分测试所有数据模型,确保 Schema 设计合理。部署操作通过 CloudKit Dashboard 的 Schema 页面执行,一旦部署不可撤销。 监控同步状态。 CKSyncEngine 提供了 description 属性,返回适合日志记录的引擎状态描述字符串。建议在关键节点(如启动、同步完成、出错时)打印这个描述,方便排查问题。在 Debug 构建中,可以在 handleEvent 方法的开头打印 event.description,记录每个事件的详细信息。CKSyncEngine.Event 同样有 description 属性,可以输出事件的完整信息。 // 在 handleEvent 中添加日志 func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async { #if DEBUG print("[CKSyncEngine] \(event.description)") // 注意:生产环境中应使用 os.Logger 而非 print // import os // let logger = Logger(subsystem: "com.example.app", category: "CloudKitSync") // logger.debug("Event: \(event.description)") #endif // ... 事件处理逻辑 } 性能优化。 批量操作是性能优化的核心原则。每次同步操作都有固定的网络开销,频繁的小同步比偶尔的大同步效率低得多。在实际使用中,尽量将短时间内多次的本地变更合并后一次性注册为 pending changes,而不是每改一条记录就调用一次 add(pendingRecordZoneChanges:)。合理使用 zone 也很重要——将经常一起访问的记录放在同一个 zone 中,可以利用 zone 级别的批量拉取提高效率。RecordZoneChangeBatch 已经自动处理了 250 条记录的限制,你不需要手动分页。 CloudKit 免费额度。 CloudKit 对免费用户有请求配额限制。对于 Private Database,每个 iCloud 账户的 App 有一定的请求次数上限(通常足够个人使用)。对于 Public Database,配额是按 App 计算的,所有用户共享。如果你的 App 用户量较大,Public Database 的请求配额可能成为瓶颈。Apple Developer Program 会员享有更高的配额,但仍有上限。建议在开发阶段就关注请求量,避免不必要的查询和频繁的小同步。可以通过 CloudKit Dashboard 的 Usage 页面查看配额使用情况。 数据迁移。 当你的 App 版本升级、数据模型发生变化时,需要考虑数据迁移。SwiftData 提供了 VersionedSchema 和 SchemaMigrationPlan 机制来处理模型版本迁移。在 CloudKit 侧,Schema 的变化只能增加字段不能删除(如 3.5 节所述)。建议在 App 启动时检查 SwiftData 的 Schema 版本,必要时执行迁移计划,确保本地数据与 CloudKit Schema 兼容。 并发安全。 CKSyncEngine 本身是 Sendable 的,可以在不同 actor 之间安全传递。但你的本地数据访问(如 SwiftData 的 ModelContext)通常不是线程安全的。建议将 CloudKitSyncManager 标记为 @MainActor,确保所有数据访问都在主线程执行。如果需要在后台执行耗时的数据操作,可以使用 ModelActor 宏创建专用的后台 actor。