在 SwiftData 和 Core Data 中用 Transaction 代替 Save

发表于

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

在数据持久化操作中,确保数据的一致性和完整性至关重要。SwiftData 框架通过在 ModelContext 中引入 transaction 方法,为开发者提供了一种更优雅的方式来组织和管理数据操作。本文将探讨如何运用事务(Transaction)的概念来构建更可靠、高效的持久化操作。

理解 Transaction(事务)

在数据库领域,Transaction(事务)是一个强大而基础的概念。它可以将多个相关的数据库操作打包成一个不可分割的逻辑单元,遵循“全有或全无”的原则 —— 要么所有操作都成功执行,要么在遇到错误时完全回滚,就像这些操作从未发生过一样。这种机制为数据操作提供了强大的安全保障,确保了数据的一致性和完整性。

虽然 SwiftData 和 Core Data 都以支持事务的 SQLite 作为底层存储引擎,但有趣的是,Core Data 选择了一种更抽象( 或更隐晦 )的方式来处理事务。在其主要 API 中(除了持久化历史跟踪外),你很少能直接看到事务相关的概念和操作接口。

Core Data 的隐式事务处理机制

Core Data 没有提供类似 BEGIN TRANSACTIONCOMMIT 这样的显式事务控制命令,而是隐式的将事务概念融入到了框架之中。每当调用 save 方法时,Core Data 都会自动将当前上下文中的所有更改打包成一个事务,统一提交给 SQLite。

让我们通过代码来理解这一机制。首先,看一个多次调用 save 的例子:

Swift
let newItem = Item(context: viewContext)
newItem.timestamp = Date()
try? viewContext.save() // 第一个事务
let newItem1 = Item(context: viewContext)
newItem1.timestamp = Date()
try? viewContext.save() // 第二个事务

相比之下,如果我们将所有操作集中到一次 save 调用中:

Swift
let newItem = Item(context: viewContext)
newItem.timestamp = Date()
let newItem1 = Item(context: viewContext)
newItem1.timestamp = Date()
try? viewContext.save() // 单一事务包含所有操作

这种差异不仅关系到性能,更重要的是影响到数据操作的可靠性。考虑一个实际场景:创建一个主题(Topic)并为其添加图片。这类复合操作要求所有步骤都必须成功完成,否则就需要完全回滚。在这种情况下,使用单一事务就显得尤为重要:

Swift
do {
  let topic = Topic(context: context)
  let image = Image(context: context)
  image.topic = topic
  try context.save()  // 将所有操作打包在一个事务中
} catch {
  context.rollback()  // 出错时可以完整回滚
}

Core Data 的回滚操作(rollback)总是作用于整个事务。它会将上下文恢复到上一次成功调用 save 时的状态,从而确保数据的一致性。

事务整合对性能的影响

在 Core Data 和 SwiftData 中,合理使用事务不仅能确保数据操作的一致性,还能显著提升应用性能。让我们观察一下框架是如何处理事务的。

要查看 Core Data 构建事务的具体细节,我们可以在 Xcode 中开启调试输出选项:-com.apple.CoreData.SQLDebug 1。以下是一个简单的数据插入操作:

Swift
let newItem = Item(context: viewContext)
newItem.timestamp = Date()
try? viewContext.save()

通过调试输出,我们可以看到 Core Data 实际上为这个简单操作创建了两个独立的事务:

Bash
// 事务 1:分配主键
CoreData: sql: BEGIN EXCLUSIVE // 开启事务
CoreData: sql: SELECT Z_MAX FROM Z_PRIMARYKEY WHERE Z_ENT = ?
CoreData: annotation: getting max pk for entityID = 1
CoreData: sql: UPDATE OR FAIL Z_PRIMARYKEY SET Z_MAX = ? WHERE Z_ENT = ? AND Z_MAX = ?
CoreData: annotation: updating max pk for entityID = 1 with old = 6 and new = 7
CoreData: sql: pragma auto_vacuum
CoreData: annotation: sql execution time: 0.0000s
CoreData: sql: pragma auto_vacuum=2
CoreData: annotation: sql execution time: 0.0000s
CoreData: sql: COMMIT // 提交事务

// 事务 2:插入数据并更新历史跟踪
CoreData: sql: BEGIN EXCLUSIVE
CoreData: sql: INSERT INTO ZITEM(Z_PK, Z_ENT, Z_OPT, ZTIMESTAMP) VALUES(?, ?, ?, ?)
CoreData: details: SQLite bind[0] = (int64)7
CoreData: details: SQLite bind[1] = (int64)1
CoreData: details: SQLite bind[2] = (int64)1
CoreData: details: SQLite bind[3] = (timestamp)753434654.313978
...... 更新历史数据
CoreData: sql: COMMIT

这个输出揭示了一个重要的事实:

  • 每次调用 save 时,Core Data 或 SwiftData 都需要执行额外的框架级操作,这些操作会产生附加的事务开销。
  • 框架的数据响应机制(如 didSave 通知或持久化历史跟踪)也是以事务为单位触发的。频繁的事务提交不仅会增加数据库操作的开销,还会导致更多的通知响应,进而影响 UI 的响应性能。

因此,将相关的数据操作整合到单个事务中不仅是一种良好的实践,更是提升应用性能的策略。

想深入了解 Core Data 的主键(Z_PK)机制,可以参考 Core Data 是如何在 SQLite 中保存数据的

SwiftData 的 transaction API

SwiftData 通过在 ModelContext 中引入 transaction 方法,为开发者提供了一种更优雅、明确的事务处理方式。这个设计不仅简化了事务操作,更重要的是引导开发者建立起“以事务为单位”的编程思维,鼓励将相关的业务逻辑打包成完整的事务单元。

Swift
public func transaction(block: () throws -> Void) throws

transaction 方法具有两个重要特性:

  1. 自动提交:开发者不需要显式调用 save 方法,SwiftData 会在闭包执行完成后自动进行持久化操作。
  2. 即时保存:即便 mainContext 启用了 autosaveEnabled(自动保存)模式,该方法也会忽略这个设置,确保在闭包执行完成后立即进行数据持久化。

以下是一个实际的使用示例:

Swift
try? modelContext.transaction {
    let item = Item(timestamp: Date())
    modelContext.insert(item)
    let item2 = Item(timestamp: Date())
    modelContext.insert(item2)
}

这种方式带来的好处是显而易见的:代码更加清晰、意图更加明确,同时也能确保相关操作的原子性和数据一致性。

为 ModelActor 实现更完善的事务处理

随着 SwiftData 引入 @ModelActor 这一优雅的并发编程模式,我们可以构建更安全、更高效的数据操作架构。在这种架构中,所有的数据修改操作都封装在 actor 中,并在后台上下文中进行,而主线程上下文仅负责数据获取。因此,我们也需要提供一种基于 actor 模式的事务处理机制。

请阅读 SwiftData 实战:用现代方法构建 SwiftUI 应用SwiftData 中的并发编程了解如何使用 @ModelActor

Swift
@ModelActor
public actor DataHandler {}

extension DataHandler {
    func save(_ saveImmediately: Bool) throws {
        if saveImmediately, modelContext.hasChanges {
            try modelContext.save()
        }
    }

    /// 内部使用的事务方法,接受一个同步闭包,返回值不需要符合 Sendable 协议
    func transaction<T>(_ block: () throws -> T) throws -> T {
        let result = try block()
        try save(true)
        return result
    }

    /// 内部使用的事务方法,接受一个异步闭包,返回值需要符合 Sendable 协议
    func transaction<T: Sendable>(_ block: () async throws -> T) async throws -> T {
        let result = try await block()
        try save(true)
        return result
    }
}

为了方便在数据模块外部使用,我们还需要提供更友好的公共接口:

Swift
extension DataHandler {
    /// 对外提供的事务方法,接受一个同步闭包,返回值符合 Sendable 协议
    /// - Parameter block: 接收 DataHandler 实例的同步操作闭包
    public func transaction<T: Sendable>(_ block: (DataHandler) throws -> T) throws -> T {
        let result = try block(self)
        try save(true)
        return result
    }

    /// 对外提供的事务方法,接受一个异步闭包,返回值符合 Sendable 协议
    /// - Parameter block: 接收 DataHandler 实例的异步操作闭包
    func transaction<T: Sendable>(_ block: (DataHandler) async throws -> T) async throws -> T {
        let result = try await block(self)
        try save(true)
        return result
    }
}

上述方式同样适用于 Core Data,请阅读 以 SwiftData 之道,重塑 Core Data 开发 了解如何在 Core Data 中实现与 SwiftData 一样的并发编程体验。

这种实现方式带来多重好处:

  1. 并发安全:通过 actor 和自定义执行器确保数据操作的线程安全
  2. 接口清晰:提供了内部和外部两套完整的事务处理接口
  3. 类型安全:妥善处理了 Sendable 协议要求,增加了返回值的处理
  4. 使用便捷:统一的事务处理模式简化了开发流程

通过使用这些 transaction 方法替代直接的 save 调用,我们可以更好地控制事务粒度,避免过多的事务创建,从而提升应用性能并确保数据操作的可靠性。

实质重于形式

虽然直接调用 transaction 方法可以给开发者更明确的指引,但这并不意味着它一定比直接使用 save 方法更优。本文的目的是提醒开发者了解 Core Data 和 SwiftData 中数据操作与事务的关系,并倡导以事务的逻辑来组织代码。

然而,任何事情都应适度,事务并非越大越好。考虑到 SQLite 的一些限制(例如 WAL 日志的容量),过大的事务可能会导致性能下降,甚至操作失败。在实践中,将一次性大量操作拆分为多个适当规模的事务是常见且明智的选择。

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