Core Data 改革:实现 SwiftData 般的优雅并发操作

发表于

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

SwiftData,作为 Core Data 的后继者,引入了众多创新和现代化的设计思想。尽管它已经推出一段时间,但许多开发者还未在他们的项目中采用。这种状况部分是因为 SwiftData 对操作系统版本的要求较高,另一方面,由于 SwiftData 在某些功能方面还不够成熟,即便操作系统版本符合要求,开发者也可能因为功能限制而选择继续使用 Core Data。我们是否能将 SwiftData 中的一些卓越设计理念和巧妙实现,融合到 Core Data 的实际使用中呢?本文旨在探讨如何在 Core Data 中引入类似 SwiftData 的优雅和安全的并发操作,以实现一个 @ModelActor 的 Core Data 版本。

perform VS @ModelActor

尽管在理论上,只需要遵循一个简单原则即可在 Core Data 中安全地进行并发操作:托管对象应仅在其绑定的托管对象上下文及相应线程中被操作。然而,要遵守这个规则,完全取决于开发者的耐心和经验,而编译器在这方面无法提供帮助。因此,在 Core Data 的并发代码实践中,广泛使用基于上下文的 perform 方法,这种做法既繁琐又难以控制。

SwiftData 克服了这些障碍。通过采用 Swift 的现代并发模型,开发者可以避开 perform,将数据操作逻辑封装在一个 Actor 中。此外,SwiftData 还引入了 @ModelActor 宏,允许 Actor 在特定线程中执行,为开发者提供了一种优雅、安全、高效的并发操作方式。

Swift
@ModelActor
actor DataHandler {
    func updateItem(identifier: PersistentIdentifier, timestamp: Date) throws {
        guard let item = self[identifier, as: Item.self] else {
            throw MyError.objectNotExist
        }
        item.timestamp = timestamp
        try modelContext.save()
    }
}

推荐阅读 关于 Core Data 并发编程的几点提示 了解更多关于 Core Data 并发操作的建议。同时,浏览 SwiftData 中的并发编程 可以深入了解 SwiftData 在并发操作方面的创新之处。

自定义 Actor 执行者

自 Swift 5.5 引入新并发模型以来,Actor 已经成为开发者执行串行操作的首选机制。然而,这种新的并发设计有意识的模糊了代码的实际运行方式和细节,使得很长一段时间,开发者都无法决定 Actor 的具体执行位置( 也就是所在的线程 )。

遵循 Core Data 并发操作的基本原则,所有对托管对象的操作都必须在其所属上下文的线程上执行。这个限制意味着我们无法直接将 Actor 模型应用于 Core Data 的并发操作中。

然而,Swift 社区通过 SE-392 提案,提出了自定义 Actor 执行者的概念,并在 Swift 5.9 中实现了这一功能。SwiftData 利用这一新特性,为开发者提供了一种全新的并发开发体验。

这意味着,我们现在可以为 Actor 创建一个 Executor,用它来替换 Actor 默认的任务调度机制。

创建自定义 Executor

在构建自定义 Actor 执行者之前,了解一些基本概念是必要的:

  • Executors 协议:一个基本的执行器,不提供任何调度顺序的保证,可以并行或串行地执行提交的任务。
  • SerialExecutor 协议:一个串行执行器,符合 Executors 协议。它保证任务的互斥执行,也就是说一次只能执行一个任务。这个协议被 Actor 用来实现他们的串行执行语义。
  • UnownedSerialExecutor:一个优化过的 SerialExecutor 引用类型,它为 Swift 并发运行时提供了高效的执行器引用机制,从而避免不必要的开销。这有助于提升 Swift 并发编程的性能。
  • ExecutorJob:一个可以被执行的任务类型,支持 Sendable 协议且不可复制(@noncopyable)。执行者在需要执行任务时,会调用 ExecutorJob.runSynchronously(on:) 方法,该方法会消耗掉 ExecutorJob 实例,并在指定的执行者上同步执行任务。
  • UnownedExecutorJob:作为 ExecutorJob 的补充类型,它是可复制的,使得任务存储和传递变得更加容易。

Actor 构建自定义执行者时,大致步骤如下:

  • 声明一个遵循 SerialExecutor 协议的类型。
  • 在其内部实现一个可以进行串行操作的机制。
  • enqueue 方法中,将 ExecutorJob 转换为 UnownedExecutorJob 并提交给串行机制执行。

具体的实现示例如下所示:

Swift
public final class CustomExecutor: SerialExecutor {
  // 串行工具
  private let serialQueue: DispatchQueue
  public init(serialQueue: DispatchQueue) {
    self.serialQueue = serialQueue
  }

  public func enqueue(_ job: consuming ExecutorJob) {
    // 转换 ExecutorJob 为 UnownedJob
    let unownedJob = UnownedJob(job)
    let unownedExecutor = asUnownedSerialExecutor()
    // 在串行队列中执行任务
    serialQueue.async {
      unownedJob.runSynchronously(on: unownedExecutor)
    }
  }
 
  // 转换自身为 UnownedSerialExecutor
  public func asUnownedSerialExecutor() -> UnownedSerialExecutor {
    UnownedSerialExecutor(ordinary: self)
  }
}

对于 Core Data 的应用场景,我们可以直接利用托管对象上下文的 perform 方法作为串行操作的工具。经过适当调整后的实现如下:

Swift
public final class NSModelObjectContextExecutor: @unchecked Sendable, SerialExecutor {
  public final let context: NSManagedObjectContext
  public init(context: NSManagedObjectContext) {
    self.context = context
  }

  public func enqueue(_ job: consuming ExecutorJob) {
    let unownedJob = UnownedJob(job)
    let unownedExecutor = asUnownedSerialExecutor()
    context.perform {
      unownedJob.runSynchronously(on: unownedExecutor)
    }
  }

  public func asUnownedSerialExecutor() -> UnownedSerialExecutor {
    UnownedSerialExecutor(ordinary: self)
  }
}

通过将此执行者应用于 Actor,我们能够确保该 Actor 内的所有操作(构造函数除外)均在其托管对象上下文对应的线程上执行。

构建 Actor

Actor 中引入自定义执行者非常直接,仅需声明一个 unownedExecutor 属性。编译器识别到 Actor 包含此属性后,将通过此执行者进行任务调度。

Swift
public nonisolated var unownedExecutor: UnownedSerialExecutor

借此,我们便能够实现一个类似于 SwiftData,用于处理 Core Data 并发操作的 Actor

Swift
actor DataHandler {
  public nonisolated let modelExecutor: CoreDataEvolution.NSModelObjectContextExecutor
  public nonisolated let modelContainer: CoreData.NSPersistentContainer

  public init(container: CoreData.NSPersistentContainer) {
    // 初始化私有上下文
    let context = container.newBackgroundContext()
    // 实例化自定义执行者
    modelExecutor = CoreDataEvolution.NSModelObjectContextExecutor(context: context)
    modelContainer = container
  }
  
  // 获取 Actor 所需的自定义执行者(UnownedSerialExecutor)
  public nonisolated var unownedExecutor: UnownedSerialExecutor {
    modelExecutor.asUnownedSerialExecutor()
  }

  // 用于 Actor 中数据操作的托管对象上下文
  public var modelContext: NSManagedObjectContext {
    modelExecutor.context
  }

  // 实现类似于 SwiftData 的托管对象访问机制
  public subscript<T>(id: NSManagedObjectID, as _: T.Type) -> T? where T: NSManagedObject {
    try? modelContext.existingObject(with: id) as? T
  }
}

实现 @NSModelActor 宏:简化 Core Data 并发操作

在 SwiftData 中,开发者仅需使用 @ModelActor 宏即可自动完成上文中的繁琐设置。为了提供相似的开发体验,我们引入了针对 Core Data 的 @NSModelActor 宏。

首先,我们对 Actor 的声明进行抽象化,引入 NSModelActor 协议:

Swift
public protocol NSModelActor: Actor {
  /// 为 NSModelActor 指定的 NSPersistentContainer
  nonisolated var modelContainer: NSPersistentContainer { get }

  /// 协调模型 actor 访问的执行者。
  nonisolated var modelExecutor: NSModelObjectContextExecutor { get }
}

extension NSModelActor {
  /// 模型 actor 的执行者的优化、非拥有引用。
  public nonisolated var unownedExecutor: UnownedSerialExecutor {
    modelExecutor.asUnownedSerialExecutor()
  }

  /// 序列化运行在模型 actor 上代码的上下文。
  public var modelContext: NSManagedObjectContext {
    modelExecutor.context
  }

  /// 根据指定的标识符返回模型,向下转型为适当的类。
  public subscript<T>(id: NSManagedObjectID, as _: T.Type) -> T? where T: NSManagedObject {
    try? modelContext.existingObject(with: id) as? T
  }
}

随后,声明 @NSModelActor 宏,该宏需符合 ExtensionMacroMemberMacro 协议:

Swift
@attached(member, names: named(modelExecutor), named(modelContainer), named(init))
@attached(extension, conformances: NSModelActor)
public macro NSModelActor() = #externalMacro(module: "CoreDataEvolutionMacrosPlugin", type: "NSModelActorMacro")

由于无需对原始代码进行任何特殊处理,因此宏的实现相对简单:

Swift
public enum NSModelActorMacro {}

extension NSModelActorMacro: ExtensionMacro {
  public static func expansion(of _: SwiftSyntax.AttributeSyntax, attachedTo _: some SwiftSyntax.DeclGroupSyntax, providingExtensionsOf type: some SwiftSyntax.TypeSyntaxProtocol, conformingTo _: [SwiftSyntax.TypeSyntax], in _: some SwiftSyntaxMacros.MacroExpansionContext) throws -> [SwiftSyntax.ExtensionDeclSyntax] {
    // 生成符合 NSModelActor 协议的扩展代码
    let decl: DeclSyntax =
      """
      extension \(type.trimmed): CoreDataEvolution.NSModelActor {}
      """

    guard let extensionDecl = decl.as(ExtensionDeclSyntax.self) else {
      return []
    }

    return [extensionDecl]
  }
}

extension NSModelActorMacro: MemberMacro {
  public static func expansion(of _: AttributeSyntax, providingMembersOf _: some DeclGroupSyntax, conformingTo _: [TypeSyntax], in _: some MacroExpansionContext) throws -> [DeclSyntax] {
    // 添加构造器及所需属性
    [
      """
      public nonisolated let modelExecutor: CoreDataEvolution.NSModelObjectContextExecutor
      public nonisolated let modelContainer: CoreData.NSPersistentContainer

      public init(container: CoreData.NSPersistentContainer) {
        let context = container.newBackgroundContext()
        modelExecutor = CoreDataEvolution.NSModelObjectContextExecutor(context: context)
        modelContainer = container
      }
      """,
    ]
  }
}

现在,开发者就可以在 Core Data 中享受与 SwiftData 一样的优雅、安全的并发操作了!

SerialExecutorExecutorJob 仅支持在 iOS 17、macOS 14 及以上系统使用,目前还不能在较低版本的系统中应用。希望苹果公司能够像之前对并发模型所做的那样,将这部分 API 兼容到更低版本的系统,让更多开发者能够受益。

为便于广大开发者使用,我已将上述代码整合至 CoreDataEvolution 库中,并期待将 SwiftData 中获得的灵感逐步在此库中实现,同时也欢迎更多开发者的参与。

结语

Let’s VisionOS 2024 活动中,我进行了题为 新框架、新思维:探索 Observation 与 SwiftData 的演讲,核心旨在于强调:虽然新框架旨在解决旧框架的问题,我们却不应受旧有经验和习惯的束缚。应以开放心态,从新角度学习和应用这些工具,把采纳新框架视为向更安全、更现代化转型的良机。

探索新框架的价值不仅在于应用它们的新 API,更重要的是,通过它们的设计理念启发我们优化传统框架的开发方式。因此,哪怕在长时间内你还不能使用 SwiftData、SwiftUI、Observation 等新框架,我依然鼓励开发者深入了解并学习它们,让这些新的设计理念丰富你的知识库。

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