# CloudSyncKit 源码导读 发布于 · 2026-09-10 · # 默认分类 # CloudSyncKit 源码导读 > **这份文档写给谁**:会用一点 Swift、刚接触 SwiftData,但**没写过同步引擎**的同学。 > > **这份文档不写什么**:不教你怎么把库接进 App。那是「接入方视角」,看仓库根的 > `README-CN.md`(API 用法)和 `ModelTemplates-CN.md`(模型模板)。 > 本文是「实现方视角」:**库内部是怎么转起来的,为什么这么设计,哪里最容易踩坑。** > > **怎么读**:第〇章补词 → 第一章建立画面感 → 第二章认路 → 第三章按需精读链路。 > 不要求一次读完。 --- ## 〇、迷你词典:先认识这几个词 这份文档会反复用到下面这些词。它们都是 Swift / SwiftData 的**基础概念**, 不是本库发明的(本库发明的词会单独标注)。**卡住了就回来查这张表。** ### 0.1 SwiftData 三件套 | 词 | 一句话解释 | 打个比方 | |---|---|---| | `@Model` | 加在 `class` 上的宏,把它变成"能存进数据库的表" | 给这个类发一张**入库许可证** | | `ModelContext` | 数据库的**一次操作会话**,增删改查都通过它 | 你的**草稿纸**:在上面写写画画,`save()` 才真正誊进账本 | | `@Query` | SwiftUI 里的查询属性,数据一变自动重新渲染 | 装了**自动刷新**的考勤表,谁改了它立刻跟着变 | | `PersistentModel` | "能入库"的协议,`@Model` 会自动让它遵循 | 入库许可证的**正式表格** | > ⚠️ **一个必须知道的坑**:`@Model` 宏会把你的业务属性**改写成计算属性** > (背后指向一块隐藏的存储)。所以 `Mirror`(Swift 的反射工具)**扫不到任何业务字段**。 > 本库整套"手写字段桥"的设计,根因就在这里。第三章链路 3 会展开讲。 ### 0.2 并发:actor 与"隔离域" | 词 | 一句话解释 | 打个比方 | |---|---|---| | `actor` | 一个**自带队列**的类型:同一时刻只有一个任务能进去执行 | 一间**只有一个服务窗口**的房间,大家自觉排队 | | `@MainActor` | 标记"只能在主线程上跑"(UI 相关的基本都要求这个) | 只有主厨能碰的那张**出菜台** | | `nonisolated` | 标记"不受上面那些隔离限制,随便哪个线程都能调" | 贴在墙上的**公告**,谁路过都能看 | | `Sendable` | 标记"这个值能安全地跨线程传递"(值类型通常天然满足) | **可以邮寄**的东西;不能邮寄的(如可变引用)就不满足 | | `@unchecked Sendable` | 我**手动保证**它能安全跨线程,编译器别管了 | 你说可以邮寄,但**出了事你负责** | > 本库为什么到处是 `nonisolated`?因为引擎要在后台线程读写数据库, > 而协议要求的方法必须"谁都能调"。看到 `nonisolated` 就理解成"这是为了让后台能调用"。 ### 0.3 Swift 协议的两个容易踩的坑 **坑 A:协议要求(requirement)vs 协议扩展默认实现** ```swift protocol Animal { static var sound: String { get } // ← ① 协议要求 } extension Animal { static var sound: String { "哼" } // ② 扩展里的默认实现 } struct Dog: Animal { static var sound: String { "汪" } // ③ 我覆写它 } // 关键区别在这里 ? func speak(_ type: T.Type) -> String { type.sound } // 泛型上下文 speak(Dog.self) // 有 ① 声明 → "汪"(动态派发,我的覆写生效) // 去掉 ① → "哼"(静态派发,我的覆写被静默无视!) ``` **一句话记住**:**在泛型 / `any Protocol` 上下文里用得到、又允许接入方覆写的成员, 必须写成「协议要求」**。只写在扩展里,覆写会被静默忽略——不报错,只是不生效。 本库的 `initialPublishState`、`catalogQueryDesiredKeys`、`syncScopes` 都属于这类。 **坑 B:`KeyPath`** `\.recordName` 就是 KeyPath——"**指向某个属性的路标**",本身不是值。 本库用它来告诉 SwiftData"删除时请把这两个属性的值保留下来"(详见 0.4 tombstone)。 ### 0.4 本库发明的词 | 词 | 含义 | 打个比方 | |---|---|---| | **作用域 / scope** | 数据在哪个库:`private_` / `shared` / `public_` | 东西放在**哪个房间** | | **tombstone(墓碑)** | 记录被删除后,SwiftData 留下的一小块"遗言" | 人走了,留块**牌子**:"这里曾经有个 XXX" | | **History 事务** | SwiftData 记的**流水账**:谁在什么时候改了哪条 | 账本上**按时间排的一行行记录** | | **History token** | "我上次处理到哪一行了"的书签 | **书签** | | **端口 / Port** | 一个协议,规定"外部能力长什么样"(如"能存取云") | **插座标准**:我不关心墙后面是火电水电,能插就行 | | **分波 / wave** | 把待上传的记录**按父子深度分批**发送 | **分批发货**:爸爸先走,儿子后走 | ### 0.5 三个房间:CloudKit 的三个数据库 本库要和三个"房间"打交道,**这是理解一切路由逻辑的地基**: | 房间 | CloudKit 叫法 | 谁能进 | 谁能改 | |---|---|---|---| | ? **私人保险柜** | `privateCloudDatabase`(私有库) | 只有你自己 | 只有你自己 | | ? **别人借你的房间** | `sharedCloudDatabase`(共享库) | 房主 + 被邀请的人 | 各自改各自的,房主说了算 | | ? **广场公告栏** | `publicCloudDatabase`(公共库) | **所有人**(能看) | **只有贴公告的人**能改自己那张 | > ⚠️ **三个房间的"门牌号"有个陷阱**:私人保险柜和公告栏的 `ownerName` > **长得一模一样**(都是 `__defaultOwner__`)。所以**不能靠 owner 判断东西在哪个房间**, > 只能靠 **zone 名**。这是第一章"不变式 1"的全部由来。 --- ## 一、先建立心智模型 ### 1.1 这个库到底在做什么 **一句话**:你在本地账本(SwiftData)上正常地读写,库在背后**悄悄帮你把账同步到云端三个房间**。 ``` ┌─────── 你的 App(UI / 业务代码)───────┐ 你只跟这里打交道 →│ @Model 读写 @Query 自动刷新 │ └──────────┬─────────────────────────────┘ │ 写入(普通"作者"身份) ▼ ┌───────── CloudSyncKit ─────────┐ │ Interface 门面 / 调度 / 分享UI / 生命周期 │ │ Application 用例(每条链路一个)+ 端口(协议)│ │ Infrastructure 真正干脏活的:CloudKit 适配 + 数据库适配 │ │ Domain 纯规则:协议 / 值对象 / 错误 / 事件(不认识任何框架)│ └──────────┬─────────────────────────────┘ │ ┌────────────────┴────────────────┐ ▼ ▼ ┌──── SwiftData 本地库 ────┐ ┌──── CloudKit 云端 ────┐ │ 引擎专属后台会话(盖章=syncAuthor)│ │ ? 私有 ? 共享 ? 公共 │ │ 主会话(只给你读) │ └───────────────────────┘ └──────────────────────────┘ ``` **再打个比方**:这个库就像你请的**仓库管理员**。 你只管在笔记本上记账;管理员会: 1. 定期把新账搬去云端的对应房间(**Push**); 2. 定期把云端的新变化抄回笔记本(**Pull**); 3. 自己搬的货会**盖章**,下次不重复搬(回环防护)。 ### 1.2 四个"当初就这么定了"的决定 这四个决定解释了后面**所有**代码为什么长这样。看不懂某段代码时,回来看看这张表: | 决定 | 换来了什么 | 代价 / 约束 | |---|---|---| | **不用 SwiftData 自带的 CloudKit 同步**(建容器时 `cloudKitDatabase: .none`) | 流水账(History)干净、路由完全自己说了算 | 必须**自己实现全套**上传下载——本库绝大部分代码都在干这个 | | **只有一个后台会话负责写** | 消除"主会话里的对象失效了、界面却没刷新"的撕裂 | 所有写回必须在同一个 actor 上**排队**(`SyncPersistenceActor`) | | **变更追踪靠 SwiftData 的流水账 + 盖章(`context.author`)** | 删除也能可靠追踪(tombstone)、自己的写入不会反弹回去 | 每个模型都得手写墓碑路标;`@Model` 让反射失效,字段只能手写 | | **`Domain` 层不认识 CloudKit / SwiftData** | 好测、好替换、路由规则集中在一个文件 | 需要一套"端口 + 适配器"的抽象(`Application/Ports`) | ### 1.3 四条主线 库的所有代码,都可以归到这四条线上。挑一条走通,剩下的都是变体: 1. **同步主线**:`Setup → Pull → Push`(由 `SyncCoordinator` 驱动,具体活在 `Application/UseCases`)。 2. **变更感知线**:本地一写 → 通知 → 过滤掉自己写的 → 防抖 → 触发同步。 3. **协作线**:`CKShare` 的创建 / 邀请 / 接受 / 退出(`ShareLifecycleUseCase`)。 4. **发布线**:私有草稿 → 公共副本(`PublishUseCase`,**纯本地写**,之后靠同步主线推上去)。 ### 1.4 三条不变式(读代码时的"锚点") > **什么是不变式**:一句话规矩,**任何时刻都必须成立**。 > 读代码时如果发现某段代码看起来违反了它——**基本就是 bug 或历史包袱**, > 这时候别急着改,先搞清为什么。 #### ? 不变式 1:一条记录属于哪个库,只由 `DatabaseScope.resolve` 说了算 **唯一权威规则**(`Domain/SyncConstants.swift` → `DatabaseScope.resolve`): ```swift static func resolve(scopes: Set, ownerName: String) -> DatabaseScope { if scopes.contains(.private_) { return forOwner(ownerName) } // 看 owner 分流 return scopes.contains(.public_) ? .public_ : .private_ } ``` **从 zone 反推**(同文件 → `DatabaseScope.forZone`): ```swift static func forZone(_ zoneID: ZoneIdentifier) -> DatabaseScope { zoneID.zoneName == SyncConstants.publicDatabaseZoneName ? .public_ : forOwner(zoneID.ownerName) } ``` > ⚠️ **为什么不能只看 `ownerName`**:还记得 0.5 的陷阱吗——私人保险柜和公告栏的 > `ownerName` **都是 `__defaultOwner__`**。用 owner 判断"是不是公告栏",结果**永远为真**, > 等于没判。唯一可靠的判据是 **zone 名:`_defaultZone` ⇒ 公告栏**。 **必须调这两个函数的地方**(一处都不能自己另写一套): | 场景 | 在哪儿 | |---|---| | **唯一口径**:算出某条记录归哪个房间 | `Domain/SyncConstants.swift` → `SyncModel.resolvedScope`(内部就调 `resolve`;用 `type(of: self)` 取运行时类型,这样接入方覆写的 `syncScopes` 才算数) | | 从流水账判断作用域 | `HistoryObserver.swift` → `fetchPendingChanges`(内部用 `syncModel.resolvedScope`) | | 编码时决定 zone | `Mappings/RecordEncoder.swift` → `convert(_:to:)`(作用域是**传进来的**,它只负责按 scope 选 zone) | | 全量重传时筛选记录 | `SyncPersistenceActor.swift` → `fetchRecords` | | **删除**时决定发给哪个房间 | `RecordHandler.swift` → `tombstoneZoneID` | | 资产缓存快照的作用域 | `SyncPersistenceActor.swift` → `fetchCacheableSnapshots` | #### ? 不变式 2:推得上去的,必须删得掉(推删同源) 墓碑(tombstone)里**只有 `recordName` 和 `ownerName`,没有 zone**。 可是删记录时必须知道发给哪个房间。怎么办?——**用和"推送"完全同一条规则反推**: ```swift // Interface/RecordHandler.swift → AutomaticRecordHandler.tombstoneZoneID public func tombstoneZoneID(ownerName: String) -> ZoneIdentifier { let resolved = DatabaseScope.resolve(scopes: Model.syncScopes, ownerName: ownerName) if resolved == .public_ { return ZoneIdentifier(zoneName: SyncConstants.publicDatabaseZoneName, ownerName: SyncConstants.currentUserOwnerName) } return ZoneIdentifier(zoneName: SyncEngine.shared.config.zoneName, ownerName: ownerName.isEmpty ? SyncConstants.currentUserOwnerName : ownerName) } ``` **违反了会怎样**:记录明明推上了公告栏,**删除请求却发去了私人保险柜**—— 云端留下一块**永远删不掉的垃圾**(谁也看不见,但一直占着)。 #### ? 不变式 3:不同房间的同名记录,是两条记录 去重和分波的键,必须是 **「作用域 + recordName」**: ```swift // Application/UseCases/PushChangesUseCase.swift → PushChangesUseCase.dedupKey private static func dedupKey(_ id: RecordIdentifier) -> String { "\(id.scope.rawValue)#\(id.recordName)" } ``` **为什么**:本地草稿 `Note` 和它的公开副本 `PublishedNote` **共用一个 recordName** (发布 = 同名投影,见链路 10)。它们在不同的房间、有不同的"版本戳", **是两条完全独立的云端记录**。如果只用 `recordName` 当键,两条会被折叠成一条, **丢掉一条**。 **违反了会怎样**:发布之后本地草稿莫名其妙消失 / 云端少一条记录。 ### 1.5 分层与依赖方向 ``` Interface ─────► Application ─────► Domain │ │ └──────────► Infrastructure ──► Domain ``` 箭头表示"谁依赖谁"。规矩是**只能顺着箭头依赖,不能反过来**: - **`Domain` 不认识任何框架**:`SyncModel` 不继承 `PersistentModel`, `DatabaseScope` 不依赖 `CKDatabase.Scope`,`SyncError` 不依赖 `CKError`。 → 好处:这套规则**能在没有 iCloud 账号的机器上跑测试**。 - **`Application` 只认端口(协议)**:用例不 import CloudKit,只调 `CloudKitGateway` / `PersistenceGateway`。 → 好处:测试时可以塞个假的网关进去(`CloudSyncKitTests` 就是这么干的)。 - **`Infrastructure` 是唯一的"脏"层**:所有 `CKRecord` / `ModelContext` 的转换都在这儿。 > **一个刻意保留的例外**:`ShareLifecycleUseCase` **绕过** `CloudKitGateway`,直连 `CKContainer`。 > 因为 `CKShare` 那套回调式的 API 跟网关的"值对象"模型格格不入, > 硬套抽象只会增加噪音。这个例外在 `Application/Ports/CloudKitGateway.swift` 的注释里有记录。 ### 1.6 建议的阅读顺序 1. `Domain/SyncConstants.swift` —— 三条规矩的**物理位置**,先看这个 2. `Domain/Protocols/SyncModel.swift` → `Interface/AutomaticSyncModel.swift` —— 模型契约 (顺便理解"为什么字段桥必须手写") 3. `Interface/SyncEngine.swift` → `enable(container:modelHandlers:)` —— 看**总装车间** 4. `Interface/SyncCoordinator.swift` —— 调度、去重、错误恢复 5. `Application/UseCases/PullChangesUseCase.swift` → `PushChangesUseCase.swift` —— 两条主干 6. `Infrastructure/SwiftData/HistoryObserver.swift` —— 变更到底从哪来 7. 剩下按需查。 --- ## 二、分层地图:每个文件夹是干嘛的 路径相对 `CloudSyncKit/` 目录。 | 目录 | 层级 | 职责(人话版) | 关键类型 | |---|---|---|---| | `Domain/Protocols/` | 领域 | **定规则**:什么样的类能同步 | `SyncModel`、`ShareableModel`、`CacheableModel`、`PublishableModel`(含 `PublicCatalogModel`)、`PublicationGovernedModel` | | `Domain/ValueObjects/` | 领域 | **小纸片**:可直接比较、可直接存档的小类型 | `DatabaseScope`、`ZoneIdentifier`、`RecordIdentifier`、`SyncFieldValue`、`PublicationStatus`、`ChangeToken`、`AssetReference`、`MissingReference`、`ShareInvitation` | | `Domain/Entities/` | 领域 | **配置与载体** | `SyncConfiguration`、`SyncRecordData`、`ChangeSet`、`CacheState`、`CacheableSnapshot` | | `Domain/Errors/` | 领域 | **出错了怎么办** | `SyncError`、`RecoveryAction`、`CloudSyncKitError`、`SyncError+Presentation` | | `Domain/Events/` | 领域 | **对外广播** | `SyncEvent`、`EventPublisher`、`StoreChangeNotification` | | `Domain/SyncConstants.swift` | 领域 | **三条路由规矩 + 常量** | `SyncConstants`、`DatabaseScope.forOwner/forZone/resolve` | | `Application/Ports/` | 应用 | **插座标准**(协议) | `CloudKitGateway`、`PersistenceGateway`、`ModelHandler`、`RecordConverter`、`TokenStore` | | `Application/UseCases/` | 应用 | **每条链路一个"办事员"** | `SetupSyncUseCase`、`PushChangesUseCase`、`PullChangesUseCase`、`PublishUseCase`、`ManageCacheUseCase`、`ShareLifecycleUseCase` | | `Application/Support/` | 应用 | 支撑零件 | `ErrorRecoveryPolicy`、`BackoffManager` | | `Infrastructure/CloudKit/` | 基础设施 | **真正跟 CloudKit 打电话** | `CloudKitGatewayImpl`、`CloudKitErrorMapper`、`RateLimiter` | | `Infrastructure/SwiftData/` | 基础设施 | **真正跟本地库打交道** | `SwiftDataPersistenceGateway`、`SyncPersistenceActor`、`HistoryObserver`、`SyncContainerFactory`、`Mappings/RecordEncoder` | | `Infrastructure/Storage/` | 基础设施 | 书签(token)存哪 | `HistoryTokenStore`、`TokenStoreImpl` | | `Infrastructure/ConfigurationProvider.swift` | 基础设施 | 默认配置 | `ConfigurationProvider` | | `Interface/` | 接口 | **门面 + 调度 + UI + 生命周期** | `SyncEngine`、`SyncCoordinator`、`SyncStatusMonitor`、`RecordHandler`、`AutomaticSyncModel`、`LocalChangeMonitor`、`BackgroundTaskGuard`、`CloudSyncAppDelegate`、`SharingUI/*` | | `Support/` | 支撑 | 库自己的语言包与文案出口 | `Bundle.cloudSyncKit`、`LocalizedStringResource.cs`、`String.cs`、`Localizable.xcstrings`、`PrivacyInfo.xcprivacy` | > ⚠️ **注意 `AutomaticSyncModel` 的位置**:它在 **`Interface/`** 而不是 `Domain/Protocols/`。 > 因为它是"零 Handler 接入"这个**接口层便利设计**的一部分(连同 `AutomaticRecordHandler`), > 跟纯粹描述数据形态的 `SyncModel` 不是一回事。 --- ## 三、链路详解 > 每条链路固定五段:**一句话人话版 → 代码路径 → 为什么这么做 → 具体怎么做 → 陷阱**。 > 「代码路径」里写的是 **`文件` → `函数`**,不写行号(原因见文末「关于引用方式」)。 --- ### 链路 1:装配与启用(`enable`) > **人话版**:开业前的总装——把管理员、账本、电话线全部接好。**顺序不能乱。** #### 代码路径 ``` SyncEngine.enable(container:) Interface/SyncEngine.swift └─ SyncEngine.enable(container:modelHandlers:) ← 真正的总装车间 ├─ SyncStatusMonitor.attach() Interface/SyncStatusMonitor.swift ├─ CloudKitGatewayImpl(container:config:) Infrastructure/CloudKit/CloudKitGatewayImpl.swift ├─ RecordEncoder(zoneName:).register(...) Infrastructure/SwiftData/Mappings/RecordEncoder.swift ├─ HistoryObserver(converter:tokenStore:…) Infrastructure/SwiftData/HistoryObserver.swift ├─ SyncPersistenceActor(container:…) Infrastructure/SwiftData/SyncPersistenceActor.swift ├─ ManageCacheUseCase(…) Application/UseCases/ManageCacheUseCase.swift ├─ SyncCoordinator(…) Interface/SyncCoordinator.swift ├─ LocalChangeMonitor { noteLocalChanges() } Interface/LocalChangeMonitor.swift └─ SyncCoordinator.enable() Interface/SyncCoordinator.swift ``` #### 为什么这么做 - **手动逐个 `new`,而不是上依赖注入框架**:依赖关系是一条**直线** (网关被所有用例依赖、持久化被所有用例依赖),一个构造函数就装得下。引入 DI 只会多一层迷雾。 - **一个引擎实例、一份依赖**:全部依赖在 `enable` 时建一次,之后只读。 所以 `CloudKitGatewayImpl` 这类能标 `@unchecked Sendable`("我保证线程安全")。 - **`SyncEngine` 用锁而不是 actor**:它需要**同步读取**配置(推送回调是 `nonisolated` 的, 不能 await)。`actor` 做不到同步访问。于是把可变状态收进 `EngineState`,用 `NSLock` 保护, 规矩是**闭包里不许 await**(锁不能跨挂起点)。 #### 具体怎么做(顺序为什么不能乱) 1. `guard coordinator != nil else { return }` —— 重复 `enable` 是安全的(幂等)。 2. **先挂状态监视器**:保证后面**所有**事件都有人听——包括"装备失败"的降级事件。 3. 读配置。 4. **降级短路**:如果数据库容器退化成了内存版(`didFallbackToInMemory`), 就只注册模型(让本地增删可用),然后标记不可用、广播失败事件、**直接返回**。 —— 没有流水账、没有云通道,同步根本没法启动,硬跑只会满屏报错。 5. 造 `CloudKitGatewayImpl`。 6. 造 `RecordEncoder` 并 `register(modelHandlers:)` —— 业务字段编码全靠它。 7. 造 `HistoryObserver`。 8. 造 `SyncPersistenceActor`,包进 `SwiftDataPersistenceGateway`。 9. 算出 `shareableRecordTypes` / `publicRecordTypes`(从已注册模型里筛类型名)。 10. 造 `ManageCacheUseCase`。 11. 造 `SyncCoordinator`(把上面所有用例 + 错误恢复策略都塞进去)。 12. 写回 `state`。 13. 启动变更监视线程。 14. `await coordinator.enable()`(见链路 2)。 15. **补做欠账**:若冷启动是被分享邀请唤起的,重置共享库书签并拉一次。 16. 首次检查 iCloud 账号可用性。 17. 补建分享元数据。 #### 陷阱 | 陷阱 | 后果 | 为什么会这样 | |---|---|---| | 先 `enable` 再 `register` | 模型**静默**不推不拉 | `enable` 会消费并清空待注册队列——晚注册的模型根本没进去 | | 忘记注册公开副本模型 | 发布"成功"但推不上公告栏 | `PublishUseCase` 会抛错(诊断码 21)——这里**没有**静默 | | `enable` 和 `register` 并发调用 | 数据竞争 | 全部经 `EngineState.access {}`(锁保护) | | 数据库降级后仍标记"已启用" | 界面显示在同步,实际啥也没干 | 降级分支会广播失败事件且不设 coordinator | | `configureCache` 在 `enable` **之后**调用 | 静默失效 | 引擎会 NSLog 提示"本次配置已忽略" | --- ### 链路 2:初始化(建房间 / 订报纸 / 可选大搬家) > **人话版**:刚开始工作前先把自己的仓库隔间建好、订阅好"有变化就通知我"、必要时把存量全搬一次。 #### 代码路径 ``` SyncCoordinator.enable() Interface/SyncCoordinator.swift ├─ ManageCacheUseCase.recoverInterruptedTransfers() Application/UseCases/ManageCacheUseCase.swift ├─ SetupSyncUseCase.execute(uploadAllData: false) Application/UseCases/SetupSyncUseCase.swift │ ├─ createZoneWithRetry │ ├─ CloudKitGateway.subscribe(to: .private_ / .shared) │ ├─ CloudKitGateway.subscribePublic(recordTypes:) │ └─ uploadAllLocalData()(仅 uploadAllData == true) ├─ performPull() ├─ PushChangesUseCase.execute() └─ runCacheMaintenance() ``` #### 为什么这么做 - **三件事合成一个用例**:建房间、订报纸、可选大搬家都是"让引擎能开工"的前置条件, 失败后果一样(这轮没法同步)。合成一个,比拆三个更好处理"卡在半路"。 - **订阅分两种**:私有库 / 共享库用 `CKDatabaseSubscription`(**整个库**有变化就推我); 公告栏**没有**这种能力,只能按类型各订一条 `CKQuerySubscription`。 - **第一次不留书签**:书签是 `nil` ⇒ 所有没读过的流水账都会被推送 ⇒ **天然实现 "开同步就把存量数据传上去"**,不需要另写一套"首次上传"逻辑。这是很漂亮的一个设计。 - **只在明确要求时才大搬家**:日常靠流水账增量就够了;全量上传留给"修复"和"房间重建"。 #### 具体怎么做 1. 先 `recoverInterruptedTransfers()`:把上次中断在"上传中 / 下载中"的资产**捡回队列**。 2. `createZoneWithRetry`:建房间失败时,**只对临时性错误**(限流 / 服务不可用 / 断网 / 房间忙 / 账号暂不可用)重试 3 次、间隔翻倍(1 秒 → 2 秒)。其他错误原样抛出,交给策略层。 3. 注册三种订阅。公告栏订阅 ID 是 `"{前缀}-{类型名}"`,必须和通知解析用的前缀一致。 4. `uploadAllLocalData()`(可选):**逐个房间**遍历,每个房间里**按父子深度分批发货**,全字段上传。 5. 初始化失败时**不要**继续拉取 / 推送:房间还不存在时硬推,只会每轮报"房间找不到"。 #### 陷阱 | 陷阱 | 后果 | 为什么 | |---|---|---| | 建房间失败不重试 | 永久卡在"房间缺失",之后每次推送报 26 且永不自愈 | 需要 `createZoneWithRetry` + 恢复策略里的"重建房间" | | 初始化失败还继续拉取/推送 | 请求风暴 | `SyncCoordinator.enable()` 失败即 `return` | | 大搬家不按父子分批发货 | CloudKit 整批拒绝(`CKError 15`) | 必须复用 `waveGroups` | | 大搬家时按"传入的房间"编码全部模型 | **私人数据泄漏进公告栏** | 内部**按每个模型自己声明的作用域**编码,再筛选 | --- ### 链路 3:Push(本地改动 → 云端) > **人话版**:把笔记本上的新改动搬上云。这是全库**踩坑最密集**的地方,四道工序缺一不可。 #### 代码路径 ``` PushChangesUseCase.execute() Application/UseCases/PushChangesUseCase.swift ├─ persistence.fetchPendingChanges() → SyncPersistenceActor.fetchPendingChanges │ └─ HistoryObserver.fetchPendingChanges Infrastructure/SwiftData/HistoryObserver.swift ├─ deduplicated(…) ← ① 去重 ├─ ensureAncestorsInBatch(inserted) ← ② 父记录保障 ├─ buildInFlightParentIndex ├─ pushInWaves(resolveInheritedOwners(…)) ← ③ 分批发货 + ④ 共享库改 owner │ ├─ waveGroups(records) │ ├─ groupByScope(records) │ └─ cloudKit.modifyRecords(…, in: scope, …) Infrastructure/CloudKit/CloudKitGatewayImpl.swift ├─ deleted 按 scope 分组 → cloudKit.deleteRecords ├─ persistence.markChangesAsPushed(…) └─ publish(.pushCompleted(recordCount:)) (失败分支:discardStagedToken + pushFailed + throw) ``` #### 为什么这么做(四道工序各自在防什么) > ? **先说一个前提:业务字段为什么要"手写桥"?** > `@Model` 宏会把你的业务属性**改写成计算属性**(见 0.1 的警告), > 所以 `Mirror` 反射扫出来的字段列表**永远是空的**。 > 于是本库的办法是:让每个模型**显式**实现两个方法—— > `encodeRemoteFields()`(本地属性 → 要上传的字段)和 `applyRemoteFields(_:)`(拉回来的字段 → 本地属性)。 > 这一对就叫「字段桥」。 > **代价**是每个业务字段要写两行;**换来的**是"漏了字段会在编译期报错", > 而不是"用户数据悄悄少一半"。 1. **去重**:同一条记录可能出现在多条流水账里(同一个对象被 `save` 了好几次)。 不去的后果:CloudKit 报 `"You can't save the same record twice"`(`CKError 12`) → **整批全失败**(一颗老鼠屎坏一锅汤)。 2. **父记录保障**:CloudKit 保存带"父"的记录时,要求父**已经存在**或**在同一批里**。 父从没上过云时,子单独上传会被整批拒绝(常见 `CKError 15`)。 3. **分批发货**:解决的是**顺序**问题,不是性能问题。第 ② 步补进来的父会被塞到数组**末尾**, 不重新分批的话,父和子会在同一批、甚至子先被上传。 4. **共享库改 owner**:协作者在别人的共享清单下新建条目时,条目本地的 owner 仍是自己, 但**必须写进房主的房间**(否则别人看不到)。 > ? **第 2、3 步为什么都需要**?想象搬家:② 是"发现少搬了爸爸,赶紧把爸爸也加进清单", > ③ 是"按辈分重新排发货顺序"。只做 ② 不做 ③,爸爸会被排在最后一车,儿子先到了没人签收。 #### 关键算法 - **`deduplicated`**:倒着遍历 + 记录见过的键,保留**最后一次**(后面的版本更新)。 键是 `作用域#记录名`(不变式 3)。 - **`ensureAncestorsInBatch`**:**一层一层往上找**,每层只在**本地**取父记录(不发网络请求), 然后用一次"批量核实"问云端**这些父到底在不在**;只有**确定不在**的才加进本批。最多 8 层防死循环。 网络出问题时**按"存在"处理**(保守:宁可不补传,也不能误判)。 - **`waveGroups`**:以 `作用域#记录名` 建索引,递归算"父链有多深", 按深度从小到大分批。用 `Int.max` 标记"正在计算中"来防环。 - **`resolveInheritedOwners`**: - 公告栏的记录**原样通过**(公告栏没有共享层级,乱继承会把 owner 改坏); - 其他记录:如果自己的 owner 是当前用户,就沿父链往上找**房主**,找到就把 owner 改写过去, 并把父引用的房间也一起改;同时**写回本地**(保证之后的更新/删除路由正确)。 - **删除路由**:先按墓碑还原出的作用域分组,再逐个房间删。 - **成功才推进书签**:`markChangesAsPushed` 持久化书签;失败则 `discardStagedToken`。 > ⚠️ **这一步极其关键**:不丢弃暂存书签的话,这批没推成功的事务会在下次读取时被**过滤掉**, > 于是**删除被永久静默吞掉**——表现为"删了又复活"。 #### 陷阱 | 陷阱 | 症状 | 根因 / 修法 | |---|---|---| | 同批出现重复记录 | `CKError 12` 整批失败 | 去重键必须是 `作用域#记录名` | | 子先于父上传 | `CKError 15` / Internal server error | 必须分批 + 补父 | | 父引用的房间没跟着改 | `"Only shared zones can be accessed in the shared DB"` | 改 owner 时父引用的房间要一起改 | | 公告栏记录被"共享继承"改写 | 公开记录归属错乱 | `resolveInheritedOwners` 开头就要挡掉 `.public_` | | 推送失败没丢弃暂存书签 | 删除被静默吞掉、重装"复活" | 失败分支必须 `discardStagedToken()` | | 用 `.deleteSelf` 表达父子关系 | 本地直接崩溃(`NSInternalInconsistencyException`) | `CKRecord.parent` 的 action **必须 `.none`** | | 普通引用字段的级联语义搞错 | 目标被删时,引用方被连带删掉 | 普通引用用 `.deleteSelf`,父子关系走 `CKRecord.parent`(另一条路) | --- ### 链路 4:Pull(云端 → 本地) > **人话版**:把云上的新变化抄回笔记本。"哪些房间变了"和"房间里哪几条变了"是**两级问题**。 #### 代码路径 ``` PullChangesUseCase.execute(scopes:) Application/UseCases/PullChangesUseCase.swift ├─ .public_ → pullPublicCatalog() ← 公告栏:另一条完全不同的路 └─ 其余 → pullFromScope(scope) └─ performPullFromScope(scope) ├─ cloudKit.fetchDatabaseChanges(after:) ← 第一级:哪些房间变了 │ ├─ deletedZones → handleDeletedZone() │ └─ changedZones → pullZoneChanges() ← 第二级:房间里哪几条变了 │ ├─ cloudKit.fetchZoneChanges() │ ├─ persistence.upsertRecords() │ ├─ deletedShareRoots → deleteLocalSubtree() │ └─ reconcileSharedZone()(弱信号兜底) ├─ tokenStore.setToken(dbResult.newToken) └─ resolveMissingReferences(in: scope) ``` #### 为什么这么做 - **两级增量**:第一级告诉你"哪些房间有动静",第二级告诉你"房间里哪几条记录变了"。 两级都要显式处理 `moreComing` 分页(一次拉不完要接着拉)。 - **公告栏走完全不同的路**:公告栏**没有房间变更流、没有增量书签**, 每次只能按类型**全量查询**(它本来就是"只读目录"的语义)。 - **共享库的"分享被撤销"要分强弱信号判断**(这是最绕的一处): - **强信号**:这一批的删除里,出现了**本地已知的分享根记录** ⇒ 房主不分享了 ⇒ **当场**清理本地数据。 - **弱信号**:看到 `cloudkit.share` 记录被删。这可能只是"旧分享被新分享替换了" (停止后立刻重新分享),此刻分享**仍然有效** ⇒ **不当场清理**,先记下"证据", 批末再逐个根记录问云端复核。 > ? 为什么要这么小心?因为**误删用户数据是不可挽回的**,而晚一点清理最多是数据多留一会儿。 > 这就是"保守优先"的典型取舍。 - **引用修复**:写回时如果发现某字段指向一个本地不存在的目标(悬空引用), 先记下来,批末补拉一轮再重放。 #### 关键算法 - **`pullZoneChanges`**: 1. 拉一批变更 → `upsertRecords`(内部处理加密字段合并、下载掩码、资产标记、悬空引用收集), 并发布进度事件(进度条靠它)。 2. 如果是共享库且本批有删除:**惰性**算一次本地分享根名单,挑出"被删的分享根" (要排除同批又被重建的)→ 清理本地子树。 3. 如果看到 `cloudkit.share` 被删 → 记下"证据"。 4. 推进书签;`moreComing` 决定要不要继续。 5. 批末如果有证据 → `reconcileSharedZone`:逐根记录问云端, **只有"确实不存在"或"没权限"才判定失效并清理** (网络错误**不清理**——数据多留一会儿无害,误删才是灾难)。 - **`handleDeletedZone`**:**不能**用"清空所有同步数据",那会把参与者自己的私有数据也清掉; 要按房间精确清理。 - **`resolveMissingReferences`**:最多 2 轮,每轮:去重后逐个补拉 → 复查还剩谁 → 合并新暴露的悬空引用。 还有悬空的就报个错,**不阻塞同步**(不然一个坏引用会卡死整条链路)。 - **恢复预算**:拉取最多尝试恢复 1 次,只处理"书签过期 / 用户删了房间 / 限流", 避免无限递归。 - **共享库权限被拒**:视为"分享已撤销",清理该房间 + 重置书签,**把错误吞掉** (不吞的话这个房间永远失败,会卡死整个共享库同步)。 #### 公共目录的全量回灌 + 删除对账 公告栏没有增量通道,所以: 1. 按 `publicRecordTypes` 逐个类型**全量查询**。查询时带 **字段白名单** (`publicCatalogDesiredKeys`,见链路 12)——这是"公告栏的图片默认不下载"的开关。 2. 查询**成功后**,做一次**删除对账**(`reconcilePublicDeletions`): 云端这次没返回、但本地还留着的公开副本,就是"已经被撤下的",删掉它。 > ⚠️ **对账只在查询成功后执行**。查询失败时**不做对账**——否则一次网络抖动会被误读成 > "云端全没了",把本地副本全删光。这也解释了一个常见现象:**类型还没建型、或 > `recordName` 没建索引时,目录里的记录不会消失**(不是 bug,是护栏)。 #### 陷阱 | 陷阱 | 症状 | 根因 / 修法 | |---|---|---| | 用"根记录缺 `shareRecordData` 字段"判断撤销 | **活跃分享被误删**(刚加进来内容就被清空) | 那是服务字段、**永不上云**,共享库拉回的根记录天然没有它。只能用"根记录能不能读回来"判断 | | 删房间时用"清空所有数据" | 参与者私有数据被连带清空 | 必须按房间精确清理 | | `moreComing` 没处理 | 数据一多就只拉第一页 | 两级循环都要 `while moreComing` | | 分页不发进度 | 状态栏长时间停在"正在拉取…" | 每页都要发布进度事件 | | 引用修复不设轮数上限 | 深层引用链变成无限拉取 | 最多 2 轮 | | 共享库权限错误直接抛出 | 那个房间永久失败、卡死共享库 | 视为退出分享:吞错、清房间、重置书签 | | 删除对账没排除"同批重建" | "停止→重新分享"时误删刚拉回的数据 | 要用"重建集合"排除 | --- ### 链路 5:本地一写就自动推送 > **人话版**:你在界面上改了东西,怎么自动触发上传?靠"**听通知 + 防抖 + 过滤掉自己写的**"。 #### 代码路径 ``` LocalChangeMonitor.start() Interface/LocalChangeMonitor.swift ├─ NotificationCenter: ModelContext.didSave (主通道) └─ NotificationCenter: .NSPersistentStoreRemoteChange(兼容通道) ↓ 收到通知 → Task { await onStoreChange() } SyncEngine.noteLocalChanges() Interface/SyncEngine.swift └─ SyncCoordinator.noteLocalChanges() ↓ 等 0.5 秒(防抖) SyncCoordinator.syncLocalChanges() ├─ persistence.hasPendingChanges() ├─ PushChangesUseCase.execute() ├─ performPull() └─ runCacheMaintenance() ``` #### 为什么这么做 - **`ModelContext.didSave` 才是主通道**:SwiftData 的底层存储**不保证**投递 CoreData 的 `NSPersistentStoreRemoteChange`。旧实现只听后者,结果**绝大多数应用层保存都被漏掉** ⇒ "写入即推"整体失效(新建的条目永远上不了云)。 - **监听"全部通知"也没关系**:通知只是个**触发信号**,真正判断"有没有该推送的改动"的是 `hasPendingChanges`(靠流水账过滤),引擎自己写的会被天然滤掉。 误触发的最坏代价只是一次廉价的本地查询。 - **防抖 0.5 秒**:一次业务操作(比如"新建并填字段")可能触发好几次 `save`。 #### 陷阱 | 陷阱 | 症状 | 为什么 | |---|---|---| | 只监听 `NSPersistentStoreRemoteChange` | 新建的条目永远不推送 | 必须同时监听 `ModelContext.didSave` | | 用"最近一条流水"判断有无待推送 | 引擎写回后,你的改动要等到下次编辑才推 | `hasPendingChanges` 必须**全量扫描** | | 忘了按"作者"过滤 | 拉取写回的数据又被推回云端(打乒乓球) | 过滤掉 `author == syncAuthor` 的事务 | --- ### 链路 6:变更追踪(流水账 / 墓碑 / 书签) > **人话版**:引擎怎么知道"哪儿变了"?靠三样东西:**流水账**(记了什么变了)、 > **墓碑**(记了什么被删了)、**书签**(我读到哪了)。 #### 代码路径 ``` 写入侧:SyncPersistenceActor.init Infrastructure/SwiftData/SyncPersistenceActor.swift context.author = syncAuthor ← 给引擎自己的写入盖章 读取侧:HistoryObserver.fetchPendingChanges(in:) Infrastructure/SwiftData/HistoryObserver.swift ├─ context.fetchHistory(…) ← 读流水账 ├─ 过滤 author == syncAuthor ← 别读自己写的 ├─ 过滤 token <= lastToken ← 别读已经处理过的 ├─ .insert / .update → converter.convert(…) RecordEncoder.swift └─ .delete → handler.extractDeletedIdentifier RecordHandler.swift 推进侧:HistoryObserver.markAsPushed(_:in:) ← 推送成功才推进书签 丢弃侧:HistoryObserver.discardStagedToken() ← 推送失败就丢掉暂存书签 快速判定:HistoryObserver.hasPendingChanges(in:) ``` #### 为什么这么做 - **用"盖章"防打乒乓球**:引擎后台会话的章固定是 `syncAuthor`, 它写出来的流水账都带着这个章,`fetchPendingChanges` 统统过滤掉 ⇒ **从根本上消除"拉回来的数据又被推回去"**。你 App 的主会话用普通章,正常进待推送队列。 - **墓碑怎么记得住删除**:给属性加 `@Attribute(.preserveValueOnDeletion)`, SwiftData 会在删除时把 `recordName` / `ownerName` 的**值**保留进墓碑。 这样记录都删掉了,你还读得到定位信息。 - **书签的"暂存"语义**(全库最微妙的一处状态机): - 读到最新书签后**只放内存**(`stagedToken`),**先不落盘**; - 等推送**成功**了,才真正保存书签、并清理已读的流水账; - 推送**失败**就丢弃暂存,下次重新覆盖全部没读过的事务。 - **为什么**:CloudKit 的"保存"是**幂等**的(重复推没坏处), 但**漏推**(书签跑过头)会**永久丢数据**。所以宁可重复,不可遗漏。 #### 陷阱 | 陷阱 | 症状 | 根因 | |---|---|---| | 墓碑路标写在**泛型上下文**(协议扩展 / 泛型基类) | 本地删除**永远推不上云**,重装"复活" | SwiftData 按 KeyPath 的**身份**匹配存储键;泛型上下文造出来的路标跟删除时存进去的**不是同一个**,于是永远查不到。必须写在**具体类型的扩展**里 | | `recordName` / `ownerName` 没加 `@Attribute(.preserveValueOnDeletion)` | 同上 | 删除后属性值没被保留进墓碑 | | 推送失败不丢暂存书签 | 删除被永久静默吞掉 | 必须 `discardStagedToken()` | | 流水账不清理 | 无限增长 | 只在"没有未读事务"时才清理,保证有界 | | 引擎会话忘了盖章 | 打乒乓球 | 初始化时设 `context.author = syncAuthor` | > ? **"删除被静默吞掉"是本库最阴的一类 bug**(不报错、不崩,只是数据没了)。 > 所以代码里埋了一道护栏:墓碑提取失败时会打一条去重后的告警日志。 > 排查这类问题时**一定要看日志**。 --- ### 链路 7:作用域路由(横切三条通道) > **人话版**:这不是一条独立链路,而是**贯穿始终的一条铁律**——"这件货该进哪个房间"。 | 环节 | 代码路径 | 依据 | |---|---|---| | **算出作用域(唯一口径)** | `Domain/SyncConstants.swift` → `SyncModel.resolvedScope` | `DatabaseScope.resolve(scopes:ownerName:)` | | 从流水账读取 | `HistoryObserver.swift` → `fetchPendingChanges` | 用 `syncModel.resolvedScope` | | 编码时选 zone | `Mappings/RecordEncoder.swift` → `convert(_:to:)` | 按**传入的** scope:`.public_` → 默认 zone;否则配置 zone + 模型 owner | | **删除**路由 | `RecordHandler.swift` → `tombstoneZoneID` | 同一条规则(**推删同源**) | | 推送分组 | `PushChangesUseCase.swift` → `groupByScope` | `identifier.scope`(由 zone 推导) | | 全量重传筛选 | `SyncPersistenceActor.swift` → `fetchRecords` | `model.resolvedScope == scope` | | 缓存快照作用域 | `SyncPersistenceActor.swift` → `fetchCacheableSnapshots` | `cacheable.resolvedScope` | > ? **记住一个名字:`SyncModel.resolvedScope`**。它是"这条记录归哪个房间"的**唯一口径**—— > 全量读取、删除路由、写回判定、缓存快照全都走它。看到别处自己写了一套 `if scopes.contains(...)`, > 那就是**分叉**,早晚出事。 #### 为什么 `scope` 是算出来的,不是存下来的 `RecordIdentifier.scope` 是**计算属性**,从 `zoneID` 实时推导,**刻意不存字段**。 原因:它参与存档和去重,加字段意味着**数据迁移 + 一堆调用点要改**; 而它完全可以从 zone 精确还原——**能算出来的就别存**。 #### 陷阱 - **不要用 `zoneID.ownerName` 判断是不是公告栏**(见 0.5 的陷阱)。 - **`DatabaseScope.forOwner` 只能用于私有/共享**,涉及公告栏一律走 `forZone` / `resolve`。 - **一个模型不支持同时声明 `[.private_, .public_]`**(`resolve` 会落到私有分支)。 要"私有 + 公开"就用**两个投影模型**(`PublishableModel` + `PublicCatalogModel`)。 --- ### 链路 8:共享(房主侧) > **人话版**:把一份清单变成"可共享",然后邀请别人进来。 #### 代码路径 ``` SyncEngine.createShare(rootRecordName:recordType:…) Interface/SyncEngine.swift └─ ShareLifecycleUseCase.createShare(…) Application/UseCases/ShareLifecycleUseCase.swift ├─ fetchShareForOwner(rootRecordName:) ← 主路径:读记录的 share 引用 │ └─ fetchExistingShareHandle()(兜底) ← 枚举房间找 ├─ CKShare(rootRecord:shareID:) ← 自己指定 share 的 ID └─ db.modifyRecords(saving: [root, share]) ← 根记录 + share 同批保存 SyncEngine.stopSharing(rootRecordName:recordType:) SyncEngine.inviteParticipants(…) SyncEngine.deleteCascade(recordName:recordType:) SyncEngine.leaveShare(recordName:recordType:) ``` #### 为什么这么做(三条用坑换来的 CloudKit 硬知识) 1. **根记录和 `CKShare` 必须同一批保存**,否则云端拒绝(`CKError 12`), 表现是系统分享面板"无法添加成员"。 2. **share 的记录名由我们"提交前自己指定"**。好处:停止分享时**可以直接按 ID 删**, 不用先找到它——因为 `cloudkit.share` 是系统类型、**不能被查询**, 而且不一定会出现在房间变更里,靠"找"会变成"没找到却以为成功了"。 3. **`CKShare` 的 ID 和链接由服务端确认**,一律从本次保存的**返回值**里取,不要另外查。 #### 具体怎么做 - **主路径**:拉根记录 → 读它的 `share` 引用 → 按引用取 `CKShare`。这是官方语义。 **兜底**:`share` 没返回时去枚举房间。兜底命中会打日志(提示主路径失效,值得排查)。 - **进哪个房间**:`ownerName` 是自己 → 私有库(房主侧);否则 → 共享库(参与者侧)。 - **判断"谁是我"不能用参与者对象的相等比较**:`CKShareParticipant` 的相等判断跨实例**不可靠**, 实测会把房主误判成协作者。改用 SDK 明确的 `role == .owner` + 按记录名比较字符串。 - **邀请成员**:先查人 → 批量解析 → 加为参与者 → 保存(遇到"服务端版本变了"就重取重放一次)。 重复加同一个人是幂等的。 - **公开链接和具名邀请互斥**:已经是"公开链接"状态时,不能增删参与者。 - **关闭公开链接的破坏性**:保存 `publicPermission = .none` 时,服务端会**删掉所有** 通过链接加入的参与者(SDK 原话)。所以 UI **必须二次确认**。 #### 陷阱 | 陷阱 | 症状 | 根因 / 修法 | |---|---|---| | share 和根记录分两次保存 | 面板"无法添加成员" | 必须同一批 | | 靠枚举房间找 share | "点了停止分享但分享仍有效" | 用本地记录里还原的 ID **直接删** | | 用参与者相等比较判断 | 权限选择器恒显示房主"可编辑" | 用 `role == .owner` + 记录名比较 | | `CKShare` 标题/类型字段类型不对 | `invalid attempt to set value type` | 值的类型必须与 `RecordEncoder` 一致(Date 用时间戳) | | `deleteCascade` 直接删别人的共享根 | 权限拒绝,墓碑反复重试堵住队列 | 自动转成 `leaveShare` | | `leaveShare` 按房间清理 | 误删同一房主的**其他**清单 | 同一房主的清单都在一个房间;只能按子树删 | | 关闭公开链接不提示 | 链接加入者被静默移除 | UI 必须二次确认 | | `shareRecordData` 不上云 | 新设备把"我分享的"显示成"未分享" | 引擎自动补建分享元数据 | --- ### 链路 9:共享(参与者接受邀请) > **人话版**:别人邀请我,我点接受之后发生了什么。 #### 代码路径 ``` (系统回调)CloudSyncAppDelegate.application(_:userDidAcceptCloudKitShareWith:) Interface/CloudSyncAppDelegate.swift SyncEngine.acceptCloudKitShare(metadata:) SyncEngine.acceptShareLink(_:) ├─ Self.fetchShareMetadata(for:container:) ← 拿到元数据 ├─ ckContainer.accept(metadata) ← 告诉系统"我接受了" └─ startBackgroundSharedPull(metadata:) ├─ persistShareRootImmediately(metadata) ← 根记录优先落库 ├─ publish(.shareAcceptCompleted) ← 接受成功就广播,不等数据 └─ runSharedPullAfterAccept()(后台) ├─ coordinator.resetPullTokens(in: .shared) └─ pull() ``` #### 为什么这么做 - **接受成功就算"已加入"**:先把**分享根记录**立刻落库(界面马上能看到), 广播"接受完成";剩下的数据转后台全量拉。**不该让界面一直是 loading**。 - **必须先重置共享库书签再全量拉**:数据库级书签可能已经越过了这个房间的变更点 (尤其是"停止→重新分享"之后),走增量会认为"没有新数据" ⇒ **共享内容永远不落地**。 - **不能在 Safari 里打开链接**:系统会按 bundle ID 去 App Store 校验然后失败。 必须在 App 内取元数据,再走统一接受流程。 #### 陷阱 | 陷阱 | 症状 | 根因 / 修法 | |---|---|---| | 接受后只做增量拉取 | 共享内容不落地 | 必须先重置共享库书签再全量 | | 引擎没就绪 / 离线时接受 | "加进去了但什么都没有" | 记下欠账,引擎就绪或恢复在线后补拉 | | 界面把"接受成功"当长 loading | 大数据量时一直转圈 | 根记录优先落库 + 后台全量,进度走事件 | --- ### 链路 10:发布(私有草稿 → 公开副本) > **人话版**:把一份"自己看的"内容变成"所有人看的"。 > 关键设计:**发布是纯本地操作**,云端那一步交给同步主线。 #### 代码路径 ``` SyncEngine.publish(_:recordName:) Interface/SyncEngine.swift └─ PublishUseCase.publish(recordName:as:) Application/UseCases/PublishUseCase.swift ├─ 校验:记录名非空 / 投影模型已注册 / 声明了 .public_ ├─ 取草稿 ├─ 取或新建副本(同 recordName) ├─ draft.project(to: projection) Domain/Protocols/PublishableModel.swift ├─ (治理副本)刷新内容版本戳 └─ context.save() → 进流水账 → 引擎自动推公告栏 SyncEngine.unpublish(_:recordName:) ← 删掉公开副本 SyncEngine.isPublished(_:recordName:) SyncEngine.takedown / relist / publicationStatus ``` #### 为什么这么做 - **纯本地写,不直接调 CloudKit**:发布只是在本地建/改/删公开副本, 走的是**跟普通"改了就要推"完全同一条路**。好处: **离线也能发布**;而且**只有一条写入路径**(不用另外维护一套"发布专用的云端写 + 重试")。 - **关联键就是记录名**(草稿和副本同名):不需要加字段、不需要改数据库结构、不需要外键。 - **字段投影复用现成的桥**:默认的 `project(to:)` 就是 "用草稿的上传编码 → 用副本的下载解码",逐字段拷贝一遍。 于是**副本的 `applyRemoteFields` 声明了哪些字段,就公开哪些字段**—— 草稿的私有字段因为副本**根本没声明**,天然不会被带过去。 这就是相对"手写逐字段拷贝"的核心收益。 - **幂等**:重复发布 = 原地更新;重复撤回 = 什么都不做。 #### 陷阱 | 陷阱 | 症状 | 根因 / 修法 | |---|---|---| | 投影模型没注册 | 发布推不出去 | `PublishUseCase` 抛错(诊断码 21),**不静默** | | 投影模型被误声明成 `[.private_]` | "发布"进了自己的保险柜,别人啥也看不到,**零报错** | 发布时会显式校验有没有 `.public_`,否则抛诊断码 22 | | 草稿和副本字段名不一致 | 投影不过去 | 字段名必须对齐(草稿叫 `title`,副本也得叫 `title`) | | 副本可选字段为 `nil` 时误清空 | 云端值被抹掉 | 默认投影遵循"拉取写回"语义:`nil` ⇒ **保留现值** | | 删草稿时以为会自动撤回 | 公开副本还在,内容还在目录里 | 顺序必须是**先撤回、再删草稿** | | 更新发布把已下架的条目重新上架 | 审核事故 | 上架状态**只在首次发布时**落定 | | 让别人去改公开副本 | 云端权限拒绝 | 只有创建者能改;引擎跳过这条,下轮拉取覆盖回正 | --- ### 链路 11:发布生命周期治理(下架 / 审核回传 / 更新回执) > **人话版**:内容发出去之后,还想管"能不能被看到、审核结果、有没有更新成功"。 #### 代码路径 ``` SyncEngine.takedown / relist Interface/SyncEngine.swift └─ PublishUseCase.takedown / relist → setShelfState Application/UseCases/PublishUseCase.swift SyncEngine.publicationStatus(_:recordName:) 字段写回(自动):AutomaticRecordHandler.apply Interface/AutomaticSyncModel.swift └─ PublicationGovernedModel.applyPublicationFields Domain/Protocols/PublicationGovernedModel.swift 字段编码(自动):AutomaticRecordHandler.encodeFields 上传掩码:AutomaticRecordHandler.uploadExcludedFieldNames ``` #### 为什么这么做 - **6 个生命周期字段是"库保留名",不进模型的字段桥**:`AutomaticRecordHandler` 会自动 帮你编码 / 写回。你只声明属性,**零映射代码**。 - **审核相关字段是"服务端说了算",客户端只读**:既不在编码里写,又被上传掩码兜底剔除。 ⇒ 服务端在后台改的审核结论,**不会被你一次"更新发布"覆盖掉**。 - **协议要求 vs 扩展默认实现**(0.3 坑 A 的实例):`initialPublishState` 必须是 **协议要求**。因为发布逻辑是在**泛型上下文**里读它的,只写在扩展里的话, 接入方的覆写会被**静默忽略**——"先审后上"配置失效,且不报错。 - **下架 ≠ 撤回**:下架只是把状态标成"已下架"(**记录还在公告栏**), 撤回才是真删。因此**看目录的一方必须自己过滤**(用 `PublicationStatus.isVisibleInCatalog`)。 #### 陷阱 | 陷阱 | 症状 | 根因 / 修法 | |---|---|---| | `initialPublishState` 只写在扩展里 | "先审后上"配置无效 | 必须声明为协议要求(走动态派发) | | 消费侧不过滤已下架记录 | 下架的内容还出现在目录里 | 用 `isVisibleInCatalog` 过滤 | | 刚发布完就断言"更新已确认" | 误报缺陷 | 推送**不回写**系统字段,回执要等**下一轮拉取** | | 把生命周期字段写进字段桥 | 与库的编码冲突,可能覆盖审核结论 | 只声明属性,别写桥 | --- ### 链路 12:资产缓存(图片 / 文件) > **人话版**:图片这种大文件不走"字段"通道,走一条**专用流水线**: > 文件存本地缓存目录,云端单独存一份二进制。 #### 代码路径 ``` SyncEngine.setAsset(data:suffix:recordName:pinned:recordType:) Interface/SyncEngine.swift ├─ AssetFileStore.fileURL(…) → data.write(to:) Application/UseCases/ManageCacheUseCase.swift └─ persistence.setAssetPending(…) Infrastructure/SwiftData/SyncPersistenceActor.swift SyncEngine.assetFileURL(recordName:suffix:) ← 读本地缓存文件 SyncEngine.requestAssetDownload(recordName:recordType:) ← 请求下载(remote → download) SyncCoordinator.runCacheMaintenance() ├─ enqueuePinnedDownloads() ├─ processPendingUploads() ├─ processPendingDownloads() └─ cleanupLRU() 拉取时识别资产:SyncPersistenceActor.markPulledAssetRemote ``` #### 为什么这么做 - **资产二进制不进字段桥**:它走 CloudKit 专用的 `assetData` 字段 (`SyncConstants.assetFieldName`)。 - **`suffix`(扩展名)是个例外**:本地维护,但**会随记录上云**。 因为一台全新设备拉回带资产的记录时,本地扩展名是空的—— 必须靠云端值恢复缓存文件名和类型(视频/音频/文档全靠扩展名)。 - **先推后清**:缓存维护会**先推一轮记录本体**。因为上传/下载资产都是 "先取这条记录"起步的,记录本体还没上云就会报 `CKError 11 Record not found`。 - **状态机**(由 actor 保证串行,不会打架): ``` 本地文件就绪 → local → upload → uploading → cached 云端有、本地没有 → remote → download → downloading → cached 缓存清出(LRU) → cached → unload → remote 失败 → 回退到 local / remote ``` #### ⚠️ 两个最容易忽略的关键机制 **① `recordType` 消歧参数** `setAsset` / `requestAssetDownload` 都有个 `recordType` 参数。**草稿和它的公开副本 共用同一个 recordName**(链路 10),所以本地可能同时存在两条同名记录。 不指定类型时,引擎会"遍历所有处理器取第一个命中的"——**可能把资产状态写到另一条记录上**。 ```swift // 本地存在同名草稿与公开副本时,必须传 recordType try await SyncEngine.shared.setAsset( data: imageData, suffix: "jpg", recordName: thumb.recordName, pinned: true, recordType: "Thumbnail" // ← 消歧 ) ``` **② 公告栏的资产「默认不下载」+ `assetSize` 指纹** 公告栏每轮只能**全量查询**,而 CloudKit 会把查询结果里每个 `CKAsset` 的**二进制一起下载**。 不管的话,哪怕这轮什么都没变,**整个目录的图片都会被重新拉一遍**。 解决方式:公开副本声明一个**字段白名单**(`catalogQueryDesiredKeys`), 明确列出"要拉哪些字段"——**把资产本体排除在外**: ```swift // 公开副本模型里声明(⚠️ 必须是协议要求,不能只写在扩展里,见 0.3 坑 A) static var catalogQueryDesiredKeys: [String]? { ["title", "createdAt", "updatedAt", SyncConstants.assetSuffixFieldName, // 让对端知道"云端有资产" SyncConstants.assetSizeFieldName] // 让对端知道"资产变了没有" } ``` 于是消费侧的行为变成:**默认不下载**(状态是 `.remote`,界面给个"下载"按钮), 点一下才按记录单独取。而"资产到底变没变"靠 **`assetSize`(上传时写入的字节数指纹)**判断—— 指纹和本地文件大小不一样,才降级回 `.remote` 提示重新下载。 > ⚠️ **白名单漏字段 = 公告栏一侧永远拉不到该字段**;⚠️ **不要把 `assetData` 写进去**。 #### 陷阱 | 陷阱 | 症状 | 根因 / 修法 | |---|---|---| | 刚给记录附了图片就立刻上传失败 | `CKError 11 Record not found` | 记录本体还没上云;缓存维护会先推一轮 | | `cacheStateRaw == nil` 被当成 `.local` | 所有没有资产的条目都被误当"待上传" | 快照查询显式要求"状态字段非空" | | 资产状态写到了另一条记录 | 草稿的图片状态跑到公开副本上 | 资产 API 要传 `recordType` | | 公告栏图片每轮都被重新下载 | 流量爆炸、卡顿 | 公开副本要声明 `catalogQueryDesiredKeys` 白名单 | | 公告栏某个字段总是空的(私有草稿正常) | 像"发布没生效" | 白名单漏列了那个字段,且必须与 `applyRemoteFields` 的字段名**逐字对齐** | | LRU 用临时目录兜底 | 已缓存的资产"凭空消失" | 必须用 `Application Support/CloudSyncKit/Assets` | | 下载直接写目标路径 | `File exists` 报错 / 半截文件 | 先写同目录 `.tmp` 再原子移动 | | 资产传到了错误的房间 | — | `recordID` 的 zoneName 必须与编码器同源 | --- ### 链路 13:错误分类与恢复 > **人话版**:出错了,先**归类**,再**决定怎么办**。归类错了,恢复策略就永远不会触发。 #### 代码路径 ``` 任意 CKError → CloudKitGatewayImpl.mapErrors / performOperation Infrastructure/CloudKit/CloudKitGatewayImpl.swift └─ CloudKitErrorMapper.map(error) → SyncError Infrastructure/CloudKit/CloudKitErrorMapper.swift SyncCoordinator(捕获)→ handleError(error) Interface/SyncCoordinator.swift └─ ErrorRecoveryPolicy.handle(syncError) → RecoveryAction Application/Support/ErrorRecoveryPolicy.swift ├─ .rebuildZone(uploadAllData:) → 重建房间 + 重新初始化 ├─ .purgeAndRebuild → 清空同步数据 + 重置所有书签 ├─ .resetTokenAndRetry(zoneID:) → 重置书签 + 重新拉取 ├─ .pause(duration:) → 退避暂停(到期自动推+拉) ├─ .waitForNetwork → 标记离线 + 安排联网后恢复 └─ .reportError(syncError) → 广播错误事件 面向用户的文案:SyncError.wrap(_:) / messageResource / userFacingMessage Domain/Errors/SyncError+Presentation.swift ``` #### 为什么这么做 - **决策和执行分开**:`ErrorRecoveryPolicy` **只负责决定做什么**(纯逻辑、好测、幂等), `SyncCoordinator.handleError` 负责**执行**。同一个错误永远得到同一个策略(有测试护栏)。 - **必须做错误映射**:CloudKit 的 async API 抛出的是原始 `NSError`,不会自动归类。 不映射的话,`zoneNotFound(26)` 会被上层当成 `nil` 然后降级为 `.unknown` ⇒ **恢复策略永远不触发** ⇒ 同一个错误每轮无限重试。 **这正是"完全没同步、推送报 26"的根因。** - **错误分两类**: - `SyncError` = **能分类、能触发恢复动作**的领域错误; - `CloudSyncKitError` = **不需要分类、只要告诉用户**的一次性失败(文案是资源,渲染期才解析)。 #### 陷阱 | 陷阱 | 症状 | 根因 / 修法 | |---|---|---| | 直接抛原始 `NSError` 不映射 | 恢复策略不触发、无限重试 | 一律经 `CloudKitErrorMapper.map` | | 单条记录的错误没上抛 | 静默失败,但书签却推进了 | 批次结束时把第一个错误映射后上抛 | | 用 `NSError` + `NSLocalizedDescriptionKey` 承载用户文案 | 语言在**抛出那一刻**就定格,切语言不生效 | 用 `CloudSyncKitError`(文案是资源) | | 把调试串当用户文案 | 日志泄漏到界面 | 诊断串只用于 NSLog | | "等待联网"之后没人唤醒 | 永久离线、自动同步停摆且没日志 | 联网探测按退避重试(10s → 120s) | --- ### 链路 14:事件与状态(界面怎么知道同步到哪了) > **人话版**:引擎把"我正在干嘛"广播出去,界面订阅后渲染。 #### 代码路径 ``` 发布:EventPublisher.publish(_:) Domain/Events/EventPublisher.swift 订阅:SyncEngine.events → AsyncStream Interface/SyncEngine.swift 消费:SyncStatusMonitor.attach() → handle(_:) Interface/SyncStatusMonitor.swift └─ 结构化状态 SyncStatusMessage → text(in:) 按语言解析 ``` #### 为什么这么做 - **`AsyncStream` 直连发布器**:不依赖协调器是否已经创建 ⇒ **什么时候订阅都安全**, 不存在"必须先订阅再启用"的顺序约束。 - **状态存"结构化数据",不存拼好的字符串**:`SyncStatusMessage` 保留 相位 / 房间 / 计数 / 错误这些零件,到渲染时再按语言拼。 这样"上传失败:<原因>"这种复合文案**内层原因也能跟着同一个语言解析** ⇒ **整句同语言**(不会出现"外句英文、内句中文")。 - **不能靠"比较文案字符串"判断流程走到哪一步**:文案是本地化的, 同一状态在中英文下是不同字符串。要用标志位。 #### 陷阱 | 陷阱 | 症状 | 怎么修 | |---|---|---| | 把资源提前转成 `String` 存进状态 | 语言定格,切语言不变 | 存 `LocalizedStringResource`,渲染期才解析 | | 用文案字符串做相等比较 | 本地化后失效 | 用结构化枚举 / 标志位 | | 假设"必须先订阅再启用" | 丢初始化事件 | 事件流直连发布器,时序任意 | --- ## 四、按"性质"给代码分类 同一份代码,换个角度看:**它承担什么角色?** 可以分三类。判断标准: - **有状态、主动驱动一条链路** → 功能性 - **只定义规则 / 契约 / 值** → 支持性 - **无状态、可复用的通用小工具** → 工具性 ### 4.1 功能性代码:有状态、主动做事 这些文件**各管一条链路**、通常持有状态、由 `SyncEngine` 装配。**改动它们 = 改动行为。** | 文件 | 职责 | 持有状态 | 谁调用它 | |---|---|---|---| | `Interface/SyncEngine.swift` | 门面 + 装配 + 全部公开 API | `EngineState`(锁保护) | 你的 App | | `Interface/SyncCoordinator.swift` | 调度:去重合并 / 退避 / 在线离线 / 执行恢复 | `isPushing` / `isPulling` / 待办 / 各 Task | `SyncEngine` | | `Application/UseCases/SetupSyncUseCase.swift` | 建房间 + 订阅 + 可选大搬家 | 无 | Coordinator | | `Application/UseCases/PushChangesUseCase.swift` | 本地 → 云 | 无 | Coordinator | | `Application/UseCases/PullChangesUseCase.swift` | 云 → 本地 | 无 | Coordinator | | `Application/UseCases/PublishUseCase.swift` | 草稿 ↔ 公开副本(纯本地) | 无(struct) | `SyncEngine` | | `Application/UseCases/ManageCacheUseCase.swift` | 资产缓存状态机 | 无 | Coordinator | | `Application/UseCases/ShareLifecycleUseCase.swift` | CKShare 协商(直连 `CKContainer`) | 无 | `SyncEngine` | | `Infrastructure/SwiftData/SyncPersistenceActor.swift` | 引擎后台会话的全部读写 | 会话 + 悬空引用队列 | 各用例 | | `Infrastructure/SwiftData/HistoryObserver.swift` | 流水账 / 墓碑 / 书签 | 暂存书签、告警去重表(锁保护) | `SyncPersistenceActor` | | `Interface/RecordHandler.swift` + `AutomaticRecordHandler` | 模型 ↔ 记录的执行骨架 | 无 | 编码 / 写回路径 | | `Interface/LocalChangeMonitor.swift` | 感知本地写入 | 观察者数组 | `SyncEngine` | | `Application/Support/ErrorRecoveryPolicy.swift` | 错误 → 恢复动作(**纯决策**) | 无 | Coordinator | > **读这类代码时问自己**:它的输入从哪来、输出到哪去、状态在哪个隔离域、失败时谁负责重试。 ### 4.2 支持性代码:定规则 / 契约 / 值的底座 这些文件**自己不干活**,是功能代码赖以运行的**契约与事实源**。 **改它们会横向波及全库**——尤其 `SyncConstants.swift`(三条不变式就在里面)。 | 文件 | 提供的"底座" | 改动影响面 | |---|---|---| | `Domain/SyncConstants.swift` | **三条路由规矩** + zone/owner 常量 | ⚠️ 全库路由;必须同步改 TC-ROUTE / TC-ISO 用例 | | `Domain/Protocols/SyncModel.swift` | 模型契约(服务字段 + 作用域 + 父记录) | 所有模型 | | `Interface/AutomaticSyncModel.swift` | 自动模式契约 + `SyncFieldCoder` + `catalogQueryDesiredKeys` | 所有模型 | | `Domain/Protocols/ShareableModel.swift` | 共享根契约 | 共享链路 | | `Domain/Protocols/CacheableModel.swift` | 缓存契约 | 缓存链路 | | `Domain/Protocols/PublishableModel.swift` | 发布契约 + 投影默认实现 + `PublicCatalogModel` | 发布链路 | | `Domain/Protocols/PublicationGovernedModel.swift` | 生命周期契约 | 治理链路 | | `Domain/ValueObjects/DatabaseScope.swift` | 三个房间的枚举 | 路由 | | `Domain/ValueObjects/RecordIdentifier.swift` | `scope` 计算属性(路由依据) | 路由 / 去重 | | `Domain/ValueObjects/ZoneIdentifier.swift` | zone 值对象 | 路由 | | `Domain/ValueObjects/SyncFieldValue.swift` | 字段值枚举 | 编码 / 写回 | | `Domain/ValueObjects/PublicationStatus.swift` | 回执 + `isVisibleInCatalog` | 消费侧过滤 | | `Domain/ValueObjects/ChangeToken.swift` / `AssetReference.swift` / `MissingReference.swift` / `ShareInvitation.swift` | 其余小纸片 | 各链路 | | `Domain/Entities/SyncConfiguration.swift` | 配置(容器 / zone / 订阅 ID / 缓存策略) | 装配 | | `Domain/Entities/SyncRecordData.swift` | 传输载体 | 推送 / 拉取 | | `Domain/Entities/ChangeSet.swift` / `CacheState.swift` / `CacheableSnapshot.swift` | 载体 / 状态 | 各链路 | | `Domain/Errors/SyncError.swift` + `RecoveryAction.swift` | 错误分类与恢复动作 | 恢复链路 | | `Domain/Errors/CloudSyncKitError.swift` | 用户可见错误载体 | 本地化 | | `Domain/Events/SyncEvent.swift` / `EventPublisher.swift` / `StoreChangeNotification.swift` | 事件契约与广播 | UI 状态 | | `Application/Ports/*.swift` | **端口(协议)**——改一处,所有实现和调用方都要看 | 架构边界 | | `Interface/SyncStatusMonitor.swift` | 状态文案事实源(结构化 + 语言) | UI | | `Interface/CloudSyncAppDelegate.swift` | 生命周期接入点 | 宿主集成 | | `Support/Localization.swift` | 库 bundle + 文案出口(**唯一合法文案来源**) | 全部文案 | > **读这类代码时问自己**:这个类型是不是某条规则的**唯一事实源**? > 改它会不会让别处"各自写的一套"产生分叉? ### 4.3 工具性代码:无状态、可复用的通用小工具 这些文件与业务无关,解决的是**通用技术问题**(分批、限流、退避、编解码、默认值)。 | 文件 | 解决的问题 | 备注 | |---|---|---| | `Mappings/RecordEncoder.swift` | 模型 → `SyncRecordData`(**纯转换**) | 内含 `SyncFieldReflector`——**对 `@Model` 恒为空**,只服务历史遗留的非宏模型 | | `Application/Support/BackoffManager.swift` | 退避暂停(含计时 Task) | 含时间,测试容易不稳定,**不在单测基线内** | | `Infrastructure/CloudKit/RateLimiter.swift` | 令牌桶限流(避免超 CloudKit 速率) | 每次写之前先取令牌 | | `Infrastructure/CloudKit/CloudKitErrorMapper.swift` | `CKError` / `NSError` → `SyncError` | 恢复策略的入口 | | `Domain/Errors/SyncError+Presentation.swift` | 错误 → 人能读的三出口(资源 / 字符串 / 诊断串) | 文案事实源是 `messageKey` | | `Infrastructure/Storage/HistoryTokenStore.swift` | 流水账书签持久化(UserDefaults) | — | | `Infrastructure/Storage/TokenStoreImpl.swift` | 两级书签存储 | — | | `ManageCacheUseCase.swift` 内的 `AssetFileStore` | 资产文件路径约定(**唯一来源**) | 引擎公开 API 也用它 | | `AutomaticSyncModel.swift` 内的 `SyncFieldCoder` | 字段解码小工具 | 拉取写回模板用 | | `CloudKitGatewayImpl.swift` 内的 `Array.chunked(into:)` | 按 400 条分批 | — | | `Infrastructure/ConfigurationProvider.swift` | 默认配置(含分平台缓存策略) | — | | `Infrastructure/SwiftData/SyncContainerFactory.swift` | 健壮地建容器(关镜像 / 自愈 / 降级) | 引导工具 | | `Interface/BackgroundTaskGuard.swift` | iOS 后台运行时间保护 | 非 iOS 平台是空实现 | > **读这类代码时问自己**:它是纯函数吗?有没有隐含假设(如"输入已去重""目录已存在")? > ? **三类不是泾渭分明的**。`RecordEncoder` 既"做事"又"无状态"—— > 归到工具性,是因为它的语义是**纯转换**(相同输入必得相同输出): > 改它不会改变链路行为,只会改变映射结果。 --- ## 五、二次开发指南 ### 5.1 不改库源码就能扩的(扩展点清单) | 想做的事 | 扩展点 | 放在哪 | |---|---|---| | 加一种同步模型 | 遵循 `AutomaticSyncModel`(+ 按需 `ShareableModel` / `CacheableModel` / `PublishableModel`) | 你的 App target | | 自定义字段投影规则 | 覆写 `PublishableModel.project(to:)` | 你的模型扩展 | | 端到端加密 | `static var encryptedFieldNames` | 你的模型 | | 仅本地字段 / 服务端权威字段 | `uploadLocalOnlyFieldNames` / `downloadLocalOnlyFieldNames` | 你的模型 | | 公告栏资产不下载(白名单) | `static var catalogQueryDesiredKeys`(**必须是协议要求**) | 你的公开副本模型 | | 替换 CloudKit 层(做测试替身) | 实现 `CloudKitGateway` 协议 | 需库内可见性(当前非 public) | | 替换持久化层 | 实现 `PersistenceGateway` 协议 | 同上 | | 自定义"先审后上" | 覆写 `PublicCatalogModel.initialPublishState = .delisted` | 你的公开副本模型 | ### 5.2 加一个同步模型(最常用的操作) 1. 从仓库根 `ModelTemplates-CN.md`(英文 `ModelTemplates.md`)复制对应模板, 粘贴到 App target 新建的 `.swift` 里。 2. **全文替换类名**;业务字段一律带默认值;显式写 `init() {}`。 3. 维护**三处同步**:属性声明 + `encodeRemoteFields()` + `applyRemoteFields(_:)`。 > **漏一处都是静默出错**:漏编码 → 上不了云;漏解码 → 上传正常但拉不回来。 4. 在 `tombstoneKeys` 里写上 `recordName` / `ownerName` 的路标—— **必须写在具体类型的扩展里**(见链路 6 的陷阱)。 5. 启动时 `register(Model.self)`,再 `enable(container:)`。 ### 5.3 加一条新链路 / 新用例 照着现有模式来: 1. 在 `Application/Ports/` 定义需要的端口(如果现有端口不够用)。 2. 在 `Application/UseCases/` 写一个 `actor`(纯本地的话可以写 `struct`)。 只依赖端口,**不 import CloudKit**。 3. 在 `SyncEngine.enable` 的装配段构造它、注入 `SyncCoordinator` (或者像 `PublishUseCase` 那样直接由 `SyncEngine` 持有)。 4. 如果涉及新事件:在 `Domain/Events/SyncEvent.swift` 加 case, 并在 `SyncStatusMonitor.handle` 里处理(**注意 switch 完备性**)。 5. 如果涉及新文案:加进 `Support/Localizable.xcstrings`。 ### 5.4 改路由规则(⚠️ 高危操作) `DatabaseScope.resolve` / `forZone` / `forOwner`、`PushChangesUseCase.dedupKey`、 `RecordHandler.tombstoneZoneID` —— 这几处是**三条不变式的物理位置**。 改任何一处,**必须**同步更新 `CloudSyncKitTests` 里的 **TC-ROUTE / TC-ISO** 两组用例—— 它们是这条不变式的**唯一护栏**。 ### 5.5 工程约束(不做这些就是"改了没生效") | 约束 | 说明 | |---|---| | **新增/删除源文件必须改 pbxproj** | `CloudSyncKit` 是文件系统同步组,target 成员由 `membershipExceptions` **逐条列举**(不是默认全收)。漏加 → `cannot find type 'Xxx' in scope`;漏删 → `Build input files cannot be found` | | **构建必须绕过沙箱** | 用了 `@Model` / `@Observable` 宏;沙箱拦下编译器插件会报 `external macro implementation type 'ObservationMacros.ObservableMacro' could not be found`——**假错误**,别去改代码 | | **DerivedData 放 `/tmp`** | 默认的 `~/Library` 路径会被拒写 | | **新增文案必须进 `Localizable.xcstrings`** | 否则回落中文原文。key 就是中文整句,`STRING_CATALOG_GENERATE_SYMBOLS = NO` 是**有意**的(有两条只差标点,生成符号会撞名报错) | | **不要开启 SwiftData 自带的 CloudKit 镜像** | 会污染流水账,和引擎形成双通道打架 | | **测试 target 不得包含库源目录** | 否则库源码被编进测试 bundle,字体包/bundle 会解析错对象 | --- ## 六、别被注释骗了:注释与实现不一致的地方 读源码时遇到的**过时注释**,记在这里,免得你照着注释写出编译不过的代码。 | 位置 | 注释说 | 实际是 | |---|---|---| | `Interface/AutomaticSyncModel.swift` 顶部文档注释 | `await SyncEngine.shared.enable(container: container, models: SharedList.self, …)` | **没有这个重载**。只有 `enable(container:)` 和 `enable(container:modelHandlers:)`;实际写法是 `register(Model.self)` 若干次 + `enable(container:)` | | `Domain/Protocols/SyncModel.swift` 顶部「设计说明」 | "应用开发者在 Xcode 项目中为每个 `@Model` 类型创建对应的 `ModelHandler` 实现" | 那是**手写 Handler 时代**的写法。自动模式(`AutomaticSyncModel` + `AutomaticRecordHandler`)下**不需要**手写,`SyncEngine.enable` 的注释也明确写着"应用层无需手写 `ModelHandler` / `RecordHandler` 子类" | > ? 这类"注释说 A、代码是 B"的地方,**发现一处就往这张表补一行**—— > 它是这份导读最有价值的部分之一:编译器不会帮你发现它们。 > > ⚠️ **补之前先自己验一遍**。本文早期版本里有两条记录本身是错的 > (说 `SyncEngine.config` / `SyncConfiguration.zoneName` 是 internal、库外访问不到), > 实际上这两个现在都是 `public`。**道听途说的坑比不知道更危险**。 --- ## 七、速查表:现象 → 该看哪条链路 | 现象 | 优先查 | 关键位置 | |---|---|---| | 完全没同步、连日志都没有 | 注册 / 启用顺序 | `SyncEngine.enable` 消费待注册队列的地方 | | 本地写入不自动上云 | 链路 5 | `LocalChangeMonitor` 双通道 + `hasPendingChanges` | | 本地删除不同步、重装"复活" | 链路 6 | `tombstoneKeys` 的写法位置 + `@Attribute(.preserveValueOnDeletion)` | | 云端只有系统字段、业务字段全缺 | 链路 3 | `encodeRemoteFields` 是否实现(反射对 `@Model` 无效) | | 某字段拉不回 | 链路 4 | `applyRemoteFields` 是否漏了映射行 | | `CKError 12` 整批失败 | 链路 3 | `dedupKey` 是否含作用域 | | `CKError 15` / Internal server error | 链路 3 | `ensureAncestorsInBatch` + `waveGroups` | | `zoneNotFound(26)` 每轮无限重试 | 链路 13 | `CloudKitErrorMapper.map` 是否被调用 | | 发布"成功"但别人看不到 | 链路 10 | `syncScopes` 是否含 `.public_` | | 加入共享后内容被清空 | 链路 4 | `reconcileSharedZone`(**不能**用 `shareRecordData` 判断撤销) | | 新设备显示"未分享" | 链路 8 | 分享元数据重建 | | 状态栏长时间"正在拉取…" | 链路 4 | 每页是否发布进度事件 | | 公告栏图片每轮都被重新下载 | 链路 12 | 公开副本是否声明 `catalogQueryDesiredKeys` | | 资产状态写到了另一条记录 | 链路 12 | 资产 API 是否传了 `recordType` | | 切语言后文案不变 | 链路 14 | 是否把资源提前转成了 `String` | --- ## 关于引用方式(为什么本文不写行号) 本文早期版本用 `File.swift:123` 这种带行号的方式引用代码。问题有两个: 1. **行号会腐烂**。库里任何一处增删,后面的行号全得改。实测一次改动之后, 文中引用普遍漂移了 **20~50 行**——照着找会跳到完全不相干的地方。 2. **对初学者反而不友好**。想找 `DatabaseScope.resolve`,用 `Cmd+Shift+O`(Open Quickly) 输符号名一步就到;盯着行号数行数反而慢。 所以本文统一改成 **`文件` → `符号名`**。这种引用**不会腐烂**, 你也能直接搜。少数地方保留的 `CKError 26` 之类是 **CloudKit 的错误码**,那不是行号。 --- > **相关文档** > - `README-CN.md` / `README.md` —— 接入方视角:怎么用(API、快速开始) > - `ModelTemplates-CN.md` / `ModelTemplates.md` —— 模型模板与字段映射(**接入必读**) > - `CloudSyncKitTests/README.md` —— 测试基线、用例编号、不变式护栏与维护注意事项