大多数浏览器和
Developer App 均支持流媒体播放。
-
使用 SwiftData 历史记录 API 跟踪模型更改
利用 SwiftData,直观呈现你的模型变更历史记录!使用历史记录 API 来了解数据存储何时发生了变更,并学习如何根据这些信息为你的 App 构建远程服务器同步和进程外变更处理等功能。我们还将介绍如何将对历史记录 API 的支持构建到自定数据存储中。
章节
- 0:00 - Introduction
- 0:45 - Fundamentals
- 5:18 - Transactions and changes
- 12:37 - Custom stores
资源
相关视频
WWDC24
-
下载
大家好! 我叫 David 是 SwiftData 团队的工程师 SwiftData 历史记录是一项新技术 可以让你的 App 跟踪数据的修改情况 你可以使用历史记录构建一些 需要处理数据变更的功能 例如 与服务器同步 或响应来自 App 扩展的变更 在本视频中 我将介绍 SwiftData 历史记录的基本原理 还会在示例 App 中 使用历史记录事务和变更 构建一项新功能
最后 我将介绍为自定数据存储 提供历史记录支持的 一些注意事项
让我们来谈谈 什么是 SwiftData 历史记录 以及为什么要使用这个 API
用户使用 App 时 SwiftData 存储的 内容会随时间而变更 例如 当 App 启动时 它可能会创建一些模型 或插入从远程服务器获取的模型
保存模型上下文时 所有待处理的变更都会 保存到数据存储中
随着时间的推移
当用户使用你的 App 及不同功能
进行交互时 其中一些模型可能会发生变更 或被删除
你的 App 可以随时查询 存储中的数据 但是 查询结果代表的是 数据存储中的当前数据 如果没有历史记录或手动比较 就无法从查询中得知 自上次查询后添加、删除 或更新了哪些模型 SwiftData 历史记录 让你能够简单高效地 跟踪数据存储在一段时间内的变更
你可以用这个 API 来构建 许多不同的功能 例如 你可能想要按时间顺序记录 App 离线时发生的变更 这样一来 这些变更之后就能 与远程服务器高效同步
你可能希望发现 不同进程中数据发生的各种变更 比如小组件扩展 这样你就可以在 App 中 正确反映这些变更
或者 你可能只想 通过一种有效的方式 了解自上次查询以来 插入或删除了哪些模型 以便在运行时更新某些状态 让我们来了解一下运作方式
通过 SwiftData 历史记录 你的 App 可按照变更发生的顺序 查询和处理变更
每次保存模型时 它都会记录一个事务 其中包含关于所有变更的元数据
SwiftData 历史记录 由事务和变更组成 事务会将数据存储中 在某个边界发生的所有变更 分组汇总 例如 ModelContext 保存时 事务按发生时间排序
在事务中 它所包含的变更集 也保留了每项变更发生的顺序 每项变更代表插入、更新 或删除了一个模型 还会接受 PersistentModel 的参数化处理 这样就能使用 KeyPaths 来引用 PersistentModel 的属性
SwiftData 历史记录 使用令牌的概念 令牌是历史记录中事务的书签 令牌可以帮助你的 App 跟踪 它在历史记录流中 处理的最后一项事务
令牌只对与它关联的数据存储有效 在 SwiftData 中 可以通过 模型上下文删除历史记录信息
删除时 历史记录中 已删除部分的令牌就会过期 无法用于获取操作
当 SwiftData 历史记录操作 涉及过期令牌时 就会抛出 historyTokenExpired 错误 如果出现这种错误 应丢弃 不再有效的令牌并获取新令牌
删除模型时 模型中的数据也会被丢弃 这意味着标识符等重要数据 可能会丢失 并且在处理历史记录信息时 无法为 App 提供足够的信息 为解决这个问题 SwiftData 历史记录允许 保留模型的特定属性 模型删除时 这些属性将作为墓碑值保存 让你可以处理已删除模型的 历史记录信息
在 PersistentModel 中 使用修饰符 .preserveValueOnDeletion 标记的属性 会保留在墓碑中 墓碑也由 PersistentModel 进行参数化 因此 它们的 KeyPaths 可用于 检索墓碑值或 根据需要进行迭代
SwiftData 中的历史记录 API 易于使用 并且建立在 Swift 丰富的 类型系统之上 让我们来探讨如何在 App 中 使用这个 API 为方便演示实际应用情况 我将为“SampleTrips”App 开发一个新功能 使用这个 App 我可以记录 所有我喜欢的行程 帮助我规划下一次度假
我想添加一项新功能 给存在未读变更的行程 打上标记 供我查看 未读变更可能发生在 App 外部 比如 从远程服务器同步时 或是在 App 对应的小组件中
在小组件中 我打算添加一项功能 可直接从主屏幕上确认 指定的住宿安排 使用 SwiftData 历史记录 我可以构建这项功能 实现方法是找出这些数据的变更时间 以及变更人 并更新相应的 UI
为此 我把任务分成三个步骤: 获取 SwiftData 历史记录
通过检查变更的属性来处理变更 最后更新用户界面 首先 我将创建一个函数 根据 token 和 author 参数 获取数据存储中的事务 在本例中 token 对应的类型是 DefaultHistoryToken 因为这个 App 使用的是 SwiftData 中的 DefaultStore
接下来 我将创建 HistoryDescriptor 以便为我的请求配置约束
我将创建一个谓词来约束 事务必须发生在所提供的令牌之后
由于我希望这个函数 只显示我的小组件中的变更 因此我还将添加一个约束条件 以获取由指定作者撰写的变更 如果我调用这个函数时没有令牌 我就会获取所有可用的历史记录
接下来 我会创建一个数组 其中包含所有 需要处理的事务 我将使用描述符在 ModelContext 上 调用 fetchHistory 这将提供 DefaultHistoryTransaction 集合 然后遍历整个集合 现在 我能够检索我关注的事务 我将定义另一个函数 来处理这些事务 这个函数将接受一个事务数组 并返回一个需要标记为 未读变更的行程集合 还会返回一个令牌 每次函数运行时 它都会返回一个新的令牌 下次我想查找变更时 就可以使用这个令牌
首先 我将定义一个 ModelContext 和一个集合 用来存储有未读变更的行程
对于每项事务和事务中的变更 历史记录 API 都会提供相应的 持久性模型 ID 为了获取行程的模型实例 我将使用这个持久性模型 ID 为 LivingAccommodation 构建一个获取描述符 然后 我会从模型上下文中 获取这个模型 并存储与 LivingAccommodation 相关联的行程
为了确定事务中的变更 是插入、更新还是删除 我将使用 switch 语句 来检查变更的类型
如果我的小组件插入、 更新或删除了一个 LivingAccommodation 模型 我想在 App 的 UI 中应用一个标记 为此 我首先要检查 LivingAccommodation 是否有 DefaultHistoryInsert 类型的变更 如果这个案例匹配 则表示 插入了一个 LivingAccommodation 因此 应将这一行程添加到集合中 再次提醒大家注意一下 这里的类型 称为 DefaultHistoryInsert 因为在本例中 我是使用 DefaultStore 来处理这个模型 我还要为 LivingAccommodation 的 DefaultHistoryUpdate 类型 添加一个案例以便检查更新 如果这一变更属于更新 我会更新集合中的行程
如果行程删除 我不需要在 App 的 界面中执行任何操作 为了处理这种情况 我将为 LivingAccommodation 的 DefaultHistoryDelete 类型 添加一个案例 并将相应行程从我的集合中删除
最后 我会将最后一个令牌 连同我的行程集合一起返回 这样 以后调用这个函数时 就只会返回在这一事务之后 发生的变更
使用 SwiftData 历史记录 这个 App 现在可以发现 自 App 上次检查变更以来 小组件中的哪些行程发生了变更 现在 我需要存储这个令牌 以便它只考虑 自上次发现变更以来 发生的变更 为此 我将定义第三个函数 并使用 UserDefaults 来存储 最近的令牌 在函数 findUnreadTrips 中 我将获取可用的令牌 并从 JSON 解码令牌 然后再用这个令牌调用 我的 findTransactions 函数 我想指定的作者是小组件 所以 在小组件中 我在 ModelContext 上 设置了 .author 属性 这个属性等同于 TransactionAuthor.widget
调用 findTrips 后 将返回的令牌 重新存储到 UserDefaults 中 现在 每次调用 findUnreadTrips 时 它只会返回自上次调用以来 需要标记的行程
我的功能基本可以使用了 我只需要再添加两个操作:
第一 当 App 打开时 我想检查是否有未读行程 第二 当用户轻点某个行程时 我想 将这个行程从未读行程集合中移除 这样标记就会消失 在 SwiftUI 视图中 只要场景阶段变为活跃状态 我就会调用 findUnreadTripIdentifiers 这将更新界面 添加需要标记的新行程
然后 当用户选中一个行程时 我会 从 unreadTripIdentifiers 集合 移除这个行程的标识符 这样标记就会消失
最后 我会将标记添加到 unreadTripIdentifiers 集合 中包含的任何行程上
现在 所有必要的代码都已实现 我将构建并运行 App App 中已经输入一次行程 我想直接在主屏幕上的小组件中 确认住宿安排
轻点“Accommodation” UI 就会发生变化 显示已确认住宿 下次我启动行程 App 时 前往 Formation Flyover 的行程 会有一个蓝色的未读标记 表明这一行程已发生变更 查看行程后 标记就会移除
如果你使用 SwiftData 构建自定数据存储 你的自定存储也可以 支持历史记录功能 如果你的底层模型 支持历史记录功能 你也可以通过你的存储实现 来支持同样的工作流程 要在自定数据存储中 添加历史记录功能 你需要为你的数据存储 实现自己的类型 以表示 SwiftData 历史记录 API 的基本元素 这包括事务、每种变更类型 以及在事务之间充当书签的令牌 此外 你的自定数据存储 需要符合 HistoryProviding
事务的边界需要明确定义 因为数据存储中的 写入操作需要合并和排序 在默认存储中 保存时 对 ModelContext 上 模型实例的所有变更 都归类为一项事务
创建事务类型时 你需要定义一种方法 用来在持久性后端中唯一标识事务 与事务类似 变更边界的定义 也必须明确 在 DefaultStore 中 变更的边界范围是单个模型实例
选择一个标识符 跟踪这些变更的细粒度性质
你的 App 可能不需要 所有现有的变更类型 或者需要其他的变更类型 例如 如果你的 App 只需插入模型 作为时间序列日志 你可能不需要更新 和删除变更类型 另外一个考虑因素是 你的 App 是否需要 支持在删除时保留值 以及如何存储删除的值
自定存储需要实现 HistoryProviding 协议 来提供历史记录 这就需要能够从存储中提取 定义事务和变更的行
在确定哪些行是事务的一部分后 你需要建立特定的模型集
默认数据存储会管理 历史记录的生存时间 作为自定提供者 你需要 决定何时删除历史记录 虽然 SwiftData 历史记录很强大 可以处理大量历史记录数据 但在某些特定情况下 你可能希望删除历史记录 例如 假设你从 App 中 删除了模型 那么可能会有一些关于 这些模型的历史记录数据 或许你今后永远不会用到这些数据 在这种情况下 你可能希望 从数据存储中删除这些历史记录数据
最后 在为自定存储 添加历史记录支持时 你需要创建一种自定类型的令牌 HistoryToken 是令牌的 基本协议 需要使用状态来唯一标识 你在事务流中的位置
请考虑你的 App 是否使用了 多个相关存储 你的自定令牌应包括事务中 使用的所有存储的状态
历史记录是一项强大的功能 可查询变更 例如从小组件发现行程的更新 SwiftData 采用 Swift 富有表现力的类型系统 可帮你轻松掌握每项模型变更 在 App 中的使用情况 你可以使用 SwiftData 历史记录 在 App 中构建令人愉悦的体验 如果你使用 Core Data 的共存功能 来利用持久性历史记录 现在可以迁移到 SwiftData 历史记录 如果你正在构建自定存储 则可以创建自己的历史记录类型来 支持历史记录跟踪的所有功能 感谢观看
-
-
4:57 - Preserve values in history on deletion
// Add .preserveValueOnDeletion to capture unique columns import SwiftData @Model class Trip { #Unique<Trip>([\.name, \.startDate, \.endDate]) @Attribute(.preserveValueOnDeletion) var name: String var destination: String @Attribute(.preserveValueOnDeletion) var startDate: Date @Attribute(.preserveValueOnDeletion) var endDate: Date var bucketList: [BucketListItem] = [BucketListItem]() var livingAccommodation: LivingAccommodation? }
-
6:26 - Fetch transactions from history
private func findTransactions(after token: DefaultHistoryToken?, author: String) -> [DefaultHistoryTransaction] { var historyDescriptor = HistoryDescriptor<DefaultHistoryTransaction>() if let token { historyDescriptor.predicate = #Predicate { transaction in (transaction.token > token) && (transaction.author == author) } } var transactions: [DefaultHistoryTransaction] = [] let taskContext = ModelContext(modelContainer) do { transactions = try taskContext.fetchHistory(historyDescriptor) } catch let error { print(error) } return transactions }
-
7:34 - Process history changes
private func findTrips(in transactions: [DefaultHistoryTransaction]) -> (Set<Trip>, DefaultHistoryToken?) { let taskContext = ModelContext(modelContainer) var resultTrips: Set<Trip> = [] for transaction in transactions { for change in transaction.changes { let modelID = change.changedPersistentIdentifier let fetchDescriptor = FetchDescriptor<Trip>(predicate: #Predicate { trip in trip.livingAccommodation?.persistentModelID == modelID }) let fetchResults = try? taskContext.fetch(fetchDescriptor) guard let matchedTrip = fetchResults?.first else { continue } switch change { case .insert(_ as DefaultHistoryInsert<LivingAccommodation>): resultTrips.insert(matchedTrip) case .update(_ as DefaultHistoryUpdate<LivingAccommodation>): resultTrips.update(with: matchedTrip) case .delete(_ as DefaultHistoryDelete<LivingAccommodation>): resultTrips.remove(matchedTrip) default: break } } } return (resultTrips, transactions.last?.token) }
-
10:19 - Save and use a history token
private func findUnreadTrips() -> Set<Trip> { let tokenData = UserDefaults.standard.data(forKey: UserDefaultsKey.historyToken) var historyToken: DefaultHistoryToken? = nil if let tokenData { historyToken = try? JSONDecoder().decode(DefaultHistoryToken.self, from: tokenData) } let transactions = findTransactions(after: historyToken, author: TransactionAuthor.widget) let (unreadTrips, newToken) = findTrips(in: transactions) if let newToken { let newTokenData = try? JSONEncoder().encode(newToken) UserDefaults.standard.set(newTokenData, forKey: UserDefaultsKey.historyToken) } return unreadTrips }
-
11:30 - Update the user interface
struct ContentView: View { @Environment(\.scenePhase) private var scenePhase @State private var showAddTrip = false @State private var selection: Trip? @State private var searchText: String = "" @State private var tripCount = 0 @State private var unreadTripIdentifiers: [PersistentIdentifier] = [] var body: some View { NavigationSplitView { TripListView(selection: $selection, tripCount: $tripCount, unreadTripIdentifiers: $unreadTripIdentifiers, searchText: searchText) .toolbar { ToolbarItem(placement: .topBarLeading) { EditButton() .disabled(tripCount == 0) } ToolbarItemGroup(placement: .topBarTrailing) { Spacer() Button { showAddTrip = true } label: { Label("Add trip", systemImage: "plus") } } } } detail: { if let selection = selection { NavigationStack { TripDetailView(trip: selection) } } } .task { unreadTripIdentifiers = await DataModel.shared.unreadTripIdentifiersInUserDefaults } .searchable(text: $searchText, placement: .sidebar) .sheet(isPresented: $showAddTrip) { NavigationStack { AddTripView() } .presentationDetents([.medium, .large]) } .onChange(of: selection) { _, newValue in if let newSelection = newValue { if let index = unreadTripIdentifiers.firstIndex(where: { $0 == newSelection.persistentModelID }) { unreadTripIdentifiers.remove(at: index) } } } .onChange(of: scenePhase) { _, newValue in Task { if newValue == .active { unreadTripIdentifiers += await DataModel.shared.findUnreadTripIdentifiers() } else { // Persist the unread trip names for the next launch session. await DataModel.shared.setUnreadTripIdentifiersInUserDefaults(unreadTripIdentifiers) } } } #if os(macOS) .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in Task { unreadTripIdentifiers += await DataModel.shared.findUnreadTripIdentifiers() } } .onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in Task { await DataModel.shared.setUnreadTripIdentifiersInUserDefaults(unreadTripIdentifiers) } } #endif } }
-
-
正在查找特定内容?在上方输入一个主题,就能直接跳转到相应的精彩内容。
提交你查询的内容时出现错误。请检查互联网连接,然后再试一次。