零登录架构:用 SwiftData + CloudKit 打造私密日记应用#
个人日记大概是最不适合放注册界面的应用类别。用户打开应用,是想写下这个月最脆弱的一天,而他们看到的第一件事却是"创建账户""设置密码""验证邮箱"。这不是假设——这正是我会失去 Lanternly 部分用户的地方。Lanternly 是一款正在开发中、对标 Day One 的日记应用。
我最终采用的方案是 零登录(zero-login):应用里根本没有"账户"这个概念。个人资料保存在本地,iPhone 和 iPad 之间的同步通过设备上已登录的 iCloud 账户的 CloudKit 私有数据库完成。没有登录界面,没有密码,也没有服务器去验证这个密码。本文将介绍这套架构、SwiftData 与 CloudKit 搭配使用时的限制,以及零登录如何进一步影响导出、导入这类产品决策。
为什么是零登录,而不是"用 Sign in with Apple 就够了"#
同事们最先问我的问题是:"Sign in with Apple 一个按钮就能搞定,为什么要把事情复杂化?"这里的区别是根本性的。哪怕是最轻量的身份验证,也意味着存在一个 账户——一个必须在某处注册、与数据绑定、在 iCloud ID 变更时妥善处理、并且需要在客服工单里持续维护的实体("登录不了""换了手机数据就没了")。
零登录彻底消除了这整类问题:没有账户,就没有登录、没有忘记密码、也没有换设备时的数据迁移——因为"设备"和"用户身份"从一开始就没有被分离开。CloudKit 早就知道数据的所有者是谁:就是在这台设备上登录了 iCloud 的那个人。我不需要在应用层重新解决一个 Apple 已经在系统层面解决好的问题。
这个选择并非没有代价。它意味着 Lanternly 没有"用另一个 iCloud 账户登录、看到你的数据"这条路径——只能通过显式的数据迁移。对于日记这种本身就高度隐私敏感的数据,我认为这是一个合理的取舍,而不是功能缺失。
架构:SwiftData 负责本地存储,CloudKit 只是同步的传输层#
SwiftData(WWDC23 发布的 Core Data 继任者)开箱即用地提供本地存储能力,而带有 cloudKitDatabase 参数的 ModelConfiguration,可以在不写一行服务器代码的情况下,把它变成离线优先(offline-first)的同步方案:
import SwiftData
enum JournalStore {
static let cloudContainerID = "iCloud.com.yourteam.yourapp"
static let schema = Schema([
JournalEntry.self,
JournalAttachment.self,
Profile.self,
])
/// Test/CI builds pass `-localStoreOnly` to skip CloudKit entirely —
/// there is no entitlement to sign against in CI, and simulator
/// screenshots would otherwise crash trying to reach a private database.
private static var localStoreOnly: Bool {
#if DEBUG
CommandLine.arguments.contains("-localStoreOnly")
#else
false
#endif
}
@MainActor
static func makeContainer() -> ModelContainer {
guard !localStoreOnly else {
return makeLocalContainer()
}
let cloudConfig = ModelConfiguration(
schema: schema,
cloudKitDatabase: .private(cloudContainerID)
)
// CloudKit can be unavailable for reasons that have nothing to do
// with the schema: no iCloud sign-in, restricted account, missing
// entitlement in a dev build. Falling back to a local-only store
// keeps the app usable instead of crashing on first launch.
if let container = try? ModelContainer(for: schema, configurations: cloudConfig) {
return container
}
return makeLocalContainer()
}
@MainActor
private static func makeLocalContainer() -> ModelContainer {
let localConfig = ModelConfiguration(schema: schema, cloudKitDatabase: .none)
guard let container = try? ModelContainer(for: schema, configurations: localConfig) else {
fatalError("Could not create local ModelContainer: schema is invalid")
}
return container
}
}注意 -localStoreOnly 这个标志——它绝非纸上谈兵。在 CI 环境和模拟器截图流程中,CloudKit 根本不可用:没有签名的 entitlement,有时甚至没有一个真实登录的 iCloud 账户。如果没有明确的本地回退方案,应用会在测试运行环境的第一次启动时就崩溃,而不是在生产环境中——这其实更糟糕,因为它把一个真实问题伪装成了"测试不稳定"。
CloudKit 兼容性会强制你在 @Model 里改动什么#
这是零登录架构里不太讨喜的部分。CloudKit 对数据模型施加了硬性要求,SwiftData 无法绕开——它要么默默禁用不兼容的特性,要么在容器启动时直接失败。我在写下第一行同步代码之前就遇到了三个具体限制:
- 不能使用唯一性约束。
@Attribute(.unique)在纯本地的 SwiftData 存储中可以正常使用,但只要配置接入了 CloudKit 就会失效——因为 CloudKit 无法在随时可能离线的多台设备之间原子性地保证唯一性。 - 每个非可选属性都必须有默认值。 一个含有必填字段却没有默认值的模型,会在容器启动时直接失败,而不是优雅降级。
- 每个关系都必须是可选的——包括那些在纯本地应用里你通常会设为非可选、并默认给空数组的一对多关系。
import SwiftData
@Model
final class JournalEntry {
var id: UUID = UUID()
var createdAt: Date = Date.now
var text: String = ""
var mood: Int?
var tags: [String] = []
// CloudKit requires every relationship to be optional — even
// to-many ones you would normally default to an empty array.
@Relationship(deleteRule: .cascade, inverse: \JournalAttachment.entry)
var attachments: [JournalAttachment]? = []
// No @Attribute(.unique) here: CloudKit cannot enforce atomic
// uniqueness across devices, so SwiftData disables it the moment
// a CloudKit-backed configuration is used. Uniqueness, if you need
// it, becomes an application-level check instead of a schema one.
init(id: UUID = UUID(), text: String = "", mood: Int? = nil) {
self.id = id
self.text = text
self.mood = mood
}
}实践中的结论是:那些原本在数据模型层面保证的业务不变量(唯一的 id、必填字段)转移到了应用层校验。这不是 CloudKit 的缺陷,而是分布式同步本身的必然结果——在随时可能离线的设备之间,通过网络进行原子性的唯一性校验,在没有中心仲裁者的情况下是物理上不可能做到的,而中心仲裁者恰恰就是零登录架构试图摆脱的那个服务器。
CloudKit 私有数据库:"你自己的 iCloud"到底意味着什么#
我不想把"数据存在用户自己的 iCloud 里,所以天生就是端到端加密"这句话过度简化——这并不完全准确,与其把它当成营销口号,我更愿意诚实地说清楚。
CloudKit 的私有数据库(.private(cloudContainerID))会把数据物理存放在该用户个人的 iCloud 存储中——完全不经过我控制的任何服务器。每条记录在上传之前,都会用在用户可信设备上生成的密钥加密。但真正意义上的端到端加密——连 Apple 自己都无法访问密钥——只有当用户在 iCloud 设置里主动开启 Advanced Data Protection(高级数据保护) 时才会生效。这是一个需要用户主动开启的选项,而不是默认行为。不开启的情况下,Apple 在技术上可以在收到合法请求时提供数据;开启之后,Apple 物理上就不再持有可以提供的密钥。
对 Lanternly 来说,这意味着在隐私说明里要诚实地表述这一点,而不是不加限定地宣称"军事级加密"。CloudKit 私有数据库确实比一个完全由我掌控的服务器要好得多,但它默认并不是零知识(zero-knowledge)——如果开发者声称相反,那就是在误导用户。
无限制导出与从 Day One 导入:零登录如何塑造产品决策#
零登录不只是一个同步架构上的选择,它是一个会级联影响整个产品的约束。如果服务端没有账户,那么通过后端 API 实现的"云端"导出也就无从谈起——导出必须是一个发生在设备本地的操作,因为数据本来就物理存在于那里。
对 Lanternly 而言,这转化成了一个具体的产品决策:把日记打印成书(PDF 和 EPUB)完全在设备端生成,文本不会上传到任何第三方渲染服务。同样重要的是,从产品角度看——无限制导出:没有"免费只能导出 10 页"这种付费墙,也不需要订阅才能以开放格式拿回自己的数据。数据属于用户,而不属于某项订阅服务。
从 Day One 导入遵循同样的逻辑:应用读取这个竞品的本地导出文件,把条目迁移到自己设备端的 SwiftData 模型中——同样没有任何中间服务器,在迁移过程中临时保存别人的私人记录。
自建后端 vs Firebase vs CloudKit + SwiftData#
| 评判标准 | 自建后端 | Firebase | CloudKit + SwiftData |
|---|---|---|---|
| 用户注册 | 自行实现(邮箱/OAuth)——必需 | Firebase Auth——必需 | 不需要:身份等同于设备上已登录的 iCloud 账户 |
| 数据物理存放位置 | 你自己的服务器/数据库 | Google 的服务器 | 用户自己的 iCloud 私有数据库 |
| 默认端到端加密 | 取决于你的实现 | 无(数据可在 Google 服务器上被读取) | 默认没有;由用户开启 Advanced Data Protection 后生效 |
| 基础设施与运维 | 开发者承担全部责任 | 托管式,但随规模增长而增加成本 | 无需——只是应用容器,不是服务器 |
| 跨平台覆盖 | 任意平台 | iOS/Android/Web | 仅限 Apple 生态 |
| 开箱即用的离线优先 | 需要手动实现 | Firestore 的部分离线缓存 | 内置在 SwiftData + ModelConfiguration 中 |
| 数据模型限制 | 无(自建数据库) | 灵活的 NoSQL 模型 | 不支持唯一性约束;非可选字段必须有默认值 |
结论与上线前检查清单#
基于 SwiftData 和 CloudKit 的零登录架构并不是万能配方,而是一个刻意的取舍:放弃 Apple 生态之外的跨平台能力,以及部分数据模型的灵活性,换来没有服务器、没有登录界面、数据物理上真正属于用户。对于像个人日记这样完全生活在 Apple 生态内的应用来说,这个取舍在用户眼中不是限制,而是一种承诺:"只有你能看到这些记录"。
在把这套架构推上生产环境之前,值得过一遍这份简短的检查清单:
- SwiftData 模型与 CloudKit 兼容:不使用
@Attribute(.unique),所有非可选字段都有默认值,所有关系都是可选的 - 当 CloudKit 不可用时(未登录 iCloud、受限账户、没有 entitlement 的开发版本),有明确的本地回退方案
- 测试/CI 构建使用类似
-localStoreOnly的标志,而不是尝试连接真实的 CloudKit 容器 - 隐私说明中如实说明完整端到端加密何时才会生效(Advanced Data Protection),而不是默认暗示已经生效
- 数据导出是设备本地操作,不需要任何中间服务器
- 产品层面的限制(付费墙、额度)永远不会阻止用户访问自己的数据
关于 CloudKit 中的冲突解决和变更订阅的更多细节,可参见《CloudKit 无服务器方案:生产环境中的离线优先同步》,文中以 MeteoHealth 的健康数据为例,详细讲解了同一套架构的落地方式。



