在 SwiftData 中使用 updatedAt 字段的最佳实践(didSet/willSet)

了解如何正确实现和管理 SwiftData 模型中的更新时间字段,以及解决自动更新的挑战。

在 SwiftData 中使用 updatedAt 字段的最佳实践(didSet/willSet)

updatedAt 字段的作用

为什么需要使用 updatedAt 字段?

通常用来排序,例如筛选出最近更新的数据,并按照更新时间来排序。

存储属性 vs 计算属性

为何推荐使用存储属性而不是计算属性?

尽管可以创建一个计算属性来动态确定最新的更新时间,例如:

var lastUpdatedAt: Date {
    let commentLastUpdated = comments?.max(by: { $0.updatedAt < $1.updatedAt })?.updatedAt ?? createdAt
    let watchLastUpdated = watchHistory?.max(by: { $0.timestamp < $1.timestamp })?.timestamp ?? createdAt
    return max(commentLastUpdated, watchLastUpdated)
}

但计算属性存在以下问题:

  1. 查询限制:SwiftData 的查询谓词(Predicate)不支持计算属性,无法用于过滤和排序
  2. 性能问题:在内存中进行计算和排序比在数据库层面操作慢得多,特别是当数据集较大时
  3. 内存占用:需要将所有对象加载到内存中才能执行排序

因此,如果数据量比较大,推荐优先使用存储属性来存储 updatedAt 时间。

创建 updatedAt 存储属性

手动更新 updatedAt 字段(仅适用于简单场景)

最直接的方法是在每次修改数据时手动更新 updatedAt 字段:

bookmark.title = "新标题"
bookmark.updatedAt = Date()
try? modelContext.save()

这种方法在简单场景下有效,但当数据更新发生在多个位置时(例如 comments、watchHistory 字段),你必须保证所有代码同步——这很不愉快还容易犯错。

一种更理想的方案时,每当关联字段被更新时,自动更新 updatedAt 字段存储的时间。无论是 comments 还是 watchHistory。

使用 didset 和 willSet 属性观察器(最理想,但SwiftData 尚不支持)

Swift 提供了 didset 和 willSet 属性观察器(property observers)专用用于解决这个问题。

它们从 Swift 1.0(2014年)就已经引入。这两个属性观察器允许你监控和响应属性值的变化:

  • willSet:在属性值被设置之前调用
  • didSet:在属性值被设置之后调用
var counter: Int = 0 {
    willSet {
        print("即将将 counter 从 \(counter) 设置为 \(newValue)")
    }
    didSet {
        print("已将 counter 从 \(oldValue) 设置为 \(counter)")
    }
}

但遗憾的是,截止 iOS18,SwiftData 仍然不支持 willSet 和 didSet —— 你可以添加,但它不会生效。

封装专门的数据更新方法(目前的妥协方案)

在等待 SwiftData 支持属性观察器之前,最佳实践是创建专门的更新方法,在这些方法中统一管理数据更新和时间戳更新:

@Model
class Bookmark {
    var title: String
    var content: String
    var updatedAt: Date
    var watchHistory: [WatchRecord]?
    var currentStatus: WatchStatus = .notStarted
    
    // 集中管理标题更新
    func updateTitle(_ newTitle: String) {
        self.title = newTitle
        self.updatedAt = Date()
    }
    
    // 集中管理内容更新
    func updateContent(_ newContent: String) {
        self.content = newContent
        self.updatedAt = Date()
    }
    
    // 集中管理观看历史更新
    func updateWatchHistory(_ newRecords: [WatchRecord]) {
        self.watchHistory = newRecords
        self.updateCurrentStatusFromHistory()
        self.updatedAt = Date()
    }
    
    // 根据观看历史更新状态
    private func updateCurrentStatusFromHistory() {
        guard let history = watchHistory, !history.isEmpty else {
            currentStatus = .notStarted
            return
        }
        
        let totalDuration: TimeInterval = 300 // 假设总时长5分钟
        let watchedDuration = history.reduce(0) { $0 + $1.duration }
        
        if watchedDuration >= totalDuration * 0.9 {
            currentStatus = .completed
        } else if watchedDuration > 0 {
            currentStatus = .inProgress
        } else {
            currentStatus = .notStarted
        }
    }
}

SwiftData 中的 willSave 和 didSave(不适用)

willSave 和 didSave 不是 Swift 标准语言的一部分,而是 SwiftData 框架中的生命周期方法,在 iOS 17(2023年)随 SwiftData 一起引入。willSave 和 didSave 是 SwiftData 的 ModelContext 提供的一个通知回调,当对象成功保存到数据库后触发。

遗憾的是,didSave 不适合解决 watchHistory 与 lastUpdatedAt 的自动同步问题。

作用域不同:

  • willSet/didSet 是属性观察器,作用于单个属性
  • willSave/didSave 是模型生命周期方法,作用于整个模型对象

触发时机不同:

  • willSet/didSet 在属性值变化时触发
  • willSave/didSave 在模型对象被持久化到数据库时触发

用途不同:

  • willSet/didSet 主要用于响应属性变化,执行相关逻辑
  • willSave/didSave 主要用于在数据持久化前后执行数据验证、清理或后续操作

Observation 中的 withObservationTracking 方法(不适用)

不适用于此目的。

SwiftData 中的 fetchHistory 方法