无服务器 CloudKit 同步:离线优先架构的生产实践#
在为 MeteoHealth(一款在 iPhone、iPad 和 Apple Watch 上记录用户健康指标的应用)设计同步方案时,我首先要回答的问题不是"用哪个框架",而是"到底需不需要服务器"。个人健康数据、三个平台、飞机上或医院地下室也要能离线工作的硬性要求——再加上完全不想为了一款应用去维护后端、数据库和 DevOps。
经过几个月的生产环境验证,我得到的答案是:只要在 CloudKit 之上正确设计离线优先同步,就完全不需要服务器。这篇文章不是 Apple 文档的复述,而是实打实的经验——哪些方案奏效、哪些出了问题,以及如果重来一次我会怎么做。
为什么在 CloudKit、Firebase 和 Supabase 之间纠结#
一开始这看起来只是一次标准的 BaaS 选型。Firebase 成熟、跨平台、社区庞大。Supabase 开源,底层是 Postgres,开发体验也不错。CloudKit 只覆盖 Apple 生态,但直接内建在系统里。
三个健康数据特有的因素最终让我做出了决定:
- 数据物理上存放在用户自己的 iCloud 里,而不是我必须管理、打补丁、保护的服务器上。这不仅仅是省事——它意味着更小的攻击面,以及更轻的敏感数据存储责任。
- 身份认证已经解决。用户本来就有 Apple ID,我不需要为健康数据再搭建一套注册、找回密码之类的流程。
- 零基础设施。没有可能在凌晨三点宕机的服务器,也没有随用户增长而水涨船高的托管账单。
代价是对 Apple 的供应商锁定,以及更低的定制上限:跨记录的复杂事务和任意的服务端查询,在 CloudKit 里都比在 Postgres 里难做。对于一款完全生活在 Apple 生态里的应用来说,这个取舍是值得的。
| 对比维度 | CloudKit | Firebase | Supabase |
|---|---|---|---|
| 自建服务器 | 不需要 | 不需要(托管) | 需要自托管或 Supabase Cloud |
| 数据存储 | 用户的 iCloud 私有数据库 | Google 服务器 | Postgres(托管/自托管) |
| 跨平台 | 仅 Apple 生态 | iOS/Android/Web | iOS/Android/Web |
| 身份认证 | 直接使用 Apple ID | Firebase Auth(需配置) | Supabase Auth(需配置) |
| 离线优先 | 通过 NSPersistentCloudKitContainer 内建于 Core Data | Firestore 离线缓存 | 需手动实现 |
| 规模化成本 | Apple 提供的按用户免费额度 | 随 Firestore 查询支出增长 | 随 Postgres 实例增长 |
架构:Core Data 之上的 NSPersistentCloudKitContainer#
MeteoHealth 同步的第一个版本建立在 NSPersistentCloudKitContainer 之上——这是实现离线优先行为最快的路径,因为 Core Data 本来就能在没有网络的情况下本地工作,而 NSPersistentCloudKitContainer 在此基础上加上了向 CloudKit 的镜像同步。
import CoreData
import CloudKit
final class PersistenceController {
static let shared = PersistenceController()
let container: NSPersistentCloudKitContainer
init(inMemory: Bool = false) {
container = NSPersistentCloudKitContainer(name: "MeteoHealthModel")
guard let description = container.persistentStoreDescriptions.first else {
fatalError("No persistent store description found")
}
// Required for CloudKit mirroring: history tracking + remote change notifications
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
containerIdentifier: "iCloud.pro.dodecaidr.meteohealth"
)
container.loadPersistentStores { _, error in
if let error = error as NSError? {
fatalError("Unresolved error loading store: \(error), \(error.userInfo)")
}
}
container.viewContext.automaticallyMergesChangesFromParent = true
// Field-level last-writer-wins. Good enough for most entities,
// but not for health metrics where "who wrote last" isn't "who is right".
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
}
}有一个仅看文档不容易察觉的细节:兼容 CloudKit 的 Core Data 模型有严格限制——所有属性必须是可选的或有默认值,不支持唯一约束,关系也必须是可选的。这迫使我在写第一行同步代码之前就重新设计了数据模型:一部分原本在 schema 层面校验的业务不变量,被搬到了应用层校验中。
私有数据库与 Zone:iPhone、iPad、Apple Watch 之间如何同步#
CloudKit 把数据分为三种数据库:公共、私有和共享。对于个人健康数据,只有私有数据库(private database)合适——它物理上存放在用户自己的 iCloud 存储里,其他用户无法访问,也完全不经过我控制的基础设施。
私有数据库内部,数据按 Zone(CKRecordZone)分组。默认情况下所有数据都落入 defaultZone,但对离线优先同步而言,自定义 Zone 带来了关键优势:同一 Zone 内的原子批量保存/删除操作,以及独立的变更令牌(change token)——可以把一组相关记录(比如一次就诊产生的所有指标)作为一个整体同步,不必担心在另一台设备上出现"只应用了一部分"的状态。
在 MeteoHealth 的实际实现中,这意味着三个平台共用同一个 HealthMetrics Zone——iPhone、iPad 和 Apple Watch 读写的是同一个 iCloud 账户下同一个私有数据库里的同一个 Zone。不需要额外的"设备间同步"服务器:CloudKit 本身就是传输层。
生产环境中的冲突:serverRecordChanged 到底意味着什么#
这才是真正的工程问题,而不是教程内容。NSPersistentCloudKitContainer 通过 NSMergePolicy 自动解决冲突——但这是字段级而非记录级的解决方式,而且无法访问"祖先"版本。对大多数字段来说这没问题:后修改的字段获胜。但对健康测量数据来说,"谁最后写入"和"谁是对的"是两个不同的问题:如果用户在 9:00 在 iPhone 上录入了一个数值,而一次延迟的同步在 9:05 才把 Apple Watch 8:45 的自动测量值送达,朴素的 last-writer-wins 会覆盖掉更早、但并非无效的数值。
这正是 NSPersistentCloudKitContainer 过于高层的地方:它不会暴露 serverRecordChanged 背后的三个记录版本——本地、服务端和祖先版本。对于关键实体,我下沉到了通过 CKSyncEngine 直接处理原始 CKRecord 的层级。
CKSyncEngine:什么时候该下沉到比 Core Data 更底层的地方#
CKSyncEngine 在 WWDC23 上为 iOS 17+ 推出,是一个层级更低但声明式的 API:它接管了大部分繁琐工作(变更令牌、重试、推送通知批处理),但把"到底发送什么"以及"如何解决冲突"的完全控制权交还给你。
import CloudKit
final class HealthSyncEngine: NSObject, CKSyncEngineDelegate {
private var syncEngine: CKSyncEngine!
private let zoneID = CKRecordZone.ID(zoneName: "HealthMetrics")
func configure(savedState: CKSyncEngine.State.Serialization?) {
let configuration = CKSyncEngine.Configuration(
database: CKContainer(identifier: "iCloud.pro.dodecaidr.meteohealth").privateCloudDatabase,
stateSerialization: savedState,
delegate: self
)
syncEngine = CKSyncEngine(configuration)
}
func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) {
switch event {
case .stateUpdate(let update):
persistState(update.stateSerialization)
case .fetchedRecordZoneChanges(let event):
event.modifications.forEach { applyToLocalStore($0.record) }
event.deletions.forEach { deleteFromLocalStore(recordID: $0.recordID) }
case .sentRecordZoneChanges(let event):
// Records CloudKit rejected because the server version moved on
// while we were offline.
for failure in event.failedRecordSaves {
guard failure.error.code == .serverRecordChanged,
let serverRecord = failure.error.serverRecord else { continue }
let resolved = mergeHealthRecord(local: failure.record, server: serverRecord)
syncEngine.state.add(pendingRecordZoneChanges: [.saveRecord(resolved.recordID)])
stageForNextSave(resolved)
}
default:
break
}
}
func nextRecordZoneChangeBatch(
_ context: CKSyncEngine.SendChangesContext,
syncEngine: CKSyncEngine
) async -> CKSyncEngine.RecordZoneChangeBatch? {
await CKSyncEngine.RecordZoneChangeBatch(
pendingChanges: context.options.scope.pendingRecordZoneChanges
) { recordID in
recordToSave(for: recordID)
}
}
}关键在于错误码为 .serverRecordChanged 的 failedRecordSaves。与 NSPersistentCloudKitContainer 不同,CKSyncEngine 会把真正的服务端记录交给我——带着最新的 recordChangeTag,没有它,重新发送只会再次被拒绝。我在服务端版本之上构建一条新记录,并应用自己的业务逻辑:
/// serverRecordChanged gives us the current server record with a valid
/// change tag. Re-sending the local record's stale tag would just be
/// rejected again — we must copy the server record and reapply our fields.
private func mergeHealthRecord(local: CKRecord, server: CKRecord) -> CKRecord {
let merged = server.copy() as! CKRecord
let localRecordedAt = local["recordedAt"] as? Date ?? .distantPast
let serverRecordedAt = server["recordedAt"] as? Date ?? .distantPast
// The freshest measurement wins — not whoever reached the server first.
if localRecordedAt > serverRecordedAt {
merged["value"] = local["value"]
merged["recordedAt"] = local["recordedAt"]
merged["source"] = local["source"]
}
return merged
}这样,冲突就是根据数据本身的含义(实际测量时间)来解决,而不是取决于网络延迟的偶然性。对于 MeteoHealth 中其他不那么关键的实体,我保留了 NSPersistentCloudKitContainer——在属性级 last-writer-wins 完全不会出问题的地方,没必要把整套技术栈都改写成 CKSyncEngine。
推送与订阅:设备如何立即感知变更#
没有推送通知,iPhone、iPad 和 Apple Watch 之间的同步就只能靠定时器或打开应用时触发——这不符合用户对"同一个 iCloud 账户"的预期。CKDatabaseSubscription 监控整个私有数据库(包括新建的 Zone),每次有变更就发送一次静默推送:
func setupDatabaseSubscription(database: CKDatabase) async throws {
let subscription = CKDatabaseSubscription(subscriptionID: "meteohealth-private-db-changes")
let notificationInfo = CKSubscription.NotificationInfo()
notificationInfo.shouldSendContentAvailable = true // silent push, no banner
subscription.notificationInfo = notificationInfo
try await database.save(subscription)
}收到这类推送后,应用不会向用户展示任何内容——只是触发通过 CKSyncEngine 拉取待处理的变更:
func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
guard let notification = CKNotification(fromRemoteNotificationDictionary: userInfo),
notification.subscriptionID == "meteohealth-private-db-changes" else {
completionHandler(.noData)
return
}
Task {
try? await syncEngine.fetchChanges()
completionHandler(.newData)
}
}使用 NSPersistentCloudKitContainer 时不需要手动配置这个订阅——框架内部已经接好了。手动配置只在你直接使用 CKSyncEngine 时才需要。
坑、限制与生产环境检查清单#
以下是任何教程都不会提及、却实实在在耗费了我不少调试时间的问题:
- CloudKit Dashboard 不是用来做实时迁移的地方。在没有先部署到 Development 环境并完成 promote 的情况下,直接在生产环境修改记录 schema,一定会在尚未更新的设备上造成兼容性问题。
- 速率限制会静默失败。超出请求配额时,CloudKit 不会抛出明显的错误——操作有时只是被推迟,稍后重试。如果应用逻辑期待即时响应,这看起来就像同步"卡住了"。
- 模拟器对离线模式撒谎。真实的断网场景以及随之而来的合并冲突,必须在真正关闭 Wi-Fi 的物理设备上测试,而不是靠模拟器里的调试开关。
- iCloud 账户可能未登录,或处于受限模式(家庭共享、受管理的 Apple ID)。应用必须优雅降级——在本地正常工作并展示清晰的状态,而不是直接崩溃。
- 重装后的首次完整同步是流量和耗时都最重的场景;我会专门单独测试这一场景,而不是依赖那些一切都很顺畅的增量同步场景。
发布带有 CloudKit 同步功能的检查清单:
- Core Data schema 兼容 CloudKit 的约束(可选属性、无唯一约束)
- 关键实体有明确的冲突解决策略,而非默认的 property trump
-
CKDatabaseSubscription已配置并测试(适用于手动使用CKSyncEngine的场景) - 已测试:两台设备同时离线并产生冲突修改
- 已测试:账户未登录 iCloud 的场景
- 已测试:从零开始的首次完整同步
- 日志不会把静默重试当成致命错误
结论。 CloudKit 并不是"如何同步数据"这个问题的万能答案,但对于一款完全生活在 Apple 生态里、处理用户个人数据的应用来说,它是自建后端的合理替代方案:更少的基础设施、内建的身份认证,以及物理上留在用户 iCloud 里而非我的服务器上的数据。代价是必须理解方便但粗糙的 NSPersistentCloudKitContainer 与精确但更繁琐的 CKSyncEngine 之间的区别,并针对每个实体而非整个项目去选择合适的工具。



