
概述
在 WWDC 24 中,苹果推出了数据库框架 SwiftData 2.0 版本。其新加入的历史记录追踪(History Trace)机制着实让秃头码农们"如痴如醉"了一番。

我们在之前的博文中已经介绍了 History Trace 是如何处理数据新增操作的。而在这里,我们将再接再厉来完成数据删除时的全盘考量。
在本篇博文中,您将学到如下内容:
- SwiftData 对于托管对象删除的稳妥处理
这是本系列第五篇博文。闲言少叙,让我们马上开始 SwiftData 精彩的探究之旅吧!
Let's dive in!!!;)
9. SwiftData 对于托管对象删除的稳妥处理
在之前的博文中,我们讨论过如何利用历史记录追踪机制(History Trace)来处理后台线程中记录的插入操作。
我们已然知晓:History Trace 可以监听 3 种类型的数据改变:新增、更新和删除。
其中更新和新增的情况比较类似,我们不再赘述。
这里,让我们"集中火力"来聊聊 History Trace 中关于记录删除时的处理。
当托管对象从 SwiftData 持久数据库中删除时,我们仍然可以通过其实例中的 persistentModelID 来"招魂"。但是,对它任意字段的所有访问将会立即导致应用崩溃。

为了能让大家体会到这种情况,我们将之前 ContentView 视图的代码略作如下修改:
swift
struct ContentView: View {
@Environment(\.modelContext) var modelContext
@Query var items: [Item]
private func handleChangeInMainContext() {
let mainContext = modelContext
var historyDesc = HistoryDescriptor<DefaultHistoryTransaction>()
historyDesc.predicate = #Predicate { trans in
trans.author == "BG"
}
let transactions = try! mainContext.fetchHistory(historyDesc)
for trans in transactions {
for change in trans.changes {
// 在删除后,下面这个 changedItem 其实已是"尸体"💀
guard let changedItem = mainContext.model(for: change.changedPersistentIdentifier) as? Item else { continue }
// 试图访问已删除对象中任何字段的内容都会导致崩溃
switch change {
case .insert(_):
NSLog("发现新增 Item - [\(changedItem.name)]")
changedItem.timestamp = .now
case .update(_):
NSLog("发现更新 Item - [\(changedItem.name)]")
changedItem.timestamp = .now
case .delete(let historyDelete):
NSLog("已删除对象的名字:\(changedItem.name)")
@unknown default:
fatalError()
}
}
}
}
var body: some View {
NavigationStack {
VStack {
if let item = items.first {
Text(item.name).font(.headline.weight(.heavy))
}
}
.navigationTitle("History Trace 删除演示")
.toolbar {
ToolbarItem(placement:.topBarTrailing) {
Button("New", systemImage: "plus.app") {
Task.detached {
let modelContext = ModelContext(.preview)
modelContext.author = "BG"
let item = Item(name: "\(Int.random(in: 0...10000))")
modelContext.insert(item)
try! modelContext.save()
// 删除新建的 Item 对象
modelContext.delete(item)
try! modelContext.save()
await MainActor.run {
handleChangeInMainContext()
}
}
}
.foregroundStyle(.white)
.tint(.green)
}
}
}
}
}
从上面代码可以看到:我们在新增 Item 对象后立即删除了它。并且,我们把原来消息处理中 History Trace 的监听代码单独放到一个 handleChangeInMainContext() 方法里,并在 Item 删除后直接调用(当前是在主线程中)。
编译运行代码,那叫一个妥妥的"榱崩栋折":

从 Xcode 调试控制台可以进一步明确崩溃的始末缘由:
SwiftData/BackingData.swift:625: Fatal error: This model instance was invalidated because its backing data could no longer be found the store. PersistentIdentifier(id: SwiftData.PersistentIdentifier.ID(url: x-coredata://AE4FAD46-DD15-492D-BA85-B3CCEFCA2C9A/Item/p1), implementation: SwiftData.PersistentIdentifierImplementation)
对于这种情况,一般来说我们有两种方法来解决它。一种简单推荐的做法是将 handleChangeInMainContext() 方法放到 NSPersistentStoreRemoteChange 消息捕获闭包中去调用:
swift
.onReceive(NotificationCenter.default.publisher(for: .NSPersistentStoreRemoteChange).receive(on: DispatchQueue.main)) { _ in
handleChangeInMainContext()
}
这样做可以避免访问已删除托管 Item 对象所造成的崩溃。
另一种方法是在访问 History Trace 记录中可能被删除的对象时先做一下预判:
swift
extension ModelContext {
func isInContainer<T: PersistentModel>(id: PersistentIdentifier, type: T.Type? = nil) throws -> Bool {
let predicate = #Predicate<T> { obj in
obj.persistentModelID == id
}
let desc = FetchDescriptor(predicate: predicate)
if let _ = try fetch(desc).first {
return true
}
return false
}
}
如上代码所示:我们在 ModelContext 扩展了一个 isInContainer 实例方法。调用它可以检查指定 PersistentIdentifier 对应的记录是否真正存在于容器中:
swift
let mainContext = self.modelContext
var historyDesc = HistoryDescriptor<DefaultHistoryTransaction>()
historyDesc.predicate = #Predicate { trans in
trans.author == "BG"
}
let transactions = try! mainContext.fetchHistory(historyDesc)
for trans in transactions {
for change in trans.changes {
let modelID = change.changedPersistentIdentifier
// 无法保证 changedItem 还在物理容器中,后面需要调用 isInContainer() 方法进一步确认。
guard let changedItem = mainContext.model(for: change.changedPersistentIdentifier) as? Item else { continue }
switch change {
case .insert(let historyInsert):
if try! mainContext.isInContainer(id: changedItem.persistentModelID, type: Item.self) {
changedItem.timestamp = .now
}
case .update(let historyUpdate):
if try! mainContext.isInContainer(id: changedItem.persistentModelID, type: Item.self) {
changedItem.timestamp = .now
}
case .delete(let historyDelete):
break
@unknown default:
fatalError()
}
}
}
注意,虽然 SwiftData 托管对象包含一个 isDeleted 属性,但将它用于判断记录是否被删除在这种情况下不太靠谱,因为一旦上下文执行了保存操作,该属性的值就会立即变为 false。

总结
在本篇博文中,我们介绍了 SwiftData 2.0 历史记录追踪(History Trace)机制在监听数据删除事件时有着怎样的稳妥处理。
在下一篇博文中,我们将用 SwiftData 2.0 中的"墓碑"(Tombstone)特性以及我对 History Trace 目前的一些"遐想"来为整个系列博文画上一个圆满的句号。敬请期待吧!
感谢观赏,再会了!8-)