ZaatarLABS
联系我们 →
← 全部文章
The Smart Budget · 工程

不信任服务器,家庭共享如何实现

两个iCloud账户,一个共享预算,没有任何由我控制的后端。NSPersistentCloudKitContainer做不到,所以我亲手写了同步引擎。下面是这件事真正需要付出的东西。

阅读约14分钟

The Smart Budget让一个家庭共享同一个预算。两个人,两个独立的iCloud账户,同时实时编辑相同的交易和预算。我给自己定的限制是:中间没有任何由我控制的服务器。您的财务数据直接通过Apple的CloudKit在账户之间同步。我永远看不到它,也没有可供我泄露、被传唤调取或被黑客攻破的后端。

Apple有一个看上去恰好能做到这一点的框架——NSPersistentCloudKitContainer配合CKShare。我花了三天时间试图让它跑通。然后我把它删掉,自己写了同步引擎。这篇文章讲的是原因,以及替代方案是什么样子,附上真实代码。

显而易见的做法,以及崩溃

官方推荐的路径是:用NSPersistentCloudKitContainer(NSPCKC)作为Core Data的后端,它会自动把您的存储镜像到一个私有CloudKit数据库,然后调用share(...),为您想共享的对象创建一个CKShare。

它会崩溃。不是马上崩——这正是残酷之处。它在演示中运行正常,却在混乱的真实场景中挂掉:当您自定义的CKShare落进框架正忙着镜像的同一个私有数据库时。下面是如今占据NSPCKC原位置的注释:

/// Apple's `NSPersistentCloudKitContainer` is gone. It crashed when our custom `CKShare`
/// landed in the same private DB it was mirroring (it tries to ingest the share as a
/// `cloudkit.share` Core Data entity that doesn't exist in the model). Replacing it with
/// our own bare-CloudKit pipeline removes the framework collision and gives us one path
/// for all sync, single-user and household.

框架在它镜像的数据库中看到一条类型为cloudkit.share的记录,就试图把它当作您的某个Core Data实体来导入。您的模型里没有cloudkit.share实体,因为它是CloudKit的系统类型。砰。

而更深层的问题不在于这一次崩溃,而在于我没有任何办法修复它。摘自重写计划文档:

针对家庭共享测试了三天NSPersistentCloudKitContainer + CKShare,出现了一个反复出现的模式:每修好一处,就冒出一种新的故障模式(找不到zone导致卡死、“Can't find metadata”循环、级联时序产生重复数据、无法恢复的CKKS部分状态重置)。框架的自动镜像是不透明的——出问题时,我们没有任何API可以检查或纠正它;唯一有效的办法是“清空一切,从头再来”。

这才是真正的控诉。自动化的东西正常工作时,像魔法一样。不正常时,却没有任何缝隙可以让您伸手进去。对于一个“从头再来”就意味着“删除一个家庭的共享预算”的功能来说,这是一票否决。设计文档对我所做取舍的总结是:“用显式控制的复杂度,换掉与框架搏斗的复杂度。”只要涉及用户的钱,我每次都会选择显式控制。

替代方案:两种模式,一个引擎

我用一条纯CloudKit流水线替换了NSPCKC:推送、拉取、冲突解决和记录转换都由我掌控。个人单账户同步和家庭共享运行在同一个引擎上——区别只在于它们指向哪个CloudKit zone和哪个数据库。核心是一个actor:

/// Custom CloudKit sync layer for ALL of the user's shareable data.
/// **Two modes, one engine:**
/// - **Personal mode** (default): syncs `Shared.sqlite` to a `personal-zone` in the
///   user's private CloudKit DB. No `CKShare`.
/// - **Household mode**: syncs to a `household-shared` zone in the owner's private DB,
///   with a `CKShare` attached.
/// **Conflict resolution:** last-writer-wins by `lastModified`.
actor HouseholdSync {
    static let shared = HouseholdSync()

整个“没有中央服务器”的诀窍,就在于参与者如何定位数据。共有四种模式,每种都会选择自己的zone和数据库:

enum Mode { case off; case personal; case householdOwner; case householdParticipant }

private var currentZoneID: CKRecordZone.ID? {
    switch currentMode {
    case .personal:
        return CKRecordZone.ID(zoneName: Self.personalZoneName, ownerName: CKCurrentUserDefaultName)
    case .householdOwner:
        return CKRecordZone.ID(zoneName: Self.householdZoneName, ownerName: CKCurrentUserDefaultName)
    case .householdParticipant:
        let ownerName = UserDefaults.standard.string(forKey: Self.householdOwnerCKUserIDKey) ?? CKCurrentUserDefaultName
        return CKRecordZone.ID(zoneName: Self.householdZoneName, ownerName: ownerName)
    default: return nil
    }
}
private var currentDatabase: CKDatabase? {
    switch currentMode {
    case .personal, .householdOwner: return container.privateCloudDatabase
    case .householdParticipant:      return container.sharedCloudDatabase
    default: return nil
    }
}

所有者写入的是自己私有数据库中的household-shared zone。参与者则通过自己的共享数据库读写同一个zone,并依据接受邀请时保存下来的所有者CloudKit用户ID,重建所有者的(zoneName, ownerName)。两个私有数据库,由一个share缝合在一起。同步由Apple的服务器完成;逻辑不经过任何人的服务器。

有四个部分为这个引擎提供输入。

LocalChangeWatcher:把保存变成推送操作

一个NSManagedObjectContextDidSave观察者。每当共享存储保存时,它就为插入、更新、删除的对象构建推送操作,并触发一次推送。其中微妙而又至关重要的,是它跳过了什么:

private func handle(_ notification: Notification) {
    guard SyncStatus.shared.isActive else { return }
    guard let sharedStore = controller.sharedStore else { return }
    // Skip saves emitted by HouseholdSync itself (pull's apply + push's metadata writeback)
    if let savingContext = notification.object as? NSManagedObjectContext,
       Self.skipContexts.contains(ObjectIdentifier(savingContext)) {
        return
    }
    for object in inserted.union(updated) {
        guard object.objectID.persistentStore == sharedStore else { continue }
        guard RecordTranslator.shareableEntityNames.contains(object.entity.name ?? "") else { continue }
        ops.append(.init(cloudKitRecordName: recordName, entityName: entityName,
                         kind: .upsert, objectIDURI: object.objectID.uriRepresentation().absoluteString))
    }
    Task {
        await PushQueue.shared.enqueue(ops)
        await HouseholdSync.shared.pushPendingChanges()
    }
}

那个skipContexts集合刻意以ObjectIdentifier为键——跨队列读取context.transactionAuthor可能会让Core Data死锁。如果没有这个跳过,应用一次拉取到的变更本身就会触发一次保存,而这次保存又会把它推送回去,伴侣那边拉取后再次保存……这就引出了整个项目中最精彩的bug(见下文)。

PushQueue:持久化、可自我合并的FIFO

网络会断。应用会被杀掉。所以待推送队列是一个以文件为后端的actor,每次修改都会原子地写入磁盘,并且在入队时去重:

func enqueue(_ ops: [Operation]) async {
    for op in ops {
        // URI-based dedupe FIRST so a stale optimistic-UUID op for the same
        // managed object gets dropped.
        if let uri = op.objectIDURI {
            queue.removeAll { $0.objectIDURI == uri || $0.cloudKitRecordName == op.cloudKitRecordName }
        } else {
            queue.removeAll { $0.cloudKitRecordName == op.cloudKitRecordName }
        }
        queue.append(op)
    }
    await persist()
}

载荷本身并不序列化——推送时会重新读取托管对象当前的状态。所以如果您在两秒内把一笔交易编辑了五次,这些修改会自动合并成一次推送,推送的是最终状态,不费任何额外功夫。

RecordTranslator:Core Data ⇄ CKRecord,双向转换

纯函数,无I/O,在调用方的context.perform内运行。每个可共享对象都带有一个稳定的cloudKitRecordName,让两台设备对每一行逻辑数据认同同一个身份。出去时,它只把对一关系编码为引用,并跳过目标尚未推送的关系:

for (name, rel) in object.entity.relationshipsByName where !rel.isToMany {
    guard let target = object.value(forKey: name) as? NSManagedObject else { record[name] = nil; continue }
    guard let targetName = target.value(forKey: "cloudKitRecordName") as? String, !targetName.isEmpty else {
        continue   // target not pushed yet — next cycle fixes it
    }
    let targetID = CKRecord.ID(recordName: targetName, zoneID: zoneID)
    record[name] = CKRecord.Reference(recordID: targetID, action: .none)
}

回来时,有一道看起来像吹毛求疵、实际上决定成败的守卫:

let target = resolveReference(ref, destinationEntity: rel.destinationEntity!.name!, in: context)
if target != nil {
    object.setValue(target, forKey: name)
}
// else: target not pulled to this context yet — leave as-is. Critically we do NOT
// overwrite to nil and create orphan rows that dedupBudgets would group under a
// single nil-category bucket, summing 9 different-category Budgets into one row.

如果一笔交易比它指向的分类先到,您绝不能把这个关系置空。一旦置空,就会产生孤儿行——接下来的去重流程(下一节)会兴高采烈地把它们合并在一起。我就曾经这样把九个毫不相干的分类合并出了一个6,800 AED的预算行。

难点

HouseholdDedup:没有协调者也能收敛

当两部手机都处于离线状态,并且都创建了一个“Groceries”(日用杂货)分类时,同步之后就会出现重复。没有服务器来仲裁。解决办法是收敛:两台设备对相同的数据运行完全相同的确定性流程,得出完全相同的删除集合,然后通过正常的推送流水线传播出去。无需任何协调。

/// **Determinism:** both devices run dedup independently. As long as both pick the same
/// canonical for each group (we use lexicographically-smallest `cloudKitRecordName`,
/// preferring `isSystem` rows for Categories), they produce the same delete set →
/// converges to the same final state under LWW.

在删除一个重复项之前,它会把所有关系重新指向保留下来的那一项,并更新lastModified,让这次合并在伴侣设备上以“最后写入者胜出”的规则胜出:

let canonical = pickCanonicalCategory(from: dupes)
for dupe in dupes where dupe.objectID != canonical.objectID {
    if let txns = dupe.transactions as? Set<Transaction> {
        for txn in txns { txn.category = canonical; txn.lastModified = now }
    }
    // budgets, splits, aliases, recurringSeries likewise…
    context.delete(dupe)
}

6,800 AED事件让我对分组键变得疑神疑鬼。早期的一个版本把分类为nil的预算归到字面字符串"nil"下面,把九个不同的分类合并成了一个巨大的行。现在它完全跳过分类为nil的预算,而转换器(见上文)从一开始就拒绝产生这些nil。一个bug,两道独立的守卫,因为这个bug的表现是某人预算里的一个错误数字,而在理财应用里,没有比一个言之凿凿的错误数字更糟糕的bug了。

HouseholdShareAcceptance:SwiftUI对您藏起来的邀请钩子

当您的伴侣点按共享链接时,iOS需要把CKShare.Metadata交给您的应用。这是通过一个UIWindowSceneDelegate回调完成的——而纯SwiftUI应用没有这个回调。所以您要通过UIApplicationDelegateAdaptor提供一个:

func windowScene(
    _ windowScene: UIWindowScene,
    userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata
) {
    let metadata = cloudKitShareMetadata
    Task {
        do { try await HouseholdSync.shared.acceptShare(metadata: metadata) }
        catch { /* … */ }
    }
}

同一个delegate还处理静默推送唤醒:一个经过验证的CKNotification会触发一次拉取+推送,而且——这个细节能帮您省下好几个小时——它最后总是报告.newData。一旦报告.failed,iOS就会开始限制您的后台推送,于是“实时”同步突然就晚了十分钟。

HouseholdIdentity:同步地知道自己是谁

有一半的逻辑需要在不经过网络往返的情况下回答“这些记录里哪些是我的?”。所以当前用户的CloudKit用户ID只解析一次,然后缓存在UserDefaults里:

func refresh() async {
    do {
        let recordID = try await CKContainer(identifier: PersistenceController.cloudKitContainerID).userRecordID()
        currentUserID = recordID.recordName
        await runClaimSweepIfNeeded()
    } catch {
        Self.logger.info("Could not resolve CloudKit user ID (likely no iCloud account)…")
    }
}

这个缓存的ID会写入记录的ownerCloudKitUserID和loggedByMemberID,“退出家庭”正是靠它知道每个人保留哪些行,按成员划分的消费报表也是靠它把一杯咖啡算到正确的那位伴侣头上。

创建和接受共享

所有者一侧:创建zone,然后在整个zone上创建一个CKShare(而不是每条记录一个),切换自己的角色,重置变更令牌,把所有内容重新入队以便它们全部流入新的zone,再拆掉已经过时的个人zone:

_ = try await privateDB.save(CKRecordZone(zoneID: householdZoneID))
let share = CKShare(recordZoneID: householdZoneID)
share[CKShare.SystemFieldKey.title] = "The Smart Budget — Household" as CKRecordValue
share.publicPermission = .none
_ = try await privateDB.save(share)

setRole(.owner)
UserDefaults.standard.removeObject(forKey: Self.serverChangeTokenKey)
await PushQueue.shared.clear()
await reenqueueAllLocalRecords(controller: controller)
await deletePersonalZoneServerSide()

您用Apple原生的UICloudSharingController来呈现这个纯粹的CKShare——驱动系统邀请界面并不需要NSPCKC,而大家都以为正是这一点把您绑在了这个框架上。参与者一侧,接受邀请并保存所有者的ID:

_ = try await container.accept(metadata)
// Owner's CKUserID is encoded in the share's zoneID — persist it so currentZoneID
// can construct the right (zoneName, ownerName) tuple on subsequent pushes/pulls.
let ownerName = metadata.share.recordID.zoneID.ownerName
UserDefaults.standard.set(ownerName, forKey: Self.householdOwnerCKUserIDKey)
setRole(.participant)

整个系统的冲突解决都是基于lastModified时间戳的“最后写入者胜出”——只有本地严格更新时本地才胜出,否则应用远端的数据。简单,而且会收敛。

代码至今仍记得的两个战斗故事

失控的iCloud推送风暴(2026年6月29日修复)。skipContexts集合(来自LocalChangeWatcher)是在相对于观察者代码块错误的队列上清空的。清空操作总是输掉竞争,于是每一次元数据回写都会泄露出去,变成一次新的推送——伴侣那边拉取后又推回来——形成一个无限同步循环,不停地冲击iCloud。修复只是一行代码,关于跳过集合何时被清空。同步代码中的反馈循环不会自报家门;它们只会悄无声息地把您的速率预算烧光。

过期拉取守卫。一次拉取在开始前会对当前的模式+zone做快照。如果acceptShare或disconnect在拉取进行中切换了模式,这次拉取的结果就会被丢弃,而不是被误读——因为在您用旧模式发起的拉取过程中出现“zone不存在”错误,并不等于“您的伴侣结束了共享”,而把前者当作后者处理,曾经让一位配偶的数据落进了错误的zone。

我会把它提取成SDK吗?

这里面藏着一个干净的CloudKitHouseholdSync包——HouseholdSync、PushQueue、RecordTranslator、LocalChangeWatcher、去重,以及scene钩子。一直有人问。我的坦白回答是:还不会。里面写死了一些Smart Budget特有的假设(去重时以小写名称为键、实体依赖的排序),我得小心地把它们通用化,而且在它经过真实家庭的生产环境验证之前,我不想发布一个同步库。提取计划已经写好了。等它证明了自己,才会发布。

结论不是“永远别用NSPersistentCloudKitContainer”——对于一个只想要iCloud备份的单用户应用,它很棒,您应该用它。结论是:一旦您把自定义的CKShare混进一个由Core Data镜像的私有数据库,您就离开了铺好的路,而框架不会给您任何在路外生存的工具。自己掌控流水线意味着更多代码。但它也是唯一一个这样的版本:当一个家庭的共享财务出了问题时,我有一个缝隙可以伸手进去修好它——而不是“清空一切,从头再来”。

The Smart Budget现已在iPhone上推出。家庭共享是Pro功能;它底层的个人同步引擎是同一套代码,为所有人运行。没有任何数据经过由我控制的服务器。

——Omar

作者:Omar Al Homaidi · The Smart Budget · @zaatarlabs