Highlight
SwiftData 在 iOS 18 新增 DataStore 协议,让你可以用一行配置切换底层存储后端——JSON 文件、REST API、Realm——而 Model 定义和 SwiftUI 视图代码完全不需要改。
核心内容
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 通信。ModelContext 发送 DataStoreFetchRequest 或 DataStoreSaveChangesRequest,Store 返回对应的结果类型。你只需要实现 fetch 和 save 两个方法,就能把整个存储后端替换掉。
Session 用一个叫 SampleTrips 的示例 app 贯穿全程。这个 app 展示旅行列表,支持增删改查。切换到 JSONStore 后,所有功能照常运行,数据被持久化到了一个 JSON 文件中。关键在于:只需把 ModelConfiguration 替换为 JSONStoreConfiguration,Model 定义、SwiftUI View、@Query 都不需要任何修改(00:40)。
详细内容
通信模型:Snapshot 是桥梁
ModelContext 和 Store 之间的通信基于 Snapshot。当 ModelContext 需要保存数据时,它为每个修改过的 Model 创建一个 Snapshot,放入 DataStoreSaveChangesRequest 发给 Store。反过来,Store 读取数据后,为每条记录创建 Snapshot,放进 DataStoreFetchResult 返回给 ModelContext,再由 ModelContext 还原成 PersistentModel(03:42)。
Snapshot 是 Sendable 且 Codable 的,这意味着你可以直接用 JSONEncoder/JSONDecoder 序列化它,这也是官方示例中 JSONStore 的做法。
临时标识符与永久标识符的映射
新插入的 Model 在 ModelContext 中持有临时标识符(如 Trip-t1)。Store 在处理插入时,需要为它分配永久标识符(如 Trip-5),然后把临时到永久的映射关系放在 DataStoreSaveChangesResult 的 remappedPersistentIdentifiers 里返回给 ModelContext(02:52)。ModelContext 收到后更新自己的状态,UI 才能正确渲染。
完整代码:实现一个 JSONStore
以下代码来自官方示例(08:15),展示了一个完整的 JSON 文件存储实现:
@available(swift 5.9) @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *)
final class JSONStoreConfiguration: DataStoreConfiguration {
typealias StoreType = JSONStore
var name: String
var schema: Schema?
var fileURL: URL
init(name: String, schema: Schema? = nil, fileURL: URL) {
self.name = name
self.schema = schema
self.fileURL = fileURL
}
static func == (lhs: JSONStoreConfiguration, rhs: JSONStoreConfiguration) -> Bool {
return lhs.name == rhs.name
}
func hash(into hasher: inout Hasher) {
hasher.combine(name)
}
}
@available(swift 5.9) @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *)
final class JSONStore: DataStore {
typealias Configuration = JSONStoreConfiguration
typealias Snapshot = DefaultSnapshot
var configuration: JSONStoreConfiguration
var name: String
var schema: Schema
var identifier: String
init(_ configuration: JSONStoreConfiguration, migrationPlan: (any SchemaMigrationPlan.Type)?) throws {
self.configuration = configuration
self.name = configuration.name
self.schema = configuration.schema!
self.identifier = configuration.fileURL.lastPathComponent
}
func save(_ request: DataStoreSaveChangesRequest<DefaultSnapshot>) throws -> DataStoreSaveChangesResult<DefaultSnapshot> {
var remappedIdentifiers = [PersistentIdentifier: PersistentIdentifier]()
var serializedTrips = try self.read()
for snapshot in request.inserted {
let permanentIdentifier = try PersistentIdentifier.identifier(for: identifier,
entityName: snapshot.persistentIdentifier.entityName,
primaryKey: UUID())
let permanentSnapshot = snapshot.copy(persistentIdentifier: permanentIdentifier)
serializedTrips[permanentIdentifier] = permanentSnapshot
remappedIdentifiers[snapshot.persistentIdentifier] = permanentIdentifier
}
for snapshot in request.updated {
serializedTrips[snapshot.persistentIdentifier] = snapshot
}
for snapshot in request.deleted {
serializedTrips[snapshot.persistentIdentifier] = nil
}
try self.write(serializedTrips)
return DataStoreSaveChangesResult<DefaultSnapshot>(for: self.identifier,
remappedPersistentIdentifiers: remappedIdentifiers,
deletedIdentifiers: request.deleted.map({ $0.persistentIdentifier }))
}
func fetch<T>(_ request: DataStoreFetchRequest<T>) throws -> DataStoreFetchResult<T, DefaultSnapshot> where T : PersistentModel {
if request.descriptor.predicate != nil {
throw DataStoreError.preferInMemoryFilter
} else if request.descriptor.sortBy.count > 0 {
throw DataStoreError.preferInMemorySort
}
let objs = try self.read()
let snapshots = objs.values.map({ $0 })
return DataStoreFetchResult(descriptor: request.descriptor, fetchedSnapshots: snapshots, relatedSnapshots: objs)
}
func read() throws -> [PersistentIdentifier: DefaultSnapshot] {
if FileManager.default.fileExists(atPath: configuration.fileURL.path(percentEncoded: false)) {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let trips = try decoder.decode([DefaultSnapshot].self, from: try Data(contentsOf: configuration.fileURL))
var result = [PersistentIdentifier: DefaultSnapshot]()
trips.forEach { s in
result[s.persistentIdentifier] = s
}
return result
} else {
return [:]
}
}
func write(_ trips: [PersistentIdentifier: DefaultSnapshot]) throws {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let jsonData = try encoder.encode(trips.values.map({ $0 }))
try jsonData.write(to: configuration.fileURL)
}
}
关键点:
JSONStoreConfiguration声明StoreType = JSONStore,SwiftData 通过这个关联类型找到对应的 Store 实现JSONStore声明Snapshot = DefaultSnapshot,因为不需要自定义编码逻辑,直接复用 SwiftData 提供的值类型容器fetch方法中,当请求包含 predicate 或 sort descriptor 时,抛出DataStoreError.preferInMemoryFilter/preferInMemorySort,让 ModelContext 在内存中完成过滤和排序——这对于小数据集是最简单的做法(09:47)save方法处理三种变更:request.inserted需要分配永久标识符并建立映射;request.updated直接覆盖;request.deleted从字典中移除(10:13)read和write是辅助方法,用 Foundation 的 JSONEncoder/JSONDecoder 读写文件,DefaultSnapshot本身是 Codable 的,可以直接序列化
DefaultStore 仍然是最优选择
Session 明确指出,DefaultStore 支持 Migration、History Tracking、CloudKit 同步等所有 SwiftData 高级功能,且封装了平台级的性能和可扩展性最佳实践(05:37)。只有在明确需要对接非 Apple 存储后端时,才需要自定义 Store。
核心启发
-
用自定义 Store 对接已有的 REST API 后端:如果你的 app 已经有一套后端 API,可以写一个 CustomStore 把
save映射到 POST/PUT 请求、把fetch映射到 GET 请求,SwiftUI 侧的 @Query 和 Model 定义完全不动。为什么值得做:让前端团队在不改 UI 代码的前提下,把本地存储无缝切换到远程 API。怎么开始:继承DataStore协议,在fetch中调用 API 并把返回的 JSON 转成DefaultSnapshot,在save中按 inserted/updated/deleted 分别发请求。 -
用 JSONStore 做轻量级配置文件持久化:很多工具类 app 只需要存几十条配置数据,引入 Core Data 太重,UserDefaults 太弱。用 JSONStore 可以直接享受 SwiftData 的 Model 宏、@Query 响应式更新,数据文件还是人类可读的 JSON。为什么值得做:降低持久化方案的复杂度,同时保留 SwiftData 的声明式 API 优势。怎么开始:复制官方示例的 JSONStore 代码,把
fileURL指向 App Support 目录下的 JSON 文件。 -
渐进式迁移:用自定义 Store 包装旧持久化方案:如果你正在从 Realm 或 Firebase 迁移到 SwiftData,可以先写一个包装了旧方案的自定义 Store,让 app 先跑起来,再在后续版本中逐步切换到 DefaultStore。为什么值得做:避免一次性重写持久化层导致的风险,让迁移可以分阶段进行。怎么开始:在
fetch和save中调用旧 SDK 的读写接口,数据模型保持不变,后续只需把ModelConfiguration换回 DefaultStore。
关联 Session
- What’s new in SwiftData — SwiftData 新增索引、唯一约束等特性
- Track model changes with SwiftData History — 如何查看 Store 的变更历史
- Model your schema with SwiftData — 用 SwiftData 宏定义和演进数据模型
- Meet SwiftData — SwiftData 基础:PersistentModel 入门
评论
GitHub Issues · utterances