CloudSyncKit-learn:从零读懂并亲手写一个 SwiftData ↔ CloudKit 同步引擎 发布于 · 2026-09-07 · # 默认分类 # CloudSyncKit 原理与实践:读懂并动手实现一个 SwiftData ↔ CloudKit 双向同步引擎 > 本教程面向第一次接触 CloudSyncKit(乃至"数据同步"这一课题)的开发者。读完并动手做完第十二章,你会: > > 1. **彻底理解** CloudSyncKit 的工作原理——本地数据如何走到云端、云端数据如何回到本地; > 2. **亲手写出**一个具备同等骨架的迷你同步引擎,并能在理解每一行的基础上扩展它。 > > 阅读时建议对照源码:`CloudSyncKit/` 下的 `Interface / Application / Domain / Infrastructure / Examples` 五个目录。 --- ## 目录 1. [第一章:问题——为什么需要同步引擎](#第一章) 2. [第二章:基础——CloudKit 与 SwiftData 的关键概念](#第二章) 3. [第三章:设计——双向同步的本质与分层架构](#第三章) 4. [第四章:领域层——用值类型描述"一条同步记录"](#第四章) 5. [第五章:SyncModel 与 ModelHandler——同步的"接口"](#第五章) 6. [第六章:RecordEncoder——模型到记录的单向翻译](#第六章) 7. [第七章:HistoryObserver——本地变更的"侦察兵"](#第七章) 8. [第八章:Push——把本地变更推上云端](#第八章) 9. [第九章:Pull——把云端变更拉回本地](#第九章) 10. [第十章:SyncCoordinator——同步引擎的"大脑"](#第十章) 11. [第十一章:错误恢复与退避——在恶劣网络下存活](#第十一章) 12. [第十二章:动手实践——从零写一个 MiniSync](#第十二章) 13. [第十三章:测试策略与进阶方向](#第十三章) --- ## 第一章:问题——为什么需要同步引擎 假设你写了一个待办事项 App,数据存在本地 SwiftData 里。用户换手机、或者同时用 iPhone 和 iPad,数据就不同步了。你希望数据自动上云,且多台设备保持一致。 最直觉的方案是 Apple 官方集成:在 `ModelConfiguration` 上打开 `cloudKitDatabase: .private("iCloud.xxx")`,剩下的交给系统。它很省事,但有代价: - **定制空间小**:同步时机、字段选择、错误恢复策略都无法控制; - **调试困难**:系统镜像的行为像黑盒,出问题只能猜; - **覆盖不了进阶需求**:公共库、精细的离线补偿、共享协作的自定义路由等。 于是就有了"自建同步引擎":自己写代码,把**本地数据库的变化搬上云**(Push),把**云端的变化搬回本地**(Pull)。CloudSyncKit 就是这样一款引擎,核心命题只有两个: - **Push**:本地改了什么 → 告诉云端; - **Pull**:云端改了什么 → 告诉本地。 要把它做好,必须回答四个刁钻的问题——CloudSyncKit 的复杂度几乎都来自它们加"网络会失败"这个现实: | # | 问题 | 答案(对应章节) | |---|---|---| | 1 | "本地改了什么"怎么高效知道? | SwiftData **History API**(第七章) | | 2 | "上次同步到哪"怎么记住? | **Change Token**(第二、七、九章) | | 3 | 引擎自己写回的数据,会再被推回云端形成死循环吗? | **author 回环防护**(第七章) | | 4 | 删掉的记录已不在库里,怎么把"删除"同步上云? | **tombstone 墓碑**(第七章) | 理解了这四个问题,就理解了同步引擎。 --- ## 第二章:基础——CloudKit 与 SwiftData 的关键概念 ### 2.1 CloudKit 的层级结构 ``` CKContainer(容器,如 iCloud.com.yourapp) ├── privateDB(私有数据库:仅当前用户) ├── sharedDB (共享数据库:CKShare 邀请的协作者共同读写) └── publicDB (公共数据库:所有人可读) 每个数据库: Database ── RecordZone(记录区,"CloudSyncKit" 引擎自建一个自定义 zone) └── CKRecord(一条记录:recordType / recordName / 字段) ``` 三个必须记住的概念: - **recordName**:记录在 zone 内的唯一标识。CloudSyncKit 用 UUID 字符串,与本地模型的 `recordName` 属性一一对应——这是"本地对象 ↔ 云端记录"的桥。 - **zoneID**:由 `zoneName` + `ownerName` 组成。`ownerName == "__defaultOwner__"` 表示"我自己",其他值表示"分享给我的协作者"。owner 是**共享路由的依据**:记录在谁名下,就写到谁的库。 - **CKRecord 系统字段**:recordID、creationDate、modificationDate、recordChangeTag(版本号)等。CloudKit 允许把系统字段序列化成二进制(`encodedSystemFields`),CloudSyncKit 把它存进模型的 `privateRecordData`/`publicRecordData`——增量更新时据此**原地重建**完整 CKRecord,云端才能正确比对 changeTag 做乐观锁。 ### 2.2 两级增量拉取与 Change Token CloudKit 的增量拉取是**两级**的,每级一个 token: 1. **数据库级**:`CKFetchDatabaseChangesOperation` 回答"哪些 zone 变了 / 哪些 zone 被删了",返回数据库级 token; 2. **zone 级**:`CKFetchRecordZoneChangesOperation` 对每个变更的 zone 回答"给我这个 token 之后的变更记录",返回 zone 级 token。 token 是**不透明书签**(二进制 `Data`):带着它去,云端只回"书签之后"的变更;只需保存、原样归还。它可能过期(`changeTokenExpired`)——清掉 token 全量重拉即可(第十一章处理)。 ### 2.3 静默推送与订阅 App 不在前台时怎么知道云端有新数据?引擎启动时向 CloudKit 注册 **subscription**;云端有变更时 Apple 推送一条**静默推送**(`content-available`)。推送的 `userInfo` 里带 `ck` 字典,其中含订阅 ID——CloudSyncKit 据此判断"这是私有库还是共享库的变更",再触发一次定向 Pull(第十章详解去重)。 ### 2.4 SwiftData History API——引擎的基石 SwiftData 提供类似 Core Data 的持久化历史: ```swift let descriptor = HistoryDescriptor() let transactions = try context.fetchHistory(descriptor) // 每个 transaction 提供: // transaction.token —— 排序/比较用的断点 // transaction.author —— 写入者标识(关键!) // transaction.changes —— [.insert / .update / .delete] ``` 三个能力正好对应第一章的三个问题: | History 能力 | 同步引擎怎么用 | |---|---| | `author` 标识写入者 | 引擎写回数据时打上自己的 author,读变更时跳过 → 回环防护 | | token 可比较、可持久化 | "上次推送到哪"的断点;崩溃后不丢变更 | | `.delete` 事务带 tombstone | 配合 `@Attribute(.preserveValueOnDeletion)`,删除后仍能读 `recordName` → 可靠删除推送 | > ⚠️ History API 要求 store 支持(默认的 `DefaultStore` 满足)。同时**不要再开启** SwiftData 原生 `cloudKitDatabase` 镜像——系统镜像的写入会污染 History,且与引擎构成双通道冲突。 --- ## 第三章:设计——双向同步的本质与分层架构 ### 3.1 本质:两条流水线 + 两套书签 ``` ┌──────────────── 本地 SwiftData ────────────────┐ │ │ ① History 读出变更 ② 云端记录写回模型 │ │ ▼ ▲ [Push 流水线] [Pull 流水线] HistoryObserver ─► RecordEncoder ModelHandler.upsert ◄─ SwiftDataPersistenceGateway │ ▲ ▼ │ └─────────────► CloudKit ◄──────────────────────┘ ▲ │ └───┘ ③ 两套书签: History token(Push 侧,本地事务断点) CloudKit change token(Pull 侧:数据库级 + 每 zone 各一) ``` 两个方向互不干扰,各自拿着书签前进。这就是所有增量同步引擎(CloudCore、CloudSyncKit,乃至绝大多数同步系统)的共同骨架。 ### 3.2 分层:六边形(端口 - 适配器)架构 ``` Interface/ 门面 SyncEngine + 协调器 SyncCoordinator(actor) Application/ UseCases/(Push/Pull/Setup…)+ Ports/(端口协议)+ Support/(退避、错误恢复策略) Domain/ 纯 Swift 领域类型(不 import CloudKit) Infrastructure/ 适配器:CloudKitGatewayImpl / SwiftDataPersistenceGateway / HistoryObserver… Examples/ 示例模型(供参考,不参与库运行时逻辑) ``` 核心手法:**用协议把"业务流程"与"具体技术"隔开**。 - `CloudKitGateway`(端口):描述"我需要能创建 zone、拉取 / 修改记录的东西"; - `CloudKitGatewayImpl`(适配器):真正调 `CKDatabase` 的实现; - `PersistenceGateway`(端口)+ `SwiftDataPersistenceGateway`(适配器):同理。 收益:UseCase 里**没有一行 `import CloudKit`**,可脱离真机用假网关做单元测试;将来换存储只需换适配器。 ### 3.3 启动装配——读 SyncEngine.enable `Interface/SyncEngine.swift` 的 `enable(container:modelHandlers:)` 把所有零件组装起来,是理解全局的最佳入口: ``` CKContainer ──► CloudKitGatewayImpl ModelContainer ──► HistoryObserver(感知本地变更) RecordEncoder / SwiftDataPersistenceGateway(写回云端变更) HistoryTokenStore / TokenStoreImpl(两类书签持久化) ErrorRecoveryPolicy + BackoffManager(容错) PushChangesUseCase / PullChangesUseCase / SetupSyncUseCase └────► SyncCoordinator(总指挥,actor) ``` 每个参数名都对应一层职责;装配完成后 `await coordinator.enable()` 依次执行 setup(建 zone、订阅)→ 首轮 pull → push。 --- ## 第四章:领域层——用值类型描述"一条同步记录" 先定义引擎内部的"通用语言"。打开 `Domain/`: ### 4.1 定位三件套 ```swift // 一个 zone = zoneName + ownerName public struct ZoneIdentifier: Sendable, Codable, Hashable { let zoneName: String // 引擎固定 "CloudSyncKit" let ownerName: String // "__defaultOwner__" = 我;其他 = 分享者 } // 一条记录 = recordName + 所在 zone public struct RecordIdentifier: Sendable, Codable, Hashable { let recordName: String let zoneID: ZoneIdentifier } // 去哪个库 public enum DatabaseScope: String, Sendable, Codable { case private_ // private 是 Swift 关键字,故加下划线(对应 CKDatabase.Scope.private) case shared case public_ } ``` ### 4.2 SyncRecordData——引擎内部的"CKRecord 替身" 为什么不用 `CKRecord`?因为 UseCase 层不允许 `import CloudKit`(分层约束),需要一个中立结构: ```swift public struct SyncRecordData: Sendable { let identifier: RecordIdentifier // 在哪里(zone + recordName) let recordType: String // 是什么类型(模型类名) let systemFields: Data? // CKRecord 系统字段二进制快照 let fields: [String: SyncFieldValue] // 业务字段 let encryptedFields: [String: SyncFieldValue] let parentRecordID: RecordIdentifier? // 父记录(共享继承用) } ``` **`systemFields` 是本层最重要的设计**。它存的是 `encodedSystemFields` 二进制,平时躺在模型的 `privateRecordData`(私有 / 共享 zone)或 `publicRecordData`(公共库)里。增量更新云端记录时,先用它**重建出完整 `CKRecord`**、改业务字段再上传,云端才能做 changeTag 乐观锁比对——否则会被当成新建或触发冲突。 ### 4.3 SyncFieldValue——字段值的类型安全包装 CKRecord 字段类型必须与 Swift 类型对号入座: ```swift public enum SyncFieldValue: Sendable, Equatable { case string(String) case integer(Int64) // Bool 也归这里:以 1 / 0 表示 case double(Double) // Date 也归这里:以时间戳秒数表示 case data(Data) case asset(AssetReference) // 对应 CKAsset case reference(RecordIdentifier) // to-one 关系 case referenceList([RecordIdentifier]) // to-many 关系 case null } ``` 注意:**没有** `.bool`、`.date` 专用分支——`Bool → integer(1/0)`、`Date → double(时间戳)` 在编码时完成转换(第六章表格)。`Equatable` 由编译器自动合成,供测试与差分直接比较。 ### 4.4 错误与事件 ```swift public enum SyncError: Error, Sendable, Equatable { // == 手写(Error 关联值无法自动判等) case zoneNotFound case userDeletedZone case changeTokenExpired(zoneID: ZoneIdentifier?) case rateLimited(retryAfter: TimeInterval?) case serviceUnavailable case networkUnavailable case authenticationFailed case quotaExceeded case partialFailure(records: [RecordIdentifier], errors: [Error]) case unknown(Error) } public enum SyncEvent: Sendable { // 引擎各节点发布,供 UI 订阅 case setupStarted, setupCompleted case pushStarted, pushCompleted(recordCount: Int), pushFailed(SyncError) case pullStarted(scope: DatabaseScope), pullCompleted(scope: DatabaseScope, recordCount: Int) case pullFailed(scope: DatabaseScope, error: SyncError) case zoneRebuilt, tokenReset(zoneID: ZoneIdentifier?), error(SyncError) case cacheStateChanged(recordID: RecordIdentifier, oldState: CacheState, newState: CacheState) case sharingStarted(recordID: RecordIdentifier), sharingCompleted(recordID: RecordIdentifier) case sharingStopped(recordID: RecordIdentifier) } ``` --- ## 第五章:SyncModel 与 ModelHandler——同步的"接口" ### 5.1 SyncModel:让模型自己声明"我要同步" `Domain/Protocols/SyncModel.swift` 把同步要求写进协议——**编译期替你把关**(相比前代 CloudCore 在 Core Data UserInfo 里运行时配置、拼错名字到运行才发现): ```swift public protocol SyncModel { static var recordType: String { get } // 默认 = 类名 static var syncScopes: Set { get } // 同步到哪些库 // ── 四个服务字段(引擎专用,不上传为业务数据)── var recordName: String { get set } // CKRecord.ID.recordName var ownerName: String { get set } // zone 的 ownerName var privateRecordData: Data? { get set } // 私有/共享 zone 系统字段快照 var publicRecordData: Data? { get set } // 公共库系统字段快照 var parentRecordIdentifier: RecordIdentifier? { get } // 默认 nil } ``` 一个标准实现(对照 `Examples/Task.swift`,真正的 @Model 写法): ```swift @Model final class Task: SyncModel { @Attribute(.preserveValueOnDeletion) // ★ 删除后值进 tombstone var recordName: String @Attribute(.preserveValueOnDeletion) // ★ var ownerName: String var privateRecordData: Data? var publicRecordData: Data? var title: String // 业务字段,自动同步 var completed: Bool static let syncScopes: Set = [.private_] init(title: String) { self.recordName = UUID().uuidString // 出生即全局唯一 self.ownerName = "" // 引擎编码时按需补全为 __defaultOwner__ ... } } ``` 三条硬规则: 1. **`@Attribute(.preserveValueOnDeletion)` 不能省**——否则删除后 tombstone 里没有 `recordName`,删除永远无法上云; 2. **不要声明 `@unchecked Sendable`,也不要让协议继承 `Sendable`**——`@Model` 宏会生成一个不可用的 `Sendable` 一致性,手动标注会与其冲突(详见 `SyncModel.swift` 注释);模型请在 MainActor / 单一 `ModelContext` 上使用; 3. **不要用 `_` 开头的属性名**——RecordEncoder 会跳过它们(那是 SwiftData 宏的内部存储)。 ### 5.2 ModelHandler:应用拥有的"翻译桥" Pull 方向要把 `SyncRecordData` 写回本地模型。可 `any SyncModel` 是存在类型,无法凭空 `init`;具体类型只有应用层知道。于是每个模型配一个 `ModelHandler`: ```swift public protocol ModelHandler: Sendable { var recordType: String { get } func fetchByRecordName(_ recordName: String, in context: ModelContext) -> (any SyncModel)? func upsert(_ record: SyncRecordData, in context: ModelContext) -> (any SyncModel)? // Pull 写回 func deleteAll(in context: ModelContext) throws func fetchAll(in context: ModelContext) throws -> [any SyncModel] // 可选钩子(有默认实现) func extractDeletedIdentifier(from delete: any HistoryDelete) -> RecordIdentifier? // 默认 nil func mapUpdatedFieldNames(from update: any HistoryUpdate) -> [String] // 默认 [] } ``` > **思考题**:为什么 Push 不需要 ModelHandler、Pull 需要? > 答:Push 只"读"模型属性,`Mirror` 反射对任意 `any SyncModel` 都适用;Pull 要"创建/更新具体类型的实例",存在类型的具体信息只存在于应用层。这解释了引擎的接线:`enable` 里只注入 `RecordEncoder`(Push 用),Pull 写回走各 Handler 的 `upsert`。 --- ## 第六章:RecordEncoder——模型到记录的单向翻译 `Infrastructure/SwiftData/Mappings/RecordEncoder.swift`(约 90 行,含注释)实现 `RecordConverter` 协议的唯一方法: ```swift func convert(_ model: any SyncModel, to scope: DatabaseScope) -> SyncRecordData { // 1. 定位:zoneName 固定 + ownerName 取自模型 let zoneID = ZoneIdentifier(zoneName: Self.defaultZoneName, ownerName: model.ownerName) // 2. 系统字段快照:私有/共享 scope 用 privateRecordData;公共 scope 用 publicRecordData // 3. Mirror 反射遍历属性 → fields // 跳过:recordName/ownerName/privateRecordData/publicRecordData(服务字段) // 跳过:_ 开头的属性(SwiftData 内部存储) // 4. parentRecordID 取 model.parentRecordIdentifier } ``` 属性值 → `SyncFieldValue` 的类型映射(`convertToSyncFieldValue` 的 `switch + as?` 链,含 Optional 剥壳): | Swift 值 | SyncFieldValue | |---|---| | `String` / `URL` | `.string`(URL 转 `absoluteString`) | | `Int`/`Int64`/`Int32`/`Int16` | `.integer(Int64)` | | `Double` / `Float` | `.double` | | `Bool` | `.integer(1 / 0)` | | `Date` | `.double(timeIntervalSince1970)` | | `Data` | `.data` | | 其他 / nil | `.null` | 三个要点: - **Mirror 反射**取代 Core Data 的 `attributesByName`:不加字段注册表,加一个属性就自动同步; - **Optional 剥壳**:`Mirror` 看到的 `String?` 是一层 optional 包装,先查 `displayStyle == .optional` 取内层值,nil → `.null`; - **服务字段集合是"防火墙"**:保证引擎专用字段不被当业务数据污染云端。 (反方向的"记录 → 模型"没有等价反射工具:@Model 宏让模型属性无法被动态 KVC 赋值,因此 Pull 写回落在应用实现的 `ModelHandler.upsert` 里逐字段赋值——第五章思考题。) --- ## 第七章:HistoryObserver——本地变更的"侦察兵" `Infrastructure/SwiftData/HistoryObserver.swift` 是整个引擎最精妙的部分,Push 的源头,直接回答第一章的问题 1、3、4。 ### 7.1 读取变更(fetchPendingChanges) ```swift @MainActor func fetchPendingChanges() throws -> ChangeSet { let allTransactions = try context.fetchHistory(descriptor) let lastToken = stagedToken ?? tokenStore.loadToken() // 上次书签 // 内存过滤(不依赖 #Predicate): // ① 排除 author == syncAuthor 的事务(回环防护) // ② 排除 token <= 已消费 token 的事务 let relevant = allTransactions.filter { txn in if txn.author == syncAuthor { return false } if let lastToken, txn.token <= lastToken { return false } return true } for txn in relevant { for change in txn.changes { switch change { case .insert(let insert): // context.model(for:) 取模型 → converter.convert → insertions case .update(let update): // 同样编码进 updates;并让 handler.mapUpdatedFieldNames 产出字段级差分 case .delete(let delete): // 从 tombstone 提取 recordName/ownerName → deletions(见 7.4) } } } // ★ 暂存最新 token(只进内存,不落盘——见 7.2) stagedToken = relevant.last?.token return ChangeSet(inserted:..., updated:..., deleted:..., changedFields:...) } ``` ### 7.2 Token 暂存语义:崩溃安全的秘诀 ```swift fetchPendingChanges() → 把最新 token 暂存内存(stagedToken) markAsPushed() → 推送【成功】后才把 stagedToken 持久化到 UserDefaults ``` 为什么分两步?设想推送中途崩溃: - 读完就持久化、推送前崩溃 → 这批变更被"书签"跳过,**永久丢失**(不可接受); - 推送成功后持久化、中途崩溃 → 重启后 History 里的事务还在,会**再推一遍**。有害吗?无害——CloudKit `modifyRecords` 幂等,重复推送结果不变。 两害相权:**宁可重复,不可丢失**。`markAsPushed` 还会在"没有未消费事务"时清理全部 History,防止无限膨胀。 ### 7.3 回环防护:author 过滤 引擎把云端数据写回本地时,`SwiftDataPersistenceGateway` 会临时把 `context.author` 切到 `historyObserver.syncAuthor`(`"CloudSyncKitSync"`),写毕还原。History 事务忠实记录 author,于是 `fetchPendingChanges` 一行过滤掐断死循环: > 云端改 → 引擎写回本地(author = CloudSyncKitSync)→ History 有这条事务 → 但 author 是同步引擎 → 跳过 → 不会再推回云端 ✅ 这是相对旧方案(按 `context.name` 过滤)的根本性修复——旧方案里同步写入实际走了 mainContext,name 丢失,云端数据会被无限推回去。 ### 7.4 Tombstone:给删除的记录立碑 `context.delete(task)` 之后对象就没了,`fetchByRecordName` 永远查不到它——怎么把删除告诉云端?给属性标 `.preserveValueOnDeletion`,删除时值被保留进 History 事务的 **tombstone**: ```swift case .delete(let delete): if let handler = modelHandlers[delete.changedPersistentIdentifier.entityName], let recordID = handler.extractDeletedIdentifier(from: delete) { deletions.append(recordID) } ``` `extractDeletedIdentifier` 由应用实现(存在类型需向下转型): ```swift func extractDeletedIdentifier(from delete: any HistoryDelete) -> RecordIdentifier? { guard let delete = delete as? DefaultHistoryDelete, let name = delete.tombstone[\.recordName], let owner = delete.tombstone[\.ownerName] else { return nil } return RecordIdentifier( recordName: name, zoneID: ZoneIdentifier(zoneName: "CloudSyncKit", ownerName: owner.isEmpty ? "__defaultOwner__" : owner)) } ``` **为什么 `ownerName` 也要进墓碑?** 删除推送同样需要路由:曾属于共享 zone 的记录(owner 是分享者),删除必须发到 `sharedDB`。墓碑里留了 ownerName,路由才不错。 ### 7.5 字段级差分 `.update` 事务提供 `updatedAttributes: [PartialKeyPath]`——哪些属性变了。`ModelHandler.mapUpdatedFieldNames` 把 KeyPath 翻译成 CKRecord 字段名,Push 就能用 `savePolicy: .changedKeys` 只上传变化字段省流量;不实现则退化为全字段上传(依然正确,只是浪费)。 --- ## 第八章:Push——把本地变更推上云端 `Application/UseCases/PushChangesUseCase.swift`(actor)的 `execute()`: ``` 1. persistence.fetchPendingChanges() // HistoryObserver 读出 ChangeSet 2. 无任何变更 → 发 pushCompleted(0),结束 3. 用"本批次变更"建 in-flight 父记录索引 // 父记录可能同批创建、尚未落库 4. 解析继承 owner(见 8.1) 5. 按 owner 分组路由: owner == "__defaultOwner__" → privateDB 其他 owner → sharedDB 6. 分三类上传: 插入 → modifyRecords(savePolicy: .allKeys) // 全字段 更新 → modifyRecords(savePolicy: .changedKeys) // 仅变更字段 删除 → deleteRecords(ids:)(按墓碑 owner 路由) 7. persistence.markChangesAsPushed(...) // ★ 全部成功后才提交 History token 8. 发 pushCompleted(recordCount:);失败发 pushFailed(SyncError) 并上抛 ``` 要点: - **先上传、后提交 token**——顺序反了违背第七章的崩溃安全设计; - **owner 路由是共享协作的地基**:记录在谁名下就写谁的库; - 失败上抛给协调器统一决策恢复(第十一章)。 ### 8.1 父子共享继承(进阶) 场景:协作项目(父记录,属分享者 A)下新建子任务,子任务应"自动进入" A 的共享 zone,协作者才看得到。实现是 `effectiveOwner(of:)` 的**沿父链递归**: ``` 子记录 owner == 我?→ 有 parentRecordID? → 先在 in-flight 父记录索引里找(父记录可能还没落库!) → 找不到再查本地已持久化的父记录 → 父 owner == 我?→ 继续向上找父的父…… → 父 owner == 分享者 A → 子记录改写 zoneID.ownerName = A 并 persistence.updateOwnerName 写回本地模型, 保证后续更新/删除也路由到共享库 ``` 两个工程细节值得学习:`visited` 集合防循环引用;in-flight 索引解决"父子同批创建、父不可查"。 --- ## 第九章:Pull——把云端变更拉回本地 `Application/UseCases/PullChangesUseCase.swift`(actor)。两级拉取、全程分页: ``` execute(scopes: [.private_, .shared]) 对每个 scope: pullFromScope(scope): while true: // ★ 外层:恢复重试循环(预算 = 1 次) performPullFromScope(scope) // 真正的一次拉取(见下),成功即返回 失败 → attemptRecovery 决定: changeTokenExpired → 重置对应 token,canRetry = true,回到循环 其余 → 发 pullFailed 并上抛 performPullFromScope(scope): // 一次完整的数据库级 + zone 级拉取 发 pullStarted(scope) while moreComing { // 数据库级分页 dbResult = fetchDatabaseChanges(in: scope, after: token) for zone in deletedZones { 清该 zone token + 清本地数据 } for zone in changedZones { 计数 += pullZoneChanges(zone) } 存数据库级 token } resolveMissingReferences() // 修复缺失引用 发 pullCompleted(scope, recordCount) pullZoneChanges(zone): while moreComing { // zone 级分页 zoneResult = fetchZoneChanges(in: zone, after: token) upsertRecords(changedRecords) // → ModelHandler.upsert 写回本地 deleteRecords(deletedRecordIDs) 存 zone 级 token } ``` 四个值得注意的设计: - **先写数据、后更新 token**——若先存 token 后写库,写库失败时这批变更就被"书签"跳过了; - **moreComing 分页**:CloudKit 单次返回有上限;返回 `moreComing == true` 必须继续循环; - **恢复重试要有预算**:`changeTokenExpired` 可重置 token 重试,但若服务端持续报同一错误,无限递归会演变成请求风暴——`maxRecoveryRetries = 1` 保证重试有界(这是本库比初版更稳的地方); - **resolveMissingReferences**:增量拉取时引用目标可能还没到(先拉到"评论"后拉到"文章"),拉完 scope 统一修复。 --- ## 第十章:SyncCoordinator——同步引擎的"大脑" `Interface/SyncCoordinator.swift` 是 **actor**,职责是"调度"而非"干活"。 ### 10.1 为什么用 actor? Push / Pull 可能被多方同时触发(手动刷新、静默推送、网络恢复……)。旧方案用 OperationQueue 串行;actor 方案里,**同一 actor 上的方法天然互斥**,编译器保证无数据竞争: ```swift func push() async throws { guard isEnabled else { return } guard !(await backoffManager.isPaused()) else { return } if isPushing { pushPending = true; return } // 正在推?记一笔就走 isPushing = true defer { isPushing = false } try await pushUseCase.execute() if pushPending { // 期间又有人请求 → 补一次 pushPending = false try await pushUseCase.execute() } } ``` 这是经典的"**进行中标记 + 补偿执行**"去重模式:不排队等待,而是"你来晚了,等我跑完再补最后一次"。 ### 10.2 静默推送去重(debounce) 短时间可能收到一串通知(如云端批量改动)。`pull(using:)` 不每条都拉,而是重置一个 1 秒窗口: ```swift pullDebounceTask?.cancel() // 重置窗口 pullDebounceTask = Swift.Task { [weak self] in try? await Swift.Task.sleep(nanoseconds: 1_000_000_000) // 等 1 秒 guard !Swift.Task.isCancelled, let self else { return } try? await self.pullUseCase.execute(scopes: [scope]) // 窗口结束才拉 } ``` 窗口内每来一条通知就重置计时器,最终只拉一次;`disable()` 时取消任务。 ### 10.3 订阅 ID → 作用域路由 通知里没有现成的 `DatabaseScope`,需要解析。`SyncCoordinator` 先尝试用 CloudKit 官方 `CKNotification(fromRemoteNotificationDictionary:)` 取 `subscriptionID`;**取不到时**(未配置 CloudKit 权限、单元测试等场景)回退为直接读 `ck` payload 的 `sid`/`qid` 键,再与配置比对: - 等于 `subscriptionIDs.privateDB` → `.private_` - 等于 `subscriptionIDs.sharedDB` → `.shared` - 以 `subscriptionIDs.publicPrefix` 开头 → `.public_` 这样两条路径都覆盖,同步通知不会被静默丢弃。 ### 10.4 启动序列与离线补偿 ``` enable: isEnabled = true → Setup:建 zone、订阅私有/共享库推送(首次"开荒") → Pull:先把云端现状拉全 → Push:再把本地积压推上去 每步失败各自走 handleError,互不阻塞 disable:取消去重任务、取消退避、清空 pending 标记 setOnline(true) 且此前离线:自动补一次 push——离线积压就此补交 ``` ### 10.5 事件流 各用例在关键节点发布 `SyncEvent`;协调器经 `EventPublisher`(actor 发布器)转成 `AsyncStream`。相比单 delegate,多消费者意味着 UI、日志、埋点可同时监听互不干扰: ```swift for await event in await SyncEngine.shared.events { switch event { case .pullCompleted(_, let count): updateBadge(count) case .error(let e): showBanner(e) default: break } } ``` --- ## 第十一章:错误恢复与退避——在恶劣网络下存活 CloudKit 的常态是限流、断网、token 过期、用户删库。引擎的应对是**策略与执行分离**: - `ErrorRecoveryPolicy`(决策者,actor):`SyncError` → `RecoveryAction`,无副作用、可单测; - `SyncCoordinator.handleError`(执行者):按动作执行,并发布事件。 | SyncError | RecoveryAction | 协调器执行 | |---|---|---| | `changeTokenExpired(zone)` | `.resetTokenAndRetry(zoneID:)` | 重置对应 token(nil 则全部)→ 重新 Pull | | `userDeletedZone` | `.purgeAndRebuild` | 清空本地同步数据 + 重置 token + 重建 zone + 全量上传 | | `zoneNotFound` | `.rebuildZone(uploadAllData: true)` | 重新 Setup(含全量上传) | | `rateLimited(retryAfter)` | `.pause(duration:)` | `BackoffManager` 休眠,到期自动恢复 Push + Pull | | `serviceUnavailable` | `.pause(60s)` | 同上(固定 60 秒) | | `networkUnavailable` | `.waitForNetwork` | 置离线;网络恢复 `setOnline(true)` 触发积压推送 | | `authenticationFailed` / `quotaExceeded` / `partialFailure` / `unknown` | `.reportError` | 发 `error` 事件,不自动恢复(需用户介入) | `BackoffManager`(`Application/Support/`)要点:用 actor + `Task.sleep` 替代旧方案的主线程 Timer——不阻塞任何线程、可取消;`pause(duration:) { 回调 }` 到期自动跑一轮双向同步;`disable()` 时 `cancel()`。 > **设计心得**:把"遇到错误怎么办"集中到一个 Policy,而不是散落各 UseCase。新增错误类型只改一处;测试只喂假错误、断言动作即可。Pull 用例内部的恢复(第九章)是"用例级"的快路径,Policy 是"引擎级"的兜底,二者职责不同、都会触发。 --- ## 第十二章:动手实践——从零写一个 MiniSync 现在轮到你了。目标:**不复制 CloudSyncKit 代码**,从空文件开始写一个能跑通"本地改 → 云端可见 → 另一台设备拉到"的迷你引擎。顺序即依赖顺序,每步有可验证产物。总时长约一周的业余时间,可按自己的节奏拆。 ### Step 0:准备工程 1. Xcode 新建 iOS 项目,Signing & Capabilities 加 **iCloud → CloudKit**,勾选一个容器(如 `iCloud.com.yourname.minisync`); 2. 打开 **Background Modes → Remote notifications**(否则收不到静默推送); 3. 到开发者后台确认容器存在,你的账号已登录 iCloud。 ### Step 1:领域层(约半天) 照第四章写出:`DatabaseScope`、`ZoneIdentifier`、`RecordIdentifier`、`SyncFieldValue`、`SyncRecordData`、`SyncError`、`SyncEvent`。这层零依赖,编译通过即达标。**验证点**:给每种类型写一个"值构造 + 判等"的小单测。 ### Step 2:SyncModel 协议 + 示例模型(约半天) 写 `SyncModel`(四个服务属性 + `syncScopes` + 默认实现),再建 `@Model final class Note: SyncModel`,只留一个 `title: String` 业务字段。**验证点**:故意删掉 `recordName` → 编译报错 → 加回 → 通过。你已经体会"编译期约束"的价值。 ### Step 3:RecordEncoder(约半天) 实现 `convert(_ model: to:)`:Mirror 反射 + 服务字段过滤 + Optional 剥壳 + 类型映射(照第六章表格)。**验证点**:单测里构造 Note,断言 `fields["title"] == .string("hello")`、`identifier.recordName == note.recordName`。 ### Step 4:系统字段快照的存与用(1 天) 这是 MiniSync 最容易卡住的地方,也是理解 CKRecord 的关键: ```swift // Push 插入成功后:把云端返回的 record 系统字段存回本地 if let data = record.encodedSystemFields() { note.privateRecordData = data // @Model 上要预留存储属性 } // Push 更新前:用快照重建完整 CKRecord,改字段后上传 let decoder = try NSKeyedUnarchiver(forReadingFrom: note.privateRecordData!) decoder.requiresSecureCoding = true let ckRecord = try CKRecord(coder: decoder) // 重建出带 changeTag 的完整记录 ``` **验证点**:建 Note → 推送成功 → CloudKit Dashboard 能看到记录;改 title → 再推 → Dashboard 值更新且**没有**产生重复记录(说明 changeTag 生效)。 ### Step 5:HistoryObserver(1 天) 实现 `fetchPendingChanges` + `markAsPushed`(token 存 UserDefaults),照第七章的暂存语义。三个单测必须过: 1. 插入 Note → 返回 1 个 insertion; 2. 先设 `context.author = "MiniSync"` 再写入 → 返回 0 条(回环防护生效); 3. 删除 Note(属性已标 `.preserveValueOnDeletion`)→ 返回 1 个 deletion 且 recordName 正确。 ### Step 6:CloudKitGateway(1–2 天) 先定义协议(`createZone / subscribe / fetchDatabaseChanges / fetchZoneChanges / modifyRecords / deleteRecords / fetchUserRecordID / checkAccountStatus`),再写 CloudKit 实现,用 `withCheckedThrowingContinuation` 把 `CKOperation` 回调桥成 async。**验证点**:模拟器改 Note → 推送 → 同一 iCloud 账号的另一台模拟器手动全量拉 → 数据出现。 ### Step 7:PullUseCase(1 天) 照第九章写两级循环:先只处理 `.private_`,启动时手动调一次。**验证点**:设备 A 建 Note → 设备 B 启动 App → `pull()` 后 Note 出现在 B 的列表。 ### Step 8:SyncCoordinator + 门面(1 天) 照第十章写 actor(互斥去重 + debounce + handleError 分发),包一个 `MiniSync.shared.enable(...)` 门面。注册 subscription 并接入远程通知路由后,云端改动会自动推来。**验证点**:A 改数据,B 杀进程后仍能收到推送并更新(模拟器推送偶发延迟属正常)。 ### Step 9:错误恢复与打磨(持续) 按第十一章表格补 `ErrorRecoveryPolicy` 与 `BackoffManager`。优先覆盖 `changeTokenExpired`、`networkUnavailable` 两个最常见错误;其余渐进。补上 `ContextMenu` 式的共享入口(`CKShare`)前,先把手动"owner 继承"的最小实现做对(第八、九章)。 > 做完 Step 1–8,你已拥有一个功能子集完整的同步引擎。回头读 CloudSyncKit 源码,每层都会"似曾相识"——这就是学会了。 --- ## 第十三章:测试策略与进阶方向 ### 13.1 精读 CloudSyncKit 自带的测试 `CloudSyncKitTests/` 的分组正好对应学习重点: - `RecordEncoderTests` —— 编码转换的边界(Optional、类型映射、服务字段过滤); - `PushChangesUseCaseTests` / `PullChangesUseCaseTests` —— 假网关断言路由与 token 推进、token 过期**有界重试**; - `CloudSyncKitBatchingTests` —— 批量上传;`RateLimiterTests` —— 令牌桶限流; - `SyncCoordinatorDebounceTests` —— 远程通知去重合并、disable 取消; - `SharingAndCacheUseCaseTests` —— 共享继承与缓存状态机; - `TestSupport/Mocks.swift` —— 全项目的假端口(队列化结果 + 调用记录 + 线程安全锁)。 **核心手法**:UseCase 只依赖端口协议,测试注入 `MockCloudKitGateway` 记录调用、返回脚本化结果——不需要真 iCloud 账号就能测业务逻辑。这是分层架构的测试红利,写 MiniSync 时务必沿用。 ### 13.2 已知边界与进阶方向 - **冲突解决**:`serverRecordChanged` 目前按 `savePolicy` 策略直传,未做业务合并;进阶可按"字段级时间戳 / 服务端胜出 / 自定义 resolver"实现; - **公共库同步**:`PublicPullUseCase`(按订阅查询 + 单记录拉取)已有雏形但**未接入主流程**;公共库记录不存系统字段,模型需单独约定; - **缓存管理**:`ManageCacheUseCase` 提供 `processPendingUploads / processPendingDownloads / cleanupLRU` 状态机,也未接入主流程,需 App 在同步周期中显式驱动; - **共享全流程**:`ManageSharingUseCase` 封装 CKShare 的查询 / 创建 / 停止(`fetchOrCreateShare` / `stopSharing`),UI 层仍需集成 `UICloudSharingController`; - **订阅 ID 与 recordType 解耦**:当前公共库订阅把 recordType `"Task"` 硬编码在基础设施层,应改为由配置提供参与公共库同步的 recordType 列表; - **按 zone 删除**:`handleDeletedZone` 目前粗粒度 `purgeAllSyncData`,可细化为按 zone 清理; - **desiredKeys 优化**:Pull 时只拉模型声明过的字段可显著降流量,源码预留了接入点。 ### 13.3 一句话总结 > 同步引擎 = **两条带书签的流水线**(History token 驱动 Push,CloudKit token 驱动 Pull)+ **四道防线**(author 回环防护、tombstone 删除追踪、token 暂存的崩溃安全、有预算的错误恢复)+ **一个 actor 总指挥**(互斥去重、debounce、退避、离线补偿)。 把这 7 个词各自展开能讲 10 分钟,你就可以说你真正掌握了 CloudSyncKit。 --- *教程完。建议边读边在源码里搜索文中类名,把每章对应的文件打开对照——代码比文字诚实。*