Track model changes with SwiftData history
SwiftUI & UI Frameworks 进阶 20m

用 SwiftData History 追踪模型变更

Track model changes with SwiftData history

2024年6月10日

在 Apple 官方观看视频

一句话判断

如果你的 App 需要离线同步、Widget 数据回传或增量式 UI 更新,SwiftData History 是今年最值得直接上手的新 API 之一。

这场 Session 讲了什么

SwiftData History 是 WWDC 2024 引入的一项新能力,允许 App 追踪 SwiftData 数据仓库中发生的所有变更。在这之前,SwiftData 的查询只能拿到”当前状态”,你无法知道自上次查询以来哪些模型被插入、更新或删除了。History API 正好补上了这块空白。

整个 History 体系由三个核心概念组成:Transaction、Change 和 Token。每次 ModelContext 执行 save 时,SwiftData 会生成一个 Transaction,里面包含一组有序的 Change。每个 Change 描述了一个 PersistentModel 的插入、更新或删除操作,并且用泛型参数化,可以直接通过 KeyPath 访问模型属性。Token 则充当书签,标记你上次处理到哪条 Transaction,下次查询时从 Token 位置继续即可。

Session 以一个旅行 App(SampleTrips)为例,演示了如何用 History API 检测 Widget 中发生的住宿确认操作,并在主 App 中为对应 Trip 添加未读标记。整个流程拆成三步:fetch History、process changes、update UI。

值得深挖的点

Token 生命周期与过期处理

Token 并不是永久有效的。当 History 数据被清理后,旧的 Token 会变成 expired 状态,再次使用时会抛出 historyTokenExpired 错误。设计上这很像数据库的 cursor 或 replication log 的 LSN——你在消费端必须处理”日志截断”的情况。Apple 建议的做法是:捕获这个错误后丢弃旧 Token,重新从最新位置拉取全量数据。对于同步场景来说,这意味着你需要考虑全量补偿逻辑,而不是假设增量流永远连续。

Tombstone 机制与删除数据保留

模型被删除后,它的属性数据会随之消失,但 History 场景下你往往需要知道”删了什么”。SwiftData History 提供了 .preserveValueOnDeletion 修饰符,标记在 PersistentModel 的属性上后,即使模型被删除,这些属性值也会以 tombstone 的形式保留在 History 中。Tombstone 同样是泛型参数化的,可以通过 KeyPath 访问。这个设计在服务端同步场景下特别重要——你需要把删除操作同步到远端,而远端需要知道被删记录的 ID。

代码片段

场景一:用 HistoryDescriptor 查询指定 Token 之后的 Transaction

import SwiftData

// 构建 History 查询描述符
func fetchHistory(after token: DefaultHistoryToken?,
                  author: String) throws -> [DefaultHistoryTransaction] {
    // 构建 predicate:只取 token 之后的 transaction
    var predicate = #Predicate<DefaultHistoryTransaction> { transaction in
        transaction.token > token
    }
    // 如果指定了 author,进一步过滤
    // 注意:HistoryDescriptor 支持多个约束条件

    let descriptor = HistoryDescriptor(
        predicate: token != nil
            ? #Predicate<DefaultHistoryTransaction> { $0.token > token! }
            : nil
    )

    // 坑点:fetchHistory 是 ModelContext 的方法,需要在正确的 context 上调用
    return try modelContext.fetchHistory(descriptor)
}

坑点:HistoryDescriptor 的泛型类型必须和你的 DataStore 类型匹配,比如 DefaultStore 对应 DefaultHistoryTransaction。用错了类型编译能过但运行时会崩溃。

场景二:遍历 Transaction 中的 Change 并按类型处理

func processTransactions(
    _ transactions: [DefaultHistoryTransaction]
) -> (Set<Trip>, DefaultHistoryToken?) {
    var tripsWithUnreadChanges = Set<Trip>()
    var lastToken: DefaultHistoryToken?

    for transaction in transactions {
        for change in transaction.changes {
            // 通过 persistentModelID 获取对应的模型实例
            let fetchDescriptor = FetchDescriptor<LivingAccommodation>(
                predicate: #Predicate { $0.persistentModelID == change.changedPersistentModelID }
            )

            switch change {
            case let insert as DefaultHistoryInsert<LivingAccommodation>:
                // 插入:添加对应 Trip 到未读集合
                if let accommodation = try? modelContext.fetch(fetchDescriptor).first {
                    tripsWithUnreadChanges.insert(accommodation.trip)
                }

            case let update as DefaultHistoryUpdate<LivingAccommodation>:
                // 更新:刷新 Trip 状态
                // 坑点:更新可能来自同一个进程,注意去重
                if let accommodation = try? modelContext.fetch(fetchDescriptor).first {
                    tripsWithUnreadChanges.insert(accommodation.trip)
                }

            case let delete as DefaultHistoryDelete<LivingAccommodation>:
                // 删除:从集合中移除(可选操作)
                break

            default:
                break
            }
        }
        lastToken = transaction.token
    }
    return (tripsWithUnreadChanges, lastToken)
}

场景三:为模型属性设置删除时保留

@Model
class LivingAccommodation {
    var name: String
    var trip: Trip?

    // 标记此属性在模型删除后仍可在 History 中访问
    @Attribute(.preserveValueOnDeletion)
    var accommodationID: UUID

    init(name: String, trip: Trip?) {
        self.name = name
        self.accommodationID = UUID()
        self.trip = trip
    }
}

坑点:.preserveValueOnDeletion 只能用在存储属性上,计算属性不支持。而且 tombstone 数据不会自动清理,需要在合适的时机手动清理 History。

最佳实践

  • 迁移建议:如果你的 App 已经在使用 SwiftData,接入 History API 的改造成本不高。核心是在 Model 层加 .preserveValueOnDeletion,然后在需要监听变更的地方用 HistoryDescriptor 查询。Token 持久化建议放在 UserDefaults 或文件系统中,不要存在 SwiftData 本身(因为清理 History 可能导致循环依赖)。
  • 自定义 DataStore:如果你用了自定义 DataStore 实现,需要自己实现 History 相关接口。Session 提到这是一个需要额外处理的点,建议评估工作量后再决定是否迁移。
  • 性能考虑:History 数据会持续增长,务必设计清理策略。对于离线同步场景,建议在成功同步到服务端后立即清理对应的 History 数据。

还有什么值得关注

  • SwiftData History 和 NSPersistentHistoryTransaction 的设计思路非常相似,有 Core Data 经验的开发者会觉得很熟悉
  • Token 机制可以跨进程使用,Widget Extension 和主 App 之间共享 Token 是一个常见场景
  • Session 中提到了 author 概念,可以区分不同来源的变更(比如主 App vs Widget),这对调试和逻辑分流很有帮助
WWDC 2024