由一个 SwiftData “诡异”运行时崩溃而引发的钩深索隐(五)

概述

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

我们在之前的博文中已经介绍了 History Trace 是如何处理数据新增操作的。而在这里,我们将再接再厉来完成数据删除时的全盘考量。

在本篇博文中,您将学到如下内容:

  1. 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-)

相关推荐
大熊猫侯佩9 小时前
由一个 SwiftData “诡异”运行时崩溃而引发的钩深索隐(四)
数据库·swiftui·apple watch
MaoJiu1 天前
Flutter造轮子系列:flutter_permission_kit
flutter·swiftui
大熊猫侯佩1 天前
由一个 SwiftData “诡异”运行时崩溃而引发的钩深索隐(三)
数据库·swiftui·swift
大熊猫侯佩1 天前
由一个 SwiftData “诡异”运行时崩溃而引发的钩深索隐(二)
数据库·swiftui·swift
大熊猫侯佩1 天前
用异步序列优雅的监听 SwiftData 2.0 中历史追踪记录(History Trace)的变化
数据库·swiftui·swift
大熊猫侯佩1 天前
由一个 SwiftData “诡异”运行时崩溃而引发的钩深索隐(一)
数据库·swiftui·swift
season_zhu2 天前
iOS开发:关于日志框架
ios·架构·swift
大熊猫侯佩2 天前
SwiftUI 中如何花样玩转 SF Symbols 符号动画和过渡特效
swiftui·swift·apple
大熊猫侯佩2 天前
SwiftData 共享数据库在 App 中的改变无法被 Widgets 感知的原因和解决
swiftui·swift·apple