CDE:一次让 Core Data 更像现代 Swift 的尝试

上一篇文章 中,我聊了聊 Core Data 在当下项目中的一些现实处境:它并没有消失,也仍然有其独特价值,但它和现代 Swift 项目之间的错位感却越来越明显。在本文中,我想继续顺着这个问题往下走,介绍我的一个实验性项目:Core Data Evolution(CDE)。

它不是一个取代 Core Data 的新框架,也不是要把开发者重新拉回旧技术。更准确地说,它是我面对这些错位时,给自己的一种回答:如果我仍然认可 Core Data 的对象图模型、迁移体系和成熟运行时能力,那么能不能让它在现代 Swift 项目中以一种更自然的方式继续存在下去?

CDE 适合谁

从项目开始构思的第一天起,CDE 的目标就很明确:它不是为“第一次接触持久化”的开发者准备的,也不是为了降低 Core Data 的学习门槛而诞生的。

它更适合这样一类开发者和团队:

  • 已经在使用 Core Data
  • 仍然认可 Core Data 的对象图建模方式
  • 仍然需要它成熟的迁移、存储和运行时能力
  • 但又明显感受到传统 Core Data 写法和现代 Swift 项目之间的不协调

换句话说,CDE 更像是一个改善 Core Data 开发体验的工具库,而不是一个“让你重新学会 Core Data”的框架。

它主要试图解决以下几个问题:

  • NSManagedObject 子类提供更现代的模型声明方式,让模型源码在 Swift 层更自然
  • 在灵活性、准确性和类型安全之间取得更好的平衡
  • 提供类似 SwiftData 的 actor 隔离写法,降低 Core Data 并发代码的心智负担
  • 让单元测试中的数据环境构建和断言过程更安全、更轻松
  • 通过工具链减少模型声明代码与 .xcdatamodeld 之间的漂移

需要特别说明的是:使用 CDE 的项目在生产环境中仍然以 xcdatamodeld 作为唯一的模型源。

这一点不是妥协,而是项目设计的前提。

一些前置思考

在介绍 CDE 的能力之前,我想先解释几个它背后的设计判断。

为什么仍然保留模型文件

第一次接触 SwiftData 时,最让我震撼的部分就是它基于宏的建模方式:模型声明像普通 Swift 类型一样自然,宏负责补上持久化实现细节;同时 container 也可以直接基于宏生成的 schema 来构建模型,而不需要外部的 xcdatamodeld 文件。

这种体验非常现代,也非常漂亮。

但随着项目不断发展,我越来越清楚地感受到:一旦应用上线,模型演进就不再只是“写起来优不优雅”的问题了。即便每次修改都满足轻量迁移要求,为了更清楚地跟踪变化,你仍然不得不维护一套版本化的模型代码。typealias 确实能缓和一部分噪音,但模型版本越多,这种噪音就越难被忽略。

相比之下,外置模型文件虽然没有那么“酷”,也有它自己的不便,但它有一个很现实的优点:项目里始终只需要存在一套当前的模型源码表达。

而且,几乎所有现有 Core Data 项目本就依赖外部模型文件。不主动破坏这条既有路径,也能显著降低 CDE 的使用门槛。

因此,CDE 做出的选择是:

  • 继续保留 .xcdatamodeld 作为生产环境中的真实模型源
  • 使用 Swift Macros 和工具链,为它补上一层现代 Swift 风格的源码表达层

是否需要保留 Core Data 的全部能力

CDE 不会阻止开发者使用 Core Data 的全部能力,但在设计宏和 CLI 工具时,我有意没有去支持某些特性。

原因并不复杂:这些特性要么不利于云同步,要么会增加未来迁移到 SwiftData 或其他现代方案时的阻力。

比如,在当前版本里,CDE 会对源码声明和工具链施加一些明确约束:

  • persisted attribute 必须是 Optional,或者提供默认值
  • relationship 必须是 Optional
  • 每个 relationship 都必须有 inverse
  • deleteRule 不支持 .noAction
  • 不支持 Derived Attribute
  • 不支持实体继承

这些限制不是为了“削弱” Core Data,而是为了让使用 CDE 的项目从一开始就更贴近一种长期更容易维护的模型约定。

开发者当然仍然可以在模型层绕过这些约束,但除了 Derived Attribute 之外,很多调整本身都符合轻量迁移的常见要求,并不会对旧项目造成实质性破坏。

系统版本要求

我一直希望尽可能降低 CDE 的系统门槛,但由于部分系统 API 和语言能力的限制,目前它的最低支持版本是:

  • iOS 13+
  • macOS 10.15+
  • watchOS 6+
  • tvOS 13+
  • visionOS 1+

这已经能够覆盖不少现代 Swift 项目。

不过需要补充说明的是,composition 依赖 Core Data 的 composite attribute 能力,因此这一部分需要更高系统版本支持:

  • iOS 17+
  • macOS 14+
  • watchOS 10+
  • tvOS 17+
  • visionOS 1+

模型声明

@PersistentModel

CDE 采用了与 SwiftData 相近的方式来声明模型的源码表现层。

一个典型的模型声明如下:

Swift
import CoreDataEvolution

@objc(Item)
@PersistentModel
final class Item: NSManagedObject {
  var title: String = ""
  var timestamp: Date?
  var height: Double?
}

@PersistentModel 会为这些属性自动补上持久化访问逻辑。比如 height 在宏展开后,大致会变成这样:

Swift
{
    get {
      guard let number = value(forKey: "height") as? NSNumber else {
          return nil
      }
      return number.doubleValue
    }
    set {
      if let newValue {
        setValue(NSNumber(value: newValue), forKey: "height")
      } else {
        setValue(nil, forKey: "height")
      }
    }
}

它的核心价值不在于“替你省几行代码”,而在于:

  • NSManagedObject 的声明更贴近今天的 Swift 语义
  • 把很多重复的桥接样板收回到宏生成层
  • 让模型源码变成一个更可读、也更容易被工具处理的表述层

Storage Method

除了基础类型,CDE 还特别强化了对复杂类型的支持,包括:

  • RawRepresentable
  • Codable
  • ValueTransformer
  • composition

和 SwiftData 相比,我更希望开发者能够明确知道一个值是如何被持久化的,而不是依赖某种较黑盒的存储推断。因此,CDE 用 @Attribute(storageMethod:) 让存储策略在源码中显式可见。

Swift
enum Status: String {
  case draft
  case published
}

@Attribute(storageMethod: .raw)
var status: Status? = .draft

struct ItemConfig: Codable, Equatable {
  var retryCount: Int = 0
}

@Attribute(storageMethod: .codable)
var config: ItemConfig? = nil

@Attribute(storageMethod: .transformed(name: "CDEStringListTransformer"))
var keywords: [String]? = nil

对于 iOS 17 开始支持的 composite attribute,CDE 也提供了明确支持:

Swift
@Composition
struct GeoPoint {
  var latitude: Double = 0
  var longitude: Double = 0
}

@Attribute(storageMethod: .composition)
var location: GeoPoint? = nil

这部分的价值在于,它让“Swift 层更丰富的类型表达”和“Core Data 真实持久化方式”之间的关系变得清楚而显式,而不是散落在一堆手工桥接代码里。

关于 Storage Method 的详细能力,请参阅 Storage Method Guide

关系

CDE 要求开发者在模型声明中显式写出:

  • inverse
  • delete rule
  • 可选的 relationship persistent name

典型写法如下:

Swift
@objc(Item)
@PersistentModel
final class Item: NSManagedObject {
  @Relationship(inverse: "items", deleteRule: .nullify)
  var tag: Tag?

  @Relationship(inverse: "owner", deleteRule: .nullify)
  var tags: Set<Tag>

  @Relationship(inverse: "orderedOwner", deleteRule: .nullify)
  var orderedTags: [Tag]
}

这里有几个需要注意的点:

  • to-one relationship 必须显式使用 Optional
  • to-many relationship 使用 Set<T>[T],而不是 Set<T>? / [T]? (尽管模型层要求其为 Optional)
  • to-many relationship 不允许声明默认值
  • inverse 指向的是 Core Data 模型里的持久化关系名,而不是对端 Swift 属性名

对于 to-many relationship,CDE 不会生成 setter,而是生成一组 helper API,强制开发者通过更明确的方式修改关系:

Swift
// Unordered to-many
func addToTags(_ value: Tag)
func removeFromTags(_ value: Tag)
func addToTags(_ values: Set<Tag>)
func removeFromTags(_ values: Set<Tag>)

// Ordered to-many
func addToOrderedTags(_ value: Tag)
func removeFromOrderedTags(_ value: Tag)
func addToOrderedTags(_ values: [Tag])
func removeFromOrderedTags(_ values: [Tag])
func insertIntoOrderedTags(_ value: Tag, at index: Int)

这不是单纯的风格约束,而是希望把关系修改从“任意赋值”收敛到“更明确的操作入口”上。

更多模型声明细节,请阅读 PersistentModel Guide

TypedPath

如果说 @PersistentModel 解决的是“模型声明层”的现代化,那么 TypedPath 解决的就是另一个在长期项目里尤其现实的问题:底层模型名不方便改,但表现层命名又必须继续进化。

在 Core Data 项目里,一旦应用上线,尤其是启用了云同步后,开发者几乎没有空间去随意改动底层字段名。但业务语义却会持续演进,一个早年还算合理的属性名,几年后完全可能已经不适合当前项目。

CDE 在这里提供的是一种兼顾灵活性与类型安全的做法。

Swift
@Attribute(persistentName: "name")
var title: String = ""

这表示:

  • Swift 属性名是 title
  • 底层持久化字段名仍然是 name

在构建谓词和排序条件时,开发者可以继续使用 Swift 层更自然的命名:

Swift
let predicate = NSPredicate(
  format: "%K == %@",
  Item.path.title.raw,
  "hello"
)

let sort = try NSSortDescriptor(
  Item.self,
  path: Item.path.title,
  order: .asc
)

TypedPath 会自动把 Item.path.title 映射到底层的 name

你也可以直接使用类型安全的辅助方法:

Swift
let predicate1 = Item.path.title.contains("Core Data")
let predicate2 = Item.path.score.greaterThan(80)
let predicate3 = Item.path.createdAt.lessThanOrEqual(Date())

它的价值并不仅限于普通属性,同样适用于 relationship 和 composition:

Swift
@Relationship(persistentName: "books", inverse: "owner", deleteRule: .nullify)
var items: Set<Item>

上面的 items 会映射到原来的对多关系 books

Swift
@Composition
struct Location {
  @CompositionField(persistentName: "lat")
  var latitude: Double = 0

  @CompositionField(persistentName: "lng")
  var longitude: Double? = nil
}

这样,下面的表达依然可以成立:

Swift
let predicate = Item.path.location.latitude.greaterThan(80)

除此之外,RawRepresentable 和对多关系也都提供了相应的类型安全构建方式:

Swift
let predicate = Item.path.status.equals(Status.published)

let anySwiftTag = Item.path.tags.any.name.equals("Swift")
let allHighScore = Item.path.tags.all.score.greaterThan(80)
let noLegacyTag = Item.path.tags.none.name.contains("legacy")

从整个项目角度看,TypedPath 很重要,因为它恰好站在灵活性和类型安全之间:

  • 不强迫你去改底层模型
  • 允许 Swift 层命名持续演进
  • 又尽量把运行时字符串错误提前处理成更可验证的源码结构

值得一提的是,TypedPath 的路径映射完全基于宏在编译期生成的静态元数据,在构建谓词时只做 O(1) 的查表,不依赖任何 Runtime 反射,因此不会对 Core Data 的查询性能造成额外损耗。

想了解 TypedPath 的更多细节,请参阅 Typed Path Guide

并发

并发其实是 CDE 的起点。

最初我开始做这个项目,就是因为我想给 Core Data 带来一种更接近 SwiftData @ModelActor 的并发体验。

在这方面,CDE 的使用方式与 SwiftData 非常接近:

Swift
@NSModelActor
actor ItemStore {
  func createItem(timestamp: Date) throws -> NSManagedObjectID {
    let item = Item(context: modelContext)
    item.timestamp = timestamp
    try modelContext.save()
    return item.objectID
  }
}

这带来的最大变化不是“写法看起来更像 SwiftData”,而是:

  • 并发边界更清楚
  • 代码不再被大量 perform 闭包包裹
  • 隔离域更容易推理和验证

同时,CDE 也在此基础上提供了一些更贴近实际项目的能力。

例如,当你把 disableGenerateInit 设为 true 后,宏不会自动生成构造器,开发者就可以在 actor 中添加自己的存储属性和上下文配置逻辑:

Swift
@NSModelActor(disableGenerateInit: true)
actor ItemStore {
  let viewName: String

  init(container: NSPersistentContainer, viewName: String) {
    modelContainer = container
    self.viewName = viewName

    let context = container.newBackgroundContext()
    context.name = viewName
    modelExecutor = .init(context: context)
  }
}

@NSMainModelActor 则固定使用 container 的 viewContext,便于你用和 @NSModelActor 相同的心智模型去构建主线程上的数据逻辑:

Swift
@MainActor
@NSMainModelActor
final class ItemViewModel {
  func fetchItems() throws -> [Item] {
    // ...
  }

  var itemsCount: Int {
    (try? fetchItems().count) ?? 0
  }
}

更多并发相关内容,请阅读 NSModelActor Guide。了解 NSModelActor 的实现原理,请阅读 实现 SwiftData 般的优雅并发操作

测试

在为 Core Data 项目写测试时,我一直反复遇到几个问题:

  • 很难为每个测试单元构建可预期的数据环境
  • 一旦把数据操作封装进 actor,断言就变得不再顺手
  • 在 VSCode、Cursor 或 AI Agent 这类非 Xcode 场景里,如何尽量摆脱对外部模型文件的测试依赖

CDE 在测试上的尝试,基本就是围绕这几个痛点展开的。

构建数据环境

NSPersistentContainer.makeTest 默认会根据当前文件(#fileID)和当前方法名(#function),在给定目录下为测试创建一个独立的 SQLite store。

Swift
@Test
func itemFetchTest() async throws {
  let container = try NSPersistentContainer.makeTest(model: dataModel)
  let dataHandler = DataHandler(container: container)
  try await dataHandler.getItems()
}

这种方式相较于 /dev/null 式的内存 store,更适合 Swift Testing 默认的并发执行模型,也更接近真实 SQLite 环境。

同时,如果一个测试方法里需要创建多个 container,也可以通过显式传入不同的 testName 来避免冲突。

断言辅助

在使用 @NSModelActor 之后,数据操作都被隔离起来了,测试里的断言往往会变得别扭。

为了解决这个问题,CDE 为 actor 自动提供了 withContext 方法,允许你在隔离域中对上下文进行检查和断言:

Swift
@Test("withContext - fetch items after creation")
func fetchItemsAfterCreation() async throws {
  let stack = try TestStack()
  let handler = DataHandler(container: stack.container, viewName: "withContext-fetch")

  _ = try await handler.createNemItem(Date())
  _ = try await handler.createNemItem(Date())

  let count = try await handler.withContext { context in
    let request = Item.fetchRequest()
    return try context.fetch(request).count
  }

  #expect(count == 2)
}

这让“通过 actor 做真实操作,再在隔离域中做断言”变成了一种很自然的测试模式。

运行时 Schema

虽然 CDE 在生产环境里仍以外部模型文件为唯一途径,但为了方便测试和非 Xcode 场景下的开发,@PersistentModel 也会生成运行时 schema。

你可以这样构建运行时模型和测试容器:

Swift
let model = try NSManagedObjectModel.makeRuntimeModel(Item.self, Tag.self)

let container = try NSPersistentContainer.makeRuntimeTest(
  modelTypes: Item.self, Tag.self
)

这条路径是明确面向 test/debug 的,因此不会试图完整覆盖外部模型文件中的所有 Core Data 特征,例如索引、某些存储细节等。

这不是能力缺失,而是有意的边界控制:它的目标是让测试与调试更方便,而不是替代生产模型系统。

cde-tool

Xcode 的模型编辑器本就可以生成一套表现层代码。CDE 的 cde-tool 则试图在宏时代做一件类似的事,只不过目标不是简单代码生成,而是:

  • 生成
  • 校验
  • 对齐

cde-tool 主要承担几件事:

  • 基于模型文件生成配置文件
  • 基于模型文件和配置文件生成 @PersistentModel 风格的源码
  • 校验手工创建或修改后的源码声明是否仍然与模型文件一致

也就是说,它不是 CDE 的核心能力来源,而是一个帮助你在工程层长期维持一致性的配套工具。

你完全可以不使用它,直接手工声明模型并使用 CDE 的宏能力;但如果项目变大、模型增多,或者你希望把“模型 - 源码 - 生成层”之间的关系长期维持在一个稳定状态下,那么这个工具就会有帮助。

了解更多,请阅读 cde-tool Guide

围绕 Core Data 的其他现代化拼图

不过,CDE 并不试图成为一个“大一统”的 Core Data 框架。它主要解决的是源码表达、并发隔离和工程工作流上的现代化问题,而在云同步时代,Core Data 项目往往还会遇到另外两类非常现实的需求:如何处理持续变化的事务历史,以及如何感知 iCloud / CloudKit 的运行状态。

为此,我也分别做了另外两个配套工具:PersistentHistoryTrackingKitiCloudSyncStatusKit

其中,PersistentHistoryTrackingKit 主要围绕 Persistent History Tracking 展开。它不仅可以处理 transaction 的合并与清理,在 2.0 版本中还通过 Hook 机制进一步把“历史变更”变成一个可以接入业务逻辑的统一入口,让开发者在同步、多 target 协作或后台处理等场景下,用更加一致的方式组织数据操作。

iCloudSyncStatusKit 关注的则是另一个常被忽视的问题:当项目接入 CloudKit 之后,开发者往往还需要知道当前 iCloud 账号、网络环境、同步事件和整体可用性到底处于什么状态。只有这些信息变得可观察、可表达,云同步能力才真正具备工程上的可维护性。

如果说 CDE 回答的是“如何更现代地编写 Core Data”,那么 PersistentHistoryTrackingKitiCloudSyncStatusKit 补上的,就是“如何在云同步时代更稳地运行 Core Data”。它们和 CDE 并不是彼此替代的关系,而是从不同层面共同构成了一套更完整的现代 Core Data 工程栈。

CDE 给我的启示

Core Data Evolution 最初只是为了解决我自己在使用 Core Data 时反复遇到的一些问题。

但在思考、设计和实现它的过程中,我越来越强烈地感受到一件事:Swift 过去几年给出的那些“现代特性”,并不只是为新框架准备的。

宏、现代并发模型、工具链能力,再加上 AI 对测试和样板代码工作的辅助,实际上已经足以让很多旧框架、旧实现重新获得一种更符合今天开发环境的使用方式。

CDE 就是这样一个实验。对我来说,它证明了一点:Core Data 的问题未必只能通过“离开 Core Data”来解决——有时,重新设计它的表达层、隔离层和工作流,就已经足够了。

现代化不一定意味着推倒重来。 让一个成熟框架在新的编程环境中重新找到适合自己的表达方式,同样是一条值得走的路。

订阅 Fatbobman 周报

每周精选 Swift 与 SwiftUI 开发技巧,加入众多开发者的行列。

立即订阅