WWDC Quick Look 💓 By SwiftGGTeam
Create a custom data store with SwiftData

Create a custom data store with SwiftData

观看原视频

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 引入了三个关键协议:DataStoreConfigurationDataStoreSnapshotDataStore,构成完整的存储抽象层。SwiftData 的 ModelContext 不再直接和 DefaultStore 耦合,而是通过 DataStoreSnapshot(一个 Sendable + Codable 的值类型容器)和底下的具体 Store 通信。ModelContext 发送 DataStoreFetchRequestDataStoreSaveChangesRequest,Store 返回对应的结果类型。你只需要实现 fetchsave 两个方法,就能把整个存储后端替换掉。

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),然后把临时到永久的映射关系放在 DataStoreSaveChangesResultremappedPersistentIdentifiers 里返回给 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
  • readwrite 是辅助方法,用 Foundation 的 JSONEncoder/JSONDecoder 读写文件,DefaultSnapshot 本身是 Codable 的,可以直接序列化

DefaultStore 仍然是最优选择

Session 明确指出,DefaultStore 支持 Migration、History Tracking、CloudKit 同步等所有 SwiftData 高级功能,且封装了平台级的性能和可扩展性最佳实践(05:37)。只有在明确需要对接非 Apple 存储后端时,才需要自定义 Store。


核心启发

  1. 用自定义 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 分别发请求。

  2. 用 JSONStore 做轻量级配置文件持久化:很多工具类 app 只需要存几十条配置数据,引入 Core Data 太重,UserDefaults 太弱。用 JSONStore 可以直接享受 SwiftData 的 Model 宏、@Query 响应式更新,数据文件还是人类可读的 JSON。为什么值得做:降低持久化方案的复杂度,同时保留 SwiftData 的声明式 API 优势。怎么开始:复制官方示例的 JSONStore 代码,把 fileURL 指向 App Support 目录下的 JSON 文件。

  3. 渐进式迁移:用自定义 Store 包装旧持久化方案:如果你正在从 Realm 或 Firebase 迁移到 SwiftData,可以先写一个包装了旧方案的自定义 Store,让 app 先跑起来,再在后续版本中逐步切换到 DefaultStore。为什么值得做:避免一次性重写持久化层导致的风险,让迁移可以分阶段进行。怎么开始:在 fetchsave 中调用旧 SDK 的读写接口,数据模型保持不变,后续只需把 ModelConfiguration 换回 DefaultStore。


关联 Session

评论

GitHub Issues · utterances