掌握 Core Data 和 SwiftData 中的数据追踪与通知

发表于

为您每周带来有关 Swift 和 SwiftUI 的精选资讯!

Core Data 和 SwiftData 作为 Apple 生态系统中强大的持久化框架,不仅提供了 @FetchRequest@Query 这样的声明式数据监听工具,更内置了一套完整的数据追踪与通知机制。了解和掌握这些机制对于构建健壮的数据驱动型应用至关重要。本文将带你探索从简单的自定义通知,到强大的持久化历史追踪(Persistent History Tracking 和 SwiftData History)等多层次的解决方案,助你应对各种复杂的数据同步场景。

自定义数据操作通知

在深入探索框架提供的复杂机制之前,我们先来看一种最直接的方法——在数据操作代码中集成自定义通知。这种方式简单而实用,我们通过以下示例来理解:

Swift
extension Notification.Name {
    static let didInsertNewItem = Notification.Name("didInsertNewItem")
    static let willInsertNewItem = Notification.Name("willInsertNewItem")
}

func newItem(date: Date) throws -> PersistentIdentifier {
    // 发送即将创建 item 的通知
    NotificationCenter.default.post(name: .willInsertNewItem, object: nil)
  
    let newItem = Item(timestamp: date)
    modelContext.insert(newItem)
    try modelContext.save()
  
    // 发送已经创建 item 的通知
    NotificationCenter.default.post(name: .didInsertNewItem, object: newItem)
    return newItem.persistentModelID
}

这种通知机制虽然需要开发者手动构建,但非常灵活。我们可以针对特定的操作自定义通知,灵活控制发送条件。而且,这种通知不受限于特定的框架,甚至可以跨进程使用。开发者可以利用达尔文通知中心(Darwin Notification Center)或其他跨进程机制,实现主应用与小组件之间的通信。

然而,这种方式也存在明显的短板。只有通过包含发送通知代码的数据操作才会触发通知。例如,在启用了 iCloud 云同步功能的情况下,从其他设备通过云端同步过来的修改将不会触发这个通知机制。此外,如果同一类型的数据操作分散在多个代码位置,任何一处遗漏发送通知都会导致信息不完整。

扩展托管对象子类

在 Core Data 中,扩展托管对象子类(NSManagedObject)允许开发者在对象生命周期的特定时刻进行干预,例如发送通知、处理属性数据等。

例如,通过重写 Item 对象(托管对象子类)的 willSavedidSave 方法,我们可以在数据即将持久化前和持久化后发送通知:

Swift
extension Item {
    public override func willSave() {
        super.willSave()
        NotificationCenter.default.post(name: .willInsertNewItem, object: nil)
    }
    
    public override func didSave() {
        super.didSave()
        NotificationCenter.default.post(name: .didInsertNewItem, object: self)
    }
}

这是 Core Data 为托管对象提供的强大机制,它在对象生命周期的多个阶段提供了供开发者调用的方法。

awakeFromInsert

当托管对象被插入到托管对象上下文中时调用。我们可以在这里执行初始化操作或调整数据。在每个对象实例的生命周期中,该方法只会被调用一次。

Swift
func newItem() {
  let item = Item(contenxt: context)
  // 未设置日期
  try? content.save()
}

public override func awakeFromInsert() {
  super.awakeFromInsert()
  // 将 timestamp 属性设置为当前日期
  self.setPrimitiveValue(Date.now, forKey: "timestamp")
}

awakeFromFetch

当托管对象从惰性加载状态(fault)转换为非惰性状态(数据已填充)时调用。我们通常可以在此为瞬态属性(Transient)动态提供值。

image-20241031110159796

Swift
public override func awakeFromFetch() {
    super.awakeFromFetch()
    // 将瞬态属性 visible 设置为 true
    self.setPrimitiveValue(true, forKey: "visible")
}

Transient 是一种可以使托管对象实例变为脏状态但不会持久化的属性类型。你可以阅读 此文 了解更多信息。

willSave

当托管对象即将持久化时调用。我们可以在此调整属性数据。

Swift
  override public func awakeFromInsert() {
      super.awakeFromInsert()
      let date = primitiveValue(forKey: "timestamp") as? Date
      let startDate = Calendar.current.date(from: DateComponents(year: 2020, month: 3, day: 11))!
      // 如果日期小于 startDate,则将 timestamp 设置为 startDate
      if let date, date < startDate {
          setPrimitiveValue(startDate, forKey: "timestamp")
      }
  }

使用 primitiveValue(forKey:)setPrimitiveValue(_:forKey:) 可以避免触发不必要的 KVO 通知。需要注意的是,setPrimitiveValue(_:forKey:) 会绕过 Core Data 的一些自动管理机制,因此在使用时需确保操作不会影响数据一致性。

didSave

当托管对象持久化后调用。除了发送保存成功的通知外,我们还可以在此清理一些数据,例如处理与对象相关的文件。在下面的代码中,对象中 url 属性对应的文件将被删除:

Swift
func deleteFile(_ url: URL) {
   // 删除文件    
}

override public func didSave() {
    super.didSave()
    // 判断是否为删除操作,如果是,则删除关联的文件
    if self.isDeleted, let url = self.primitiveValue(forKey: "url") as? URL {
       deleteFile(url)
    }
}

validateForInsert、validateForUpdate、validateForDelete

在执行插入、更新、删除操作前对数据进行验证。它们的调用时机在 willSave 之前,如果验证不通过,可以通过抛出错误来阻止持久化。

Swift
enum MyError: Error {
  case dateError
}

override public func validateForInsert() throws {
    try super.validateForInsert() // 触发模型编辑器中的验证规则
    let startDate = Calendar.current.date(from: DateComponents(year: 2020, month: 3, day: 11))!
    if let timestamp = primitiveValue(forKey: "timestamp") as? Date, timestamp < Date() {
        throw MyError.dateError
    }
}

func addItem(date: Date) {
    let newItem = Item(context: viewContext)
    newItem.timestamp = date

    do {
        try viewContext.save()
    } catch {
        if let error = error as? MyError, error == .dateError {
            print("Date Error")
            viewContext.undo()
        } else {
            print("Unexpected error: \(error)")
            // other error handling
        }
    }
}

当你在模型编辑器中设置了属性的验证规则(例如最大长度、最小值等),Core Data 会在插入或更新操作时自动检查这些条件。在你自定义的 validateForInsert 方法中调用 super.validateForInsert() 时,系统会执行这些预设的验证规则,确保符合模型编辑器中的设置。

willTurnIntoFault、didTurnIntoFault

当托管对象即将转变为惰性加载状态(fault)和已经转变为惰性状态时调用。通常不建议重写这两个方法。

prepareForDeletion

在托管对象即将被删除时调用,通常用于清理资源或手动管理关系。需要注意的是,此方法在 validateForDelete 之前触发,如果删除操作因验证失败而未完成,则在此方法中进行的更改会无法持久化。

优势与局限性

扩展托管对象子类提供了丰富的生命周期干预点,允许开发者在对象的各个阶段进行精确控制和响应。而且由于这种扩展是针对类型本身的,不限制具体的调用方式。即使项目中多个位置有不同的代码,只要针对同一类型的数据操作,都可以获得一致的时机调用。此外,在当前的 Core Data with CloudKit 中,从网络同步的数据在 NSPersistentCloudKitContainer 进行持久化操作时也会调用这些方法。

然而,正因为这些操作都是针对托管对象子类的扩展,如果不构建子类或不通过具体类型进行操作,这些方法将不会被调用。例如,在进行 批量操作 时,通常不会涉及具体的托管对象子类,因此在这种操作中不会调用上述生命周期方法。

尽管 SwiftData 提供的默认存储实现是基于 Core Data 的,但由于它不会为每个实体声明托管对象子类,因此 SwiftData 并不提供类似的功能。也就是说,这些通知时机是 Core Data 独有的。

来自上下文的通知

在 Core Data 和 SwiftData 中,开发者对数据的操作大多是在上下文(NSManagedObjectContextModelContext)中完成的。上下文是持久化框架为开发者提供的数据操作区域,负责控制数据对象的生命周期、管理关系图、跟踪并保存更改等操作。

除了这些常见功能外,上下文的能力远比我们想象的复杂。例如,当我们在两个持久化存储中保存相同的实体数据时,上下文会合并来自两个不同数据源的数据,并重新进行排序。在 iOS 18 及更高版本的 SwiftData 中,开发者还可以实现完全不具备排序能力的自定义存储,排序和筛选均由上下文机制处理。

由于上下文的重要性,它还提供了三个不同的通知,帮助开发者了解数据的变化。

NSManagedObjectContextObjectsDidChange

这是 Core Data 上下文特有的通知。当上下文中注册管理的托管对象发生变化时,会发布此通知。也就是说,只要数据被修改(dirty),即使尚未持久化,该通知也会被发送。不涉及数据修改的操作(例如数据检索)不会触发此通知

Swift
let item = Item(context: viewContext) // 仅创建,不会发送通知
item.timestamp = Date() // 数据发生变化,标记为 dirty 后,上下文会发送通知

在该通知的 userInfo 中,包含了已插入、已更新、已删除等托管对象集合。

Swift
.onReceive(
    NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange, object: nil))
{ notification in
    // object 是发出通知的上下文
    let context = notification.object as? NSManagedObjectContext
    if let userInfo = notification.userInfo {
        let insertedObjects = userInfo[NSInsertedObjectsKey] as? Set<NSManagedObject> ?? []
        let updatedObjects = userInfo[NSUpdatedObjectsKey] as? Set<NSManagedObject> ?? []
        let deletedObjects = userInfo[NSDeletedObjectsKey] as? Set<NSManagedObject> ?? []
    }
}

由于通知中包含了托管对象的完整数据,当我们在与上下文不同的线程中接收到该通知时,切勿直接操作托管对象的属性,这样会引发线程安全问题,可能导致应用崩溃。正确的做法是,提取对象的 NSManagedObjectID,在当前线程的上下文中重新获取对象。不过多数情况下,我们会在 didChange 后再操作数据。

阅读 NSManagedObjectID 与 PersistentIdentifier:掌握 Core Data 与 SwiftData 中的数据标识符,了解更多有关标识符的内容。

NSManagedObjectContextObjectsDidChange 与 ModelContext.didSave

上下文完成持久化操作后会发布通知。在 SwiftData 中,对应的通知名称为 ModelContext.didSave

相比于 NSManagedObjectContextObjectsDidChange,开发者更多情况下会使用 didChange 在持久化后再进行操作。在 SwiftData 中,为了避免出现 Core Data 中由于跨线程操作托管对象实例导致的线程问题,通知中只包含了数据对应的 PersistentIdentifier

Swift
NotificationCenter.default.publisher(for: ModelContext.didSave)
    .sink(receiveValue: { notification in
        if let userInfo = notification.userInfo {
            let inserted = (userInfo["inserted"] as? [PersistentIdentifier]) ?? []
            let deleted = (userInfo["deleted"] as? [PersistentIdentifier]) ?? []
            let updated = (userInfo["updated"] as? [PersistentIdentifier]) ?? []
            print(userInfo)
        }
    })
    .store(in: &cancellables)

在 iOS 18 中,mainContext 的自动保存机制调用时机不明确,通常间隔较长。因此,开发者最好显式调用 save,否则可能长时间收不到 didSave 通知。

与之前两种通知机制不同,NSManagedObjectContextObjectsDidChangeModelContext.didSave 会在通知中返回任意类型的数据变化。因此,在处理通知时,通常需要对特定类型及操作进行筛选:

Swift
if let userInfo = notification.userInfo {
    let insertedObjects = userInfo[NSInsertedObjectsKey] as? Set<NSManagedObject> ?? []

    // 过滤出并转换为指定类型的对象
    let insertedItems = insertedObjects.compactMap { $0 as? Item }

    for item in insertedItems {
        print(item.timestamp)
    }
}

在 SwiftData 中,可以通过以下方式过滤 PersistentIdentifier 并确定其实体类型:

Swift
if let userInfo = notification.userInfo {
    let inserted = (userInfo["inserted"] as? [PersistentIdentifier]) ?? []
    let insertedItemIDs = inserted.compactMap{ $0.entityName == "Item" }
}

此外,通过只响应特定上下文对象以及比较持久化存储 ID,可以进一步减少需要处理的数量量。

Swift
let insertedItemIDs = inserted.compactMap{ $0.entityName == "Item" && $0.storeIdentifier == privateStoreID }

NSManagedObjectContextautomaticallyMergesChangesFromParent 属性设置为 true 时,相当于启用了上下文对该通知的自动响应机制。启动后的上下文会将通知中的数据变化自动合并到当前上下文中。SwiftData 默认已经开启了此机制。

有关持久化框架的线程安全问题,请阅读 SwiftData 中的并发编程关于 Core Data 并发编程的几点提示

NSManagedObjectContextObjectsWillChange 与 ModelContext.willSave

在上下文即将进行持久化操作时发布通知。无论在 Core Data 还是 SwiftData 中,通知的 userInfo 都为空。开发者响应该通知时,唯一可获取的信息是哪个上下文即将进行持久化。

上下文通知的优势与局限性

通过响应上下文通知,开发者可以在一个入口集中处理各种类型和不同种类的数据操作,简化代码逻辑。同时,通过在此入口构建筛选逻辑,可以有效地减少不必要的数据处理量,从而优化性能。

然而,对于不经过上下文的操作(例如批量数据操作),这种机制就无能为力了。此外,这种机制只能在同一进程中使用,针对多进程场景(如主应用和小组件),不同进程间无法感知彼此的变化。

Persistent History Tracking 及 SwiftData History

为了解决上下文通知的局限性,Core Data 几年前引入了 Persistent History Tracking(PHT)功能。从 iOS 18 开始,SwiftData 也提供了类似的机制:SwiftData History。

简单来说,Persistent History Tracking(PHT)是在持久化层(SQLite 数据库)中添加一个日志,记录每次数据操作的事务(Transaction),以及事务中的具体操作(添加、删除、更新)。由于这些操作在数据库层面进行,它不关心操作是由哪段代码或哪个进程触发的,只会忠实地记录所有变化。

在 Core Data 中,我们需要通过以下方式启用该功能(这将在数据库中新增几个表):

Swift
let desc = container.persistentStoreDescriptions.first!
desc.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)

如果使用 Core Data with CloudKit,NSPersistentCloudKitContainer 会自动启用该功能。SwiftData 的数据库也默认开启了该功能。需要注意的是,对于一个已启用该功能的数据库,如果使用未开启该选项的容器将无法加载。

开启 PHT 功能后,数据库在有新事务产生时并不会默认发送通知。开发者需要为持久化存储开启发送通知的选项,其他代码才能接收到该持久化存储的变化通知。

Swift
desc.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)

接下来,只需在 Core Data 项目中监听 NSPersistentStoreRemoteChange 通知,就能获知持久化存储发生的变化。

与上下文通知不同,PHT 或 SwiftData History 的通知中并不包含具体的变化信息。开发者需要通过代码在数据库中检索特定时间后的变化,才能获取这些数据。

我们可以为上下文或事务设置名称,以便在检索信息时了解变化的来源,从而进行有针对性的处理。无论是 PHT 还是 SwiftData History,开发者从事务中获取的数据变化信息都是以数据标识符来表示的,因此与上下文响应机制一样,我们也需要为特定类型、特定操作构建数据筛选机制。

有关 PHT 的具体实现,我有一篇专门的文章进行了详细介绍:在 CoreData 中使用持久化历史跟踪。SwiftData History 与 PHT 的机制十分类似,只是在实现细节上略有不同。

与上述其他集中通知机制相比,PHT 或 SwiftData 因为在收到通知后需要访问数据库进行检索,所以响应效率较低。此外,为了保存这些变化,也会占用更多的存储空间(开发者需要通过代码定期清理)。

然而,正因为变化信息都保存在数据库中,这种通知机制的限制最少,支持批处理和跨进程使用。如果你能接受响应的微小延迟,PHT 和 SwiftData 可以说是最全面的解决方案。

总结

没有完美的方案,每种机制都有各自的优点和弊端。

通知机制灵活性局限性适用平台其他特点
自定义通知非常灵活,开发者可以自由决定发送条件和内容只能通过手动触发,不适用于外部数据(如云端同步),其他进程也需要实现类型的自定义通知Core Data、SwiftData、其他支持跨进程操作(如 Darwin 通知),提供灵活的发送和接收机制
托管对象子类扩展允许在对象生命周期的多个阶段(如 willSavedidSave 等)触发仅对具体托管对象类型有效,不支持批量操作或不使用子类的情况Core Data提供精确控制,适用于需要在数据持久化前后对数据状态和通知进行管理的复杂场景
上下文通知可集中响应多种操作类型的通知仅适用于同一进程内的上下文操作,无法检测其他进程的操作Core Data、SwiftData提供对象插入、更新、删除等详细信息,在跨线程环境中需避免直接使用对象,需转换 NSManagedObjectID,多线程环境下需注意线程安全
PHT 和 SwiftData History无需关注数据源,支持跨进程,涵盖所有数据操作(包括批量操作和多进程同步)需额外检索数据库,影响性能Core Data、SwiftData能记录具体的事务和操作历史,是多进程、批量操作等数据同步的解决方案,适用于需要在本地数据中进行操作跟踪的复杂应用场景,响应效率较低,需定期清理历史记录避免占用空间

在选择通知机制时,开发者应综合考虑灵活性、性能和项目需求。理解每种机制的优势和局限性,有助于在应用中实现高效、可靠的数据同步和状态管理。

每周一晚,与全球开发者同步,掌握 Swift & SwiftUI 最新动向
可随时退订,干净无 spam