WWDC Quick Look 💓 By SwiftGGTeam
Sync to iCloud with CKSyncEngine

Sync to iCloud with CKSyncEngine

观看原视频

Highlight

CKSyncEngine 是 Apple 为 CloudKit 推出的高层同步引擎,开发者只需处理应用特定的数据转换逻辑,系统会自动处理推送监听、任务调度、网络重试、批量传输和冲突解决,将原本需要数千行代码的自定义同步实现缩减到几百行。

核心内容

用户期待数据能在所有设备上同步。在 iPhone 上记了一条笔记,打开 Mac 时它应该已经在那里。这种体验看起来理所当然,但实现起来很复杂。

CloudKit 本身不算复杂,但同步涉及很多细节:网络状态监控、电池管理、推送通知处理、订阅管理、状态追踪、错误恢复、账号切换等等。一个完整的同步引擎可能需要几千行代码,测试代码还要翻倍。Apple 内部透露 NSPersistentCloudKitContainer 的测试代码超过 7 万行。

WWDC23 推出的 CKSyncEngine 把这些通用逻辑封装起来,让你只关心应用特有的部分。

Apple 平台上有三个层级的 CloudKit 同步方案:

  • NSPersistentCloudKitContainer:全栈方案,包含本地持久化,适合直接用 Core Data 的应用
  • CKSyncEngine:自带本地持久化的同步引擎,适合需要自定义存储方案的应用
  • CKDatabase + CKOperations:完全手动控制,只有 CKSyncEngine 无法满足需求时才用

如果你已经在用 NSPersistentCloudKitContainer,继续用就好。如果你有自己的本地存储方案(SQLite、文件系统等),CKSyncEngine 是最佳选择。

CKSyncEngine 已经被多个系统应用采用,包括 Freeform 和 NSUbiquitousKeyValueStore。它使用标准的 CKRecord 和 CKRecordZone,可以和已有的 CloudKit 实现互通。随着平台演进,CKSyncEngine 会自动获得性能优化和新功能。

同步引擎的工作流程

05:11)CKSyncEngine 的核心是数据管道。你的应用用 record 和 zone 与引擎通信,引擎负责与服务器交互。

发送变更的流程:

  1. 用户修改数据(输入文字、切换开关、删除对象)
  2. 应用告诉 sync engine 有待发送的变更
  3. Sync engine 向系统调度器提交任务
  4. 设备就绪后,调度器运行任务
  5. Sync engine 向应用批量请求变更数据
  6. 数据发送到服务器
  7. 服务器返回结果,sync engine 回调给应用

06:45)批量处理是关键设计。如果用户导入了大量数据,可能有成百上千条变更。Sync engine 分批请求,避免一次性加载所有记录到内存。应用只在引擎请求时才提供下一批数据。

接收变更的流程:

  1. 服务器收到新变更后发送推送通知
  2. CKSyncEngine 自动监听推送
  3. 收到通知后提交任务给调度器
  4. 调度器运行后,sync engine 从服务器获取变更
  5. 变更交给应用持久化到本地

08:00)系统调度器是这一切的基础。它监控网络连接、电池电量、资源占用等条件,确保在合适的时机同步。正常情况下同步在几秒内完成,但如果网络断开或电量低,同步会被推迟。你不需要自己处理这些逻辑。

当然,也有需要立即同步的场景:下拉刷新、立即备份按钮。CKSyncEngine 提供了手动同步的 API,但 Apple 推荐优先依赖自动调度。

初始化与配置

12:14)使用 CKSyncEngine 需要几个前提:了解 CKRecord 和 CKRecordZone、在 Xcode 中启用 CloudKit 和远程推送能力。

初始化代码:

actor MySyncManager: CKSyncEngineDelegate {

    init(container: CKContainer, localPersistence: MyLocalPersistence) {
        let configuration = CKSyncEngine.Configuration(
            database: container.privateCloudDatabase,
            stateSerialization: localPersistence.lastKnownSyncEngineState,
            delegate: self
        )
        self.syncEngine = CKSyncEngine(configuration)
    }

    func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async {
        switch event {
        case .stateUpdate(let stateUpdate):
            self.localPersistence.lastKnownSyncEngineState = stateUpdate.stateSerialization
        }
    }
}

关键点:

  • 初始化时要提供数据库、上次保存的状态序列化、delegate
  • State serialization 必须持久化到本地,下次启动时恢复
  • Delegate 的 handleEvent 是主要的通信接口
  • 建议在应用启动后尽快初始化 sync engine,这样它才能监听推送和调度任务

发送变更到服务器

14:13)发送变更分三步:标记待发送变更、实现批量提供方法、处理发送结果。

func userDidEditData(recordID: CKRecord.ID) {
    // 告诉 sync engine 需要发送这条数据
    self.syncEngine.state.add(pendingRecordZoneChanges: [ .save(recordID) ])
}

func nextRecordZoneChangeBatch(
    _ context: CKSyncEngine.SendChangesContext,
    syncEngine: CKSyncEngine
) async -> CKSyncEngine.RecordZoneChangeBatch? {

    let changes = syncEngine.state.pendingRecordZoneChanges.filter {
        context.options.zoneIDs.contains($0.recordID.zoneID)
    }

    return await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: changes) { recordID in
        self.recordToSave(for: recordID)
    }
}

关键点:

  • pendingRecordZoneChanges 告诉引擎哪些 record 需要同步
  • Sync engine 会自动去重和保持一致性
  • nextRecordZoneChangeBatch 在引擎准备好发送时被调用
  • RecordZoneChangeBatch 用 pending changes 和 record provider 初始化,provider 在真正需要时才加载 record

获取服务器变更

15:40)获取变更是自动的。Sync engine 会发送 fetchedRecordZoneChangesfetchedDatabaseChanges 事件:

func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async {
    switch event {

    case .fetchedRecordZoneChanges(let recordZoneChanges):
        for modifications in recordZoneChanges.modifications {
            // 将修改持久化到本地
        }
        for deletions in recordZoneChanges.deletions {
            // 从本地删除数据
        }

    case .fetchedDatabaseChanges(let databaseChanges):
        for modifications in databaseChanges.modifications {
            // 处理 database 级别的修改
        }
        for deletions in databaseChanges.deletions {
            // 处理 database 级别的删除
        }

    case .willFetchChanges, .didFetchChanges:
        // 在获取前后执行设置/清理工作
        break

    case .sentRecordZoneChanges(let sentChanges):
        for failedSave in sentChanges.failedRecordSaves {
            let recordID = failedSave.record.recordID

            switch failedSave.error.code {

            case .serverRecordChanged:
                if let serverRecord = failedSave.error.serverRecord {
                    // 合并服务器记录到本地数据
                    syncEngine.state.add(pendingRecordZoneChanges: [ .save(recordID) ])
                }

            case .zoneNotFound:
                // Zone 不存在,先创建 zone 再重试
                syncEngine.state.add(pendingDatabaseChanges: [ .save(recordID.zoneID) ])
                syncEngine.state.add(pendingRecordZoneChanges: [ .save(recordID) ])

            // 以下错误 sync engine 会自动重试
            case .networkFailure, .networkUnavailable, .serviceUnavailable, .requestRateLimited:
                break

            default:
                break
            }
        }

    case .accountChange(let event):
        switch event.changeType {
        case .signIn:
            // 准备新用户数据
            break
        case .signOut:
            // 清除本地数据
            break
        case .switchAccounts:
            // 清除数据并准备新用户
            break
        }
    }
}

关键点:

  • fetchedRecordZoneChanges 包含 modifications 和 deletions 两个数组
  • serverRecordChanged 是最常见的冲突,需要应用自己决定合并策略
  • zoneNotFound 需要先创建 zone 再重试保存 record
  • 网络类错误(networkFailure、serviceUnavailable 等)sync engine 会自动处理
  • accountChange 事件处理账号登录、登出、切换三种情况

支持 Private 和 Shared 数据库

18:49)可以为每个数据库创建独立的 sync engine:

let databases = [ container.privateCloudDatabase, container.sharedCloudDatabase ]

let syncEngines = databases.map {
    var configuration = CKSyncEngine.Configuration(
        database: $0,
        stateSerialization: lastKnownSyncEngineState($0.databaseScope),
        delegate: self
    )
    return CKSyncEngine(configuration)
}

关键点:

  • 每个数据库需要独立的 sync engine 实例
  • 每个实例需要独立的状态序列化存储
  • Shared database 用于 CloudKit Sharing 场景

测试

20:00)可以用多个 sync engine 实例模拟多设备场景:

func testSyncConflict() async throws {
    // 创建两个本地数据库模拟两台设备
    let deviceA = MySyncManager()
    let deviceB = MySyncManager()

    // 设备 A 先保存到服务器
    deviceA.value = "A"
    try await deviceA.syncEngine.sendChanges()

    // 设备 B 在获取变更前也尝试保存
    deviceB.value = "B"
    XCTAssertThrows(try await deviceB.syncEngine.sendChanges())
    XCTAssertEqual(deviceB.value, "A")
}

关键点:

  • 创建多个 sync manager 模拟多设备
  • 设置 automaticallySync = false 可以精确控制同步时机
  • 测试冲突解决逻辑时,验证本地数据是否按预期更新
  • 记录 record ID 和 zone ID 有助于调试多设备同步问题

详细内容

错误处理策略

CKSyncEngine 自动处理瞬态错误:

  • networkFailure / networkUnavailable:网络问题,自动重试
  • serviceUnavailable:服务不可用,自动重试
  • requestRateLimited:请求频率限制,自动重试
  • account issues:账号问题,自动处理

需要应用处理的错误:

  • serverRecordChanged:服务器记录已被修改,需要应用决定合并策略
  • zoneNotFound:zone 不存在,需要先创建
  • unknown errors:未知错误,需要应用决定如何处理

状态管理

Sync engine 的内部状态通过 stateSerialization 保存。这个状态包含:

  • 已知的 server change token
  • 待发送的变更队列
  • 订阅信息
  • 其他内部追踪数据

每次状态更新时,sync engine 会发送 stateUpdate 事件。你必须持久化这个状态,否则下次启动时 sync engine 会重新全量同步。

调试技巧

  1. 记录事件序列:在 handleEvent 中记录每个事件的类型和时间戳
  2. 记录 record ID 和 zone ID:帮助追踪数据流向
  3. 对比多设备日志:用时间戳对齐不同设备的操作
  4. 使用 Push Notifications Console:验证推送是否正常到达

核心启发

1. 给现有本地存储应用添加 iCloud 同步

  • 做什么:把原来只存本地的笔记、待办、习惯追踪等应用加上跨设备同步
  • 为什么值得做:CKSyncEngine 把同步复杂度降到最低,你只需处理数据转换,就能获得和系统应用同级别的同步体验
  • 怎么开始:定义 CKRecord 转换逻辑,初始化 CKSyncEngine,在数据变更时标记 pending changes

2. 做一个支持协作的文档编辑器

  • 做什么:类似 Freeform 的多人协作白板或文档应用
  • 为什么值得做:CKSyncEngine 支持 shared database,结合 CloudKit Sharing 可以实现多用户实时协作
  • 怎么开始:用 private database 存储个人数据,用 shared database 存储协作数据,为每个 database 创建独立的 sync engine

3. 做一个离线优先的相册/收藏应用

  • 做什么:图片收藏、链接收藏、音乐收藏等应用,支持离线浏览和自动同步
  • 为什么值得做:Sync engine 的自动调度确保在网络恢复时无缝同步,批量处理适合大量媒体元数据
  • 怎么开始:本地用 SwiftData 或 Core Data 存储,CKSyncEngine 同步 record 到 CloudKit,媒体文件用 CKAsset

4. 重写自定义同步方案

  • 做什么:把原来自己维护的 CloudKit 同步代码迁移到 CKSyncEngine
  • 为什么值得做:减少维护成本,自动获得系统级优化,测试覆盖更完善
  • 怎么开始:逐步替换:先让 CKSyncEngine 和现有实现共存,验证数据一致性后再完全切换

关联 Session

评论

GitHub Issues · utterances