在 上一篇文章 中,我聊了聊 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 相近的方式来声明模型的源码表现层。
一个典型的模型声明如下:
import CoreDataEvolution
@objc(Item)
@PersistentModel
final class Item: NSManagedObject {
var title: String = ""
var timestamp: Date?
var height: Double?
}
@PersistentModel 会为这些属性自动补上持久化访问逻辑。比如 height 在宏展开后,大致会变成这样:
{
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 还特别强化了对复杂类型的支持,包括:
RawRepresentableCodableValueTransformercomposition
和 SwiftData 相比,我更希望开发者能够明确知道一个值是如何被持久化的,而不是依赖某种较黑盒的存储推断。因此,CDE 用 @Attribute(storageMethod:) 让存储策略在源码中显式可见。
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 也提供了明确支持:
@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
典型写法如下:
@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,强制开发者通过更明确的方式修改关系:
// 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 在这里提供的是一种兼顾灵活性与类型安全的做法。
@Attribute(persistentName: "name")
var title: String = ""
这表示:
- Swift 属性名是
title - 底层持久化字段名仍然是
name
在构建谓词和排序条件时,开发者可以继续使用 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。
你也可以直接使用类型安全的辅助方法:
let predicate1 = Item.path.title.contains("Core Data")
let predicate2 = Item.path.score.greaterThan(80)
let predicate3 = Item.path.createdAt.lessThanOrEqual(Date())
它的价值并不仅限于普通属性,同样适用于 relationship 和 composition:
@Relationship(persistentName: "books", inverse: "owner", deleteRule: .nullify)
var items: Set<Item>
上面的 items 会映射到原来的对多关系 books。
@Composition
struct Location {
@CompositionField(persistentName: "lat")
var latitude: Double = 0
@CompositionField(persistentName: "lng")
var longitude: Double? = nil
}
这样,下面的表达依然可以成立:
let predicate = Item.path.location.latitude.greaterThan(80)
除此之外,RawRepresentable 和对多关系也都提供了相应的类型安全构建方式:
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 非常接近:
@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 中添加自己的存储属性和上下文配置逻辑:
@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 相同的心智模型去构建主线程上的数据逻辑:
@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。
@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 方法,允许你在隔离域中对上下文进行检查和断言:
@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。
你可以这样构建运行时模型和测试容器:
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 的运行状态。
为此,我也分别做了另外两个配套工具:PersistentHistoryTrackingKit 和 iCloudSyncStatusKit。
其中,PersistentHistoryTrackingKit 主要围绕 Persistent History Tracking 展开。它不仅可以处理 transaction 的合并与清理,在 2.0 版本中还通过 Hook 机制进一步把“历史变更”变成一个可以接入业务逻辑的统一入口,让开发者在同步、多 target 协作或后台处理等场景下,用更加一致的方式组织数据操作。
而 iCloudSyncStatusKit 关注的则是另一个常被忽视的问题:当项目接入 CloudKit 之后,开发者往往还需要知道当前 iCloud 账号、网络环境、同步事件和整体可用性到底处于什么状态。只有这些信息变得可观察、可表达,云同步能力才真正具备工程上的可维护性。
如果说 CDE 回答的是“如何更现代地编写 Core Data”,那么 PersistentHistoryTrackingKit 和 iCloudSyncStatusKit 补上的,就是“如何在云同步时代更稳地运行 Core Data”。它们和 CDE 并不是彼此替代的关系,而是从不同层面共同构成了一套更完整的现代 Core Data 工程栈。
CDE 给我的启示
Core Data Evolution 最初只是为了解决我自己在使用 Core Data 时反复遇到的一些问题。
但在思考、设计和实现它的过程中,我越来越强烈地感受到一件事:Swift 过去几年给出的那些“现代特性”,并不只是为新框架准备的。
宏、现代并发模型、工具链能力,再加上 AI 对测试和样板代码工作的辅助,实际上已经足以让很多旧框架、旧实现重新获得一种更符合今天开发环境的使用方式。
CDE 就是这样一个实验。对我来说,它证明了一点:Core Data 的问题未必只能通过“离开 Core Data”来解决——有时,重新设计它的表达层、隔离层和工作流,就已经足够了。
现代化不一定意味着推倒重来。 让一个成熟框架在新的编程环境中重新找到适合自己的表达方式,同样是一条值得走的路。