使用 SwiftData 历史记录追踪(History Trace)跨进程同步 App 和 Widgets 间的数据更改

90 阅读6分钟

在这里插入图片描述

概述

大家都知道,我们开发的 App 和 Widgets(或者 Live Activity)实际都运行在各自独立的进程空间中,这意味着其中一方对数据库所做的改变很难被另一方所感知。

在这里插入图片描述

从 WWDC 24 开始,苹果为 SwiftData 2.0 添加了全新的历史记录追踪(History Trace,也称为模型变化跟踪 Track model changes)机制。该机制可以获取底层数据库内容改变的记录,尤其适合跨进程来同步 SwiftData 数据。

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

  1. 借助 App Groups 共享底层数据库
  2. 让 App 与 Widgets 齐头并进
  3. 使用 History Trace 跨进程监听数据库的改变
  4. 通过 DefaultHistoryUpdate 抽取托管对象被更改的字段

相信学完本课后,小伙伴们对于跨组件同步 SwiftData 数据库内容的改变将会游刃有余、胸有成竹!

那还等什么呢?让我们立即开始 History Trace 跨进程大冒险吧!

Let‘s go!!!;)


1. 借助 App Groups 共享底层数据库

在实现跨 App 和 Widgets 同步数据库之前,我们需要找到一种方法来共享它们之间的底层存储。在 Apple 平台中,我们可以选择 App Groups 或 iCloud 等方式来一蹴而就。

由于演示之目的,我们不需要 iCloud 通过云来跨设备同步数据。所以,我们选择 App Groups 来同步我们的 App 和小组件。

首先,在 Xcode 中为我们 App 和小组件(Widgets 或 Live Activity)对应的目标(Target)添加 App Groups 特性支持:

在这里插入图片描述

在这里插入图片描述

这里需要注意的是,我们需要将 App 和小组件设置成同一个 App Groups ID 名称。

如此这般,我们的 App 和 Widget 即可以通过 App Groups 共享同一片底层数据了。

enum Common {
    static let GroupsID = "group.yourGroupName.com"
}

extension ModelContainer {
    // Schema 包括所有 App 使用的数据模型
    private static let schema = Schema([
        Model.self,
        Hero.self,
    ])
    
    // shared 模型容器通过同一个 GroupsID 在 App 和 Widgets 间共享
    static var shared: ModelContainer = {
        let config = ModelConfiguration(schema: schema, groupContainer: .identifier(Common.GroupsID))
        return try! ModelContainer(for: schema, configurations: config)
    }()
    
    // 为 Xcode 预览使用的调试模型容器
    static var preview: ModelContainer = {
        let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
        return try! ModelContainer(for: schema, configurations: config)
    }()
}

从上面的代码可以看到,我们通过 App 和 Widget 使用同一个 App Groups ID 创建了名为 shared 的静态模型容器(ModelContainer),通过它我们就可以在 App 和 Widgets 间共享 SwiftData 持久存储了。

我们同时还创建了一个名为 preview 的模型容器,因为它只被用在 Xcode 预览中,所以只需常驻内存即可(无需共享支持)。

2. 让 App 与 Widgets 齐头并进

现在,我们的数据库已经被应用和小组件所共享,下面要解决的就是如何同步它们之间的数据变化了。

在 Apple 的各个系统平台中(iOS、watchOS 等),App 与其对应小组件的运行空间是彼此独立的:App 运行在自己进程,而小组件运行在系统进程(或者说是系统为你的小组件顺利执行所创建的进程)中

在这里插入图片描述

我们知道即使在同一个进程中,SwiftData 一个模型上下文(ModelContext)对持久存储的更改也不会自动同步到其它上下文中,更何况是跨进程的同步了。

所以,我们假若在 SwiftData 2.0 之前要想完成这项任务需要小费周折一番。

不过,幸运的是从 SwiftData 2.0(iOS 18,watchOS 11)开始,利用全新的历史记录追踪(History Trace)机制,我们可以轻松的搞定它!

历史记录追踪实际上提供了一种让模型上下文读取底层数据库中变化的渠道。我们可以利用 History Trace 在适当的时候获取数据库中的新增、更改和删除等操作记录,一个进程对数据库的更改,即使在另一个进程中也可以“了如指掌”。

其实,从底层上来说历史记录追踪和 CoreData 中类似的功能很像。它们都是通过将这些改变保存在数据库(自动创建独立的表)中来实现的。

3. 使用 History Trace 跨进程监听数据库的改变

在明白了使用 History Trace 跨 App 和 Widges 实现数据同步的奥义之后,我们接下来需要一种方式来感知持久数据的变化,这可以通过监听 NSPersistentStoreRemoteChange 消息来完成。

.onReceive(NotificationCenter.default.publisher(for: .NSPersistentStoreRemoteChange).receive(on: DispatchQueue.main)) { _ in
    handleChangeInMainContext()
}

如上代码所示:我们通过在 SwiftUI 视图上调用 onReceive() 修改器监听了 NSPersistentStoreRemoteChange 消息,从而在底层数据库内容发生改变时“洞若观火”。

在得知持久存储发生变化时,我们可以通过模型上下文的 fetchHistory() 方法来泰然处之了:

在这里插入图片描述

上面 NSPersistentStoreRemoteChange 消息监听代码中的 handleChangeInMainContext() 方法实现如下:

private func handleChangeInMainContext() {
    let mainContext = modelContext
    var historyDesc = HistoryDescriptor<DefaultHistoryTransaction>()
    
    let transactions = try! mainContext.fetchHistory(historyDesc)
    for trans in transactions {
        for change in trans.changes {
            guard let changedItem = mainContext.model(for: change.changedPersistentIdentifier) as? Item else { continue }
                    
            switch change {
            case .insert(_):
                NSLog("发现新增 Item - [\(changedItem.name)]")
                // 处理数据新增
            case .update(_):
                NSLog("发现更新 Item - [\(changedItem.name)]")
                // 处理数据更新
            case .delete(let historyDelete):
                if let tmp = historyDelete as? DefaultHistoryDelete<Item> {
                    print("\(tmp.tombstone[\Item.name] ?? "null") 已被删除!")
                    // 处理数据的删除
                }
            @unknown default:
                fatalError()
            }
        }
    }
    
    historyToken = transactions.last?.token
}

可以看到,当 Widgets 对持久存储进行更改后,我们在 App 中即可根据历史记录追踪所产生的不同 Change 类型,分别处理数据的新增、更新和删除操作了。

4. 通过 DefaultHistoryUpdate 抽取托管对象被更改的字段

最后相关的一个问题是:我们怎么知道托管对象中具体是哪一个(或哪几个)字段的内容发生了变化呢?

@Model
class Hero {
    var hid: UUID
    var name: String
    var power: Int
    var residentCount: Int = 0
    var timestamp: Date
    
    init(name: String, power: Int) {
        self.hid = UUID()
        self.name = name
        self.power = power
        timestamp = .now
    }
}

比如,当 Hero 某个实例对象的内容变动时,我们怎么知道是 power 或是其它字段发生了变化呢?

答案很简单:我们可以通过 DefaultHistoryUpdate 中对应的 updatedAttributes 属性来“明察秋毫”:

在这里插入图片描述 在这里插入图片描述

具体来说,我们可以遍历 DefaultHistoryUpdate 对象 updatedAttributes 属性中的所有字段对应的“更改键”,然后逐一处理之。代码如下所示:

do {
    for try await results in modelContext.historyChanges {
        for change in results.changes {
            if case .update(let historyUpdate) = change, let update = historyUpdate as? DefaultHistoryUpdate<Hero>, let updatedHero = try! modelContext.liveHeroByID(change.changedPersistentIdentifier), updatedHero.hid == hero.hid {
                for attr in update.updatedAttributes {
                    switch attr {
                    case \Hero.power:
                        let newPower = updatedHero.getValue(forKey: \.power)
                        hero.power = newPower
                    default:
                        break
                    }
                }
            }
        }
    }
} catch {
    print(error.localizedDescription)
}

现在,利用 SwiftData 2.0 中的历史记录追踪我们可以轻而易举的同步 App 与 Widgets 中的数据改变了!棒棒哒!

总结

在本篇博文中,我们讨论了在 SwiftData 2.0 中如何利用 History Trace 机制突破进程“叹息之壁”,安闲自得的同步底层数据库中内容的改变;我们还介绍了如何通过 DefaultHistoryUpdate 对象来进一步得到具体更改的字段名。

感谢观赏!再会了!8-)