用 SwiftData 打造自定义数据存储
Create a custom data store with SwiftData
2024年6月10日
一句话判断
SwiftData 终于开放了 DataStore 协议,你可以把底层的持久化方案换成任何你想要的格式(JSON、远程 API、内存数据库),而上层的 Model 和 View 代码一行都不用改。
这场 Session 讲了什么
SwiftData 在 iOS 17 发布时把 Core Data 的复杂性藏到了一个干净的 Swift 原生 API 背后,但底层存储是锁死的——只能用 Apple 提供的 DefaultStore(基于 Core Data)。这在大多数场景下没问题,但当你需要自定义存储格式、对接已有后端、或者只想用 JSON 文件做轻量持久化时,就会碰壁。很多开发者曾在社区中抱怨过这一点——SwiftData 的 API 再优雅,底层存储不可换就是个硬伤。
iOS 18 引入了三个关键协议:DataStoreConfiguration、DataStoreSnapshot 和 DataStore。它们构成了一个完整的存储抽象层。SwiftData 的 ModelContext 不再直接和 DefaultStore 耦合,而是通过 DataStoreSnapshot(一个 Sendable + Codable 的值类型容器)和底下的具体 Store 通信。这意味着你只需要实现 fetch 和 save 两个方法,就能把整个存储后端替换掉。整个通信链路都是基于 request/response 模式的——ModelContext 发送 DataStoreFetchRequest 或 DataStoreSaveChangesRequest,Store 返回对应的结果类型。
演示中以一个 JSON 文件存储为例,展示了如何通过 JSONStoreConfiguration 替换 ModelConfiguration,整个 app 的 Model 定义、SwiftUI View、Query 都不需要任何修改。这种架构设计让 SwiftData 从”一个好用的 ORM”变成了”一个真正灵活的数据框架”。Session 用一个叫 SampleTrips 的示例 app 贯穿全程——这个 app 展示旅行列表,支持增删改查,在切换到 JSONStore 后,所有功能照常运行,而数据被持久化到了一个 JSON 文件中。
值得深挖的点
DataStoreSnapshot:ModelContext 和 Store 之间的通用语言
整个自定义 Store 架构的核心设计决策是引入了 DataStoreSnapshot。它不是一个简单的字典,而是一个携带了 PersistentIdentifier 的 Codable 值类型容器。ModelContext 把 PersistentModel 转成 Snapshot 发给 Store,Store 把 Snapshot 转回来——两边通过这个中间格式解耦。
这个设计的精妙之处在于:Store 完全不知道 PersistentModel 的存在,它只操作 Snapshot。反过来看,ModelContext 也不关心 Snapshot 最终被写成了 JSON、SQLite 还是发到了远程服务器。当你调用 modelContext.save() 时,ModelContext 会把所有变更的 model 打包成 DataStoreSaveChangesRequest,里面包含对应的 Snapshot。Store 处理完后返回 DataStoreSaveChangesResult,里面有 remapped identifiers——对于新插入的 model,临时 ID(比如 Trip-t1)会被映射为永久 ID(比如 Trip-5),ModelContext 据此更新 UI。
这个设计在功能上类似于 Core Data 的 NSPersistentStore 抽象,但更加轻量、类型安全、且原生支持 Swift 并发。代价是如果你需要 CloudKit 同步、自动 Migration 这些 DefaultStore 的高级功能,自定义 Store 需要自己实现。
另一个值得注意的细节是 identifier remapping 机制。当你在 ModelContext 中插入一个新 model 时,它会被分配一个临时 identifier(比如 Trip-t1)。当 Store 持久化这个 model 后,它会为 model 分配一个永久 identifier(比如 Trip-5),并在返回的 DataStoreSaveChangesResult 中提供临时到永久的映射关系。ModelContext 收到这个映射后会更新所有引用了临时 identifier 的地方——包括 UI 绑定。这个过程是自动的,你不需要手动处理 ID 映射。
替换 Store 只需要改一行配置
Session 中最打动人的演示是:把 ModelConfiguration() 替换成 JSONStoreConfiguration(),然后 SampleTrips app 立刻从 SQLite 存储切换到 JSON 文件存储。所有 @Query、@Model 宏、SwiftUI 绑定全部正常工作。这说明 SwiftData 的架构分层做到了真正的关注点分离——存储实现是存储实现,业务逻辑是业务逻辑。
不过也要清醒地认识到,示例中的 JSONStore 是一个”archival store”,每次读写都加载整个文件。对于小数据集没问题,数据量大了性能会是灾难。苹果也明确说了 DefaultStore(封装了 Core Data 的最佳实践)仍然是大多数 app 的首选。
Session 还介绍了一个重要的协议:DataStoreHistory。它定义了如何描述 Store 中所有的变更历史。这个协议是可选的,但如果你需要做调试、审计日志或者自定义的数据同步方案,它提供了一个标准化的接口来追踪”什么时候改了什么”。DefaultStore 已经实现了 History 协议,自定义 Store 可以选择是否实现。
代码片段
声明自定义 Store 的最小实现
// 配置类型和 Store 类型互相引用
struct JSONStoreConfiguration: DataStoreConfiguration {
typealias Store = JSONStore
let fileURL: URL
}
struct JSONStore: DataStore {
typealias Configuration = JSONStoreConfiguration
typealias Snapshot = DefaultSnapshot // 不需要自定义编码就用默认的
let configuration: JSONStoreConfiguration
// 只需要实现 fetch 和 save 两个方法
func fetch<T: PersistentModel>(
_ request: DataStoreFetchRequest<T>
) throws -> DataStoreFetchResult<T> {
let data = try Data(contentsOf: configuration.fileURL)
let snapshots = try JSONDecoder().decode(
[DefaultSnapshot].self, from: data
)
return DataStoreFetchResult(knownItems: snapshots)
}
func save<T: PersistentModel>(
_ request: DataStoreSaveChangesRequest<T>
) throws -> DataStoreSaveChangesResult {
// 增删改的 snapshot 都在 request 里
// 处理完后返回 remapped identifiers
}
}
场景:创建一个基于 JSON 文件的轻量存储。坑:DefaultSnapshot 虽然是 Codable 的,但如果你的 Model 有复杂的关系(Relationship),序列化/反序列化的成本和复杂度会急剧上升。
替换 ModelConfiguration
// 之前:使用默认的 Core Data 存储
// let config = ModelConfiguration(isStoredInMemoryOnly: false)
// 之后:一行切换到 JSON 存储
let config = JSONStoreConfiguration(
fileURL: FileManager.default.urls(
for: .documentDirectory, in: .userDomainMask
)[0].appendingPathComponent("trips.json")
)
let container = try ModelContainer(for: Trip.self, configurations: config)
场景:在不改动任何 Model 和 View 代码的前提下切换存储后端。坑:确保 JSON 文件路径在 app sandbox 内且可写。
Client State 防止过度索引
let index = CSSearchableIndex(name: "myIndex")
// 先验证上次的 client state
let lastState = index.lastClientState
// 开始批量索引
try index.startBatch()
// 只索引有变化的 items(配合 isUpdate flag)
try index.index(items, isUpdate: true)
// 结束批量,保存新的 client state
try index.endBatch(withClientState: newState)
场景:大量数据目录的增量索引。坑:client state 是不透明的 Data 类型,你不需要解析它,只需存储并在下次启动时传回。
最佳实践
新项目:如果你对存储格式没有特殊要求,直接用 DefaultStore。它是 SwiftData 团队精心调优的方案,支持 Migration、History Tracking、CloudKit 同步。只有在明确需要自定义存储(比如对接已有的 REST API、使用 Realm 或 Firebase)时才考虑自定义 Store。实现时先用 DefaultSnapshot,只有在需要自定义编码逻辑时才考虑实现自己的 Snapshot 类型。
已有项目:如果你已经在用 SwiftData,自定义 Store 给了你一个渐进式迁移的路径——可以先写一个 Store 适配层对接现有的持久化方案,再逐步迁移到 DefaultStore。如果你用的是 Core Data,SwiftData 的 DefaultStore 底层就是 Core Data,迁移成本很低。如果你正在从其他持久化方案(Realm、Firebase、自定义 SQLite)迁移到 SwiftData,可以先写一个包装了旧方案的自定义 Store,让 app 先跑起来,再在后续版本中逐步切换到 DefaultStore。
还有什么值得关注
DataStoreHistory协议可以描述 Store 中所有的变更历史,为调试和数据同步提供了基础设施。- 自定义 Store 的
Snapshot可以选择用DefaultSnapshot(省事)或自己实现(更灵活),灵活性很高。 - Session 中的 JSONStore 示例是一个完整的开源实现,可以直接作为模板使用。