Observation 框架的推出,让 SwiftUI 的状态响应从对象级进一步细化到属性级,显著缓解了许多由粗粒度观察带来的无效刷新问题。更重要的是,它让状态管理的声明式逻辑重新变得自然:视图只需要读取自己真正依赖的属性,框架便能据此建立响应关系。
遗憾的是,在苹果的持久化框架体系中,这一体验主要体现在 SwiftData 上。庞大且稳定的 Core Data 生态,虽然依然支撑着大量复杂应用,却没有获得同等级的原生集成。为了让这个老牌持久化框架也能享受到现代 Swift 特性的便利,我近期在 Core Data Evolution(CDE)中探索并实现了对 Observation 的支持,赋予 NSManagedObject 属性级的精确观察能力。
本文将围绕这一能力的动因、使用方式、实现思路、工程挑战以及开发过程中的一些取舍展开讨论。
Observation 改变的不只是性能
Observation 框架刚推出时,带给我的最大惊喜是它对 SwiftUI 性能的改善:属性级的精确响应,可以大幅减少许多不必要的视图计算。但随着不断深入使用,我逐渐意识到,相比性能,它带来的开发心智变化反而更加重要。
过去使用 ObservableObject 时,观察边界通常是对象级的。只要视图依赖了某个对象,就很容易被这个对象中无关属性的变化影响。开发者不仅要考虑数据本身的结构,还要额外思考如何拆分对象、如何拆分视图、如何放置 @ObservedObject 或 @StateObject,才能让刷新范围尽可能合理。
Observation 改变了这种关系。Observable 实例的属性具备“穿透式”的可观察能力。当视图在 Observation tracking 环境中读取某个属性时,真正被记录的是读取路径上发生的属性访问,而不是开发者手动声明的某个观察容器。
@Observable
class A {
var a = 10
var b = B()
}
@Observable
class B {
var b = 10
}
class Root {
let a = A()
var b: Int {
a.b.b
}
}
在这个例子中,Root.b 本身并不是一个由 Observation 改写的可观察属性。真正重要的是,当视图读取 root.b 时,计算属性求值过程中对 a.b.b 的访问会被 Observation 记录下来。这意味着开发者可以用更自然的方式组织状态和派生数据,而不必为了让观察生效,把数据结构改造成一组专门服务 UI 刷新的包装类型。
这正是 Observation 对 SwiftUI 最大的价值之一:它不只是让视图少刷新几次,而是让状态读取路径更接近业务语义本身。
Core Data 的表达缺口
这一点在 SwiftData 上体现得非常明显。作为以对象图管理见长的持久化框架,关系的表达与管理能力正是 SwiftData/Core Data 这类框架的核心竞争力。
假设存在一条关系链:
Note -> Item -> ItemData -> Memo
在展示 Memo.content 的视图中,我们希望同时使用 Note 的颜色和 Item 对应的图标。使用 SwiftData 时,代码可以非常直接:
HStack {
Image(systemName: memo.itemData.item.symbol)
Text(memo.content)
.foregroundStyle(memo.itemData.item.note.color)
}
这里的重点不仅在于语法简洁,更在于关系链上的属性读取仍然可以参与 Observation tracking。视图读了什么,框架就知道它依赖什么。开发者不需要为了保持响应能力而人为拆出一串中间视图。
但换成 Core Data,事情就会变得笨重得多。若想在一个视图中使用关系链上的数据,同时还要保有对这些数据变化的实时响应能力,通常需要为关系链上的每个 NSManagedObject 单独拆出一个 struct View,再用 @ObservedObject 承载。否则,某些中间对象的变化可能无法准确传递到最终视图。
这不仅让代码量陡增,也很容易遗漏。更重要的是,它迫使开发者按照观察机制来重塑视图结构,而不是按照业务语义来组织界面。
有鉴于此,我决定在 CDE 中补齐这一能力缺口:让 NSManagedObject 也能像 SwiftData 的 PersistentModel 一样,提供属性级的观察能力。而最重要的是,视图无需 @ObservedObject,便可直接响应所读取数据的变化。
能力边界:只解决 SwiftUI 最需要的那一段
在 深度解读 Observation 一文中,我介绍过 Observation 的基本机制。CDE 已经有 @PersistentModel 这一层模型声明宏,因此从技术上看,为 Core Data 模型补上 Observation 能力是可行的。
但“可行”并不意味着应该把它做成一个包罗万象的 Core Data 观察系统。
Core Data 的变化来源非常多,通知粒度也并不一致。viewContext 本地保存、background context merge、CloudKit / Persistent History Tracking 带来的 re-merge、batch update、refresh、rollback,这些路径能提供的信息并不相同。如果强行承诺所有变化来源都具备属性级响应,最终只会让 API 语义变得模糊,甚至让开发者误以为自己得到了一个并不存在的保证。
因此,这次实现只围绕 SwiftUI 中最核心的痛点展开:让视图可以直接读取 NSManagedObject 的属性和关系链,并在保存后获得尽可能精确的响应。
它的基本模型可以概括为一句话:读的时候建立订阅,保存成功后再发布变化。
这也决定了几个明确边界:
- 观察消费发生在 MainActor 上,主要服务 SwiftUI。
- 只有 CDE 能掌握变化字段的路径,才承诺属性级响应。
- 对于信息不足的变化来源,会按对象级刷新保守降级。
- 刷新发生在保存或合并之后,而不是 setter 当下。
- 该能力只覆盖
@PersistentModel宏生成的持久化属性和关系访问器。 - 该能力需要 Swift 6.2+ 编译器,并运行在支持 Observation 的系统版本上(例如 iOS 17+ / macOS 14+)。
这里的关键不是让 Core Data 变得“更活跃”,而是让它在 SwiftUI 需要响应的地方变得“更可表达”。后面的实现选择都围绕这个边界展开:在 Core Data 能明确提供变化信息的地方尽量精确,在信息不足的地方诚实降级。
使用方法
使用上,我希望它尽量像一个开关,而不是一套新的架构。
首先,在需要被 SwiftUI 直接读取的模型上开启 MainActor Observation:
@objc(Item)
@PersistentModel(observation: .mainActor)
final class Item: NSManagedObject {
var title: String = ""
var summary: String = ""
@Relationship(inverse: "items", deleteRule: .nullify)
var tag: Tag?
}
这样生成的属性和关系 getter 就具备了 Observation 登记能力。如果视图会继续读取关系目标上的属性,关系链上的模型类型也应同样开启 observation: .mainActor。随后,视图就可以直接持有并读取 Core Data 对象:
struct ItemRow: View {
let item: Item
var body: some View {
VStack(alignment: .leading) {
Text(item.title)
Text(item.tag?.name ?? "")
}
}
}
在这个例子中,视图读取了 item.title、item.tag 以及 tag.name。当这些由 CDE 生成访问器承载的属性在保存后发生变化时,CDE 会触发相应的 Observation 更新。开发者不需要为 Item 或 Tag 额外声明 @ObservedObject。
其次,需要在容器的生命周期内保留一个 CDEObservationDomain。它通常应该由 store、container holder 或应用级数据入口持有:
@MainActor
final class Store {
let container: NSPersistentContainer
let observation: CDEObservationDomain
init(container: NSPersistentContainer) {
self.container = container
observation = CDEObservationDomain(container: container)
}
}
如果变化来自 viewContext,正常保存即可:
item.title = "New Title"
try container.viewContext.save()
如果变化来自 @NSModelActor 这样的后台写入者,则推荐让 actor 使用支持 Observation 的初始化方式:
@NSModelActor
actor ItemWriter {
func rename(id: NSManagedObjectID, to title: String) async throws {
guard let item = self[id, as: Item.self] else { return }
item.title = title
try await saveObservedChanges()
}
}
let writer = ItemWriter(observationDomain: store.observation)
saveObservedChanges() 主要用于 update 操作。只有更新已有对象的属性时,CDE 才需要在保存前记录“哪些字段发生了变化”。insert / delete 不需要属性级元数据,仍然可以使用普通 Core Data 保存。
如果你使用的是普通 background context,也可以通过 observation.newObservedBackgroundContext() 创建一个已注册的 context。对于这类 context,更新已有对象并希望获得属性级响应时,应通过 observation.saveObservedChanges(in:) 保存;直接 save() 缺少这次 update 的 keyPath 快照,只能按较粗粒度路径处理。其他操作继续按普通 Core Data 流程保存即可。
核心原则只有一个:凡是希望获得属性级响应的后台 update,都应让 CDE 有机会在 save 前记录“改了哪些字段”。
更完整的示例和边界说明,可以参考项目文档:MainActor Observation Guide。
实现思路
有了前面的边界后,实现方向反而变得清楚:不要试图把 Core Data 改造成另一个 SwiftData,而是在 Core Data 已经擅长的生命周期里,补上 SwiftUI 最需要的那一段桥梁。
整个流程可以分成三个阶段。
第一阶段:在读取时建立 Observation 依赖
CDE 的 @PersistentModel 本来就会为属性和关系生成访问器。因此在开启 observation: .mainActor 后,宏会在这些 getter 中加入 Observation 的读取登记。
当 SwiftUI 在 body 中读取:
Text(item.title)
或者沿着关系链读取:
Text(memo.itemData.item.note.color.description)
这些属性访问都会被 Observation tracking 记录下来。开发者不需要为了“让观察生效”而拆分视图,也不需要把每个 NSManagedObject 包成 @ObservedObject。
这一步完成的是读取登记:CDE 生成的访问器把属性访问交给 Observation,后续依赖关系由 SwiftUI / Observation tracking 维护。
第二阶段:在保存时生成变化快照
读取登记交给 Observation 之后,CDE 还需要在 Core Data 的保存路径上生成一份临时的变化快照,用来描述本次事务中有哪些变化可以被发布给 Observation。
这份快照不是新的持久化状态,也不是依赖追踪表,而是一个用于连接 Core Data 与 Observation 的中间结果,主要包含:
- 发生变化的对象 ID;
- Core Data 层面的变更字段;
- 这些字段到 CDE 生成访问器 keyPath 的映射关系。
对于 viewContext 的本地保存,变化快照可以在保存流程内生成并立即消费;对于后台 update,则需要由相应的保存包装提前生成,之后再随 viewContext merge 进入 Domain 的路由流程。
这样,后续发布 Observation 变化时,CDE 就不需要在 merge 之后再反向推测“这次到底改了什么”。
这一步解决的是:把 Core Data 的修改整理成可供 Observation 发布的变化描述。
第三阶段:在 merge 后路由到 Observation
前两个阶段已经分别完成读取登记与变化快照生成;这一阶段只负责在保存或合并之后,将 Core Data 已确认的变化转译成 Observation 可以识别的属性变更。
CDEObservationDomain 绑定到一个 NSPersistentContainer 的 viewContext。它不替代 Core Data 的通知系统,也不维护 SwiftUI 的读取依赖表;最终哪些视图刷新,仍由 Observation tracking 根据之前的读取登记决定。
Domain 关心的是变化信息的完整度:本次变化能否定位到对象,能否进一步定位到字段,以及这些字段能否映射回 CDE 生成访问器的 keyPath。
可以把不同变化来源的处理方式概括成下面这张表:
| 变化来源 | Domain 能获得的信息 | 处理方式 | 响应精度 |
|---|---|---|---|
viewContext 本地保存 | 对象 ID + 变更字段 | 保存成功后直接通知对应 keyPath | 属性级 |
@NSModelActor.saveObservedChanges() 或 observation.saveObservedChanges(in:) | 保存前暂存的对象 ID + 变更字段 | viewContext merge 时由 Domain 消费并路由 | 属性级 |
| 未注册 context、CloudKit 外部导入等 merge | 通常只有对象 ID | 在 viewContext 中定位对应实例后,刷新该对象的全部可观察属性 | 对象级 |
| batch update / delete、refresh、rollback 等生命周期变化 | 可能只有对象 ID,甚至没有对象 ID | 有对象 ID 时保守刷新;没有对象 ID 时不承诺实例级响应 | 对象级或无承诺 |
| 本地保存的 CloudKit / Persistent History Tracking re-merge 回声 | 可能是同一次保存稍后再次 merge 回来 | 过滤可确认的回声,避免精确刷新被放大成全属性刷新 | 保留原精度 |
这个设计刻意把 Observation 发布集中在 MainActor 上。Background context 并不直接发布 Observation 变化;它只有在走 saveObservedChanges 这类保存包装时,才会在保存前提供字段级元数据。真正的消费和 UI 响应仍然发生在 viewContext merge 之后。
实现难点
真正困难的地方,并不是在 getter 中插入一段 Observation 登记代码,而是如何把 Core Data 的变化信号整理成 SwiftUI 可以信任的刷新信号。
难点一:变化快照的生成时机必须早于 merge 消费
从概念上看,变化快照只是对象 ID、变更字段与 keyPath 映射的组合;真正困难的是时序。
Core Data 的 changedValues 并不是一个可以随时读取的长期账本。对象一旦完成保存、merge、refresh 或 fault 状态变化,字段级信息就可能不再完整。因此,CDE 不能等到“需要发布 Observation 变化时”才去询问对象到底改了什么,而必须在信息仍然可靠的窗口内提前记录。
对于 viewContext 本地保存,这个问题相对可控:变化捕获、保存结果确认和 Observation 发布都发生在同一条主上下文路径上,顺序比较明确。
真正麻烦的是 background context。后台 context 保存时,字段信息存在于后台写入路径中;而 UI 侧真正需要响应的时机,则通常发生在 viewContext merge 之后。这意味着 CDE 必须在两条路径之间传递一份变化快照,并保证它先于主上下文的 merge 消费抵达 Domain。
如果这里依赖一次不受控制的异步跳转,顺序就可能被打乱:viewContext 已经完成 merge,但字段级元数据还没有准备好。此时 Domain 只能看到对象级变化,属性级响应就会退化成对象级刷新,甚至在某些边界情况下出现遗漏。
因此,这里的关键并不是“能不能读到 changedValues”,而是能否在正确时机捕获、传递并消费字段级元数据:
- 字段信息在保存前后的可靠窗口内被捕获;
- background context 产生的变化快照能被安全交给 Domain;
viewContextmerge 时,Domain 已经能够拿到对应的字段级元数据;- 如果顺序无法保证,就必须诚实降级,而不是假装还能提供属性级响应。
这也是 CDE 在后台写入路径中引入 saveObservedChanges() / saveObservedChanges(in:) 的原因:它们并不是为了改变 Core Data 的保存流程,而是为了给 CDE 一个明确的时机,在字段信息消失之前生成变化快照。
难点二:通知回声会稀释属性级观察
Core Data 的通知并不总是“一次业务变化对应一次通知”。
在启用 Persistent History Tracking、CloudKit 或某些 parent/child context 流程后,本地一次精确保存可能稍后又以 merge 或 refresh 的形式回到 viewContext。如果不加处理,CDE 已经对 title 做过精确刷新,随后又收到同一个对象的 objectID-only 通知,就会退化成“所有可观察属性都刷新”。
结果就是:视图明明只读取了 summary,却也会被 title 的变化唤醒。属性级观察的意义会被这些回声逐渐稀释。
但噪音过滤也不能过度。之前 issue 中暴露过一个很有代表性的风险:如果把“这个 objectID 刚刚精确处理过”理解成一个过宽的抑制条件,就可能吞掉后续真正合法的全属性 fallback。
换句话说,过滤噪音不是简单地忽略重复 objectID,而是要判断它是不是同一轮通知、同一个保存周期或同一次 re-merge 回声。最终的方向是让 suppression 尽量短命、尽量局部,只过滤可确认的回声,不把未来的真实变化也一并吃掉。
难点三:关系链不能退化成对象图扫描
SwiftUI 可以读取:
memo.itemData.item.note.color
但 Core Data 通知里出现的往往只是某个对象、某个持久化字段的变化。CDE 需要把 Core Data 字段重新映射回 Swift 属性路径,还要处理关系属性、to-many 的 count 派生属性、composition 这类由宏生成的结构。
这里最容易走向失控的方向,是试图推断所有潜在关系路径。
例如,当某个 Note.color 改变时,理论上可能有很多 Memo 视图间接依赖了它。但如果 CDE 试图扫描整个对象图、推断所有潜在关系路径,就会很快变成一个全局关系依赖系统。那样不仅成本不可控,语义也会越来越难解释。
因此,CDE 的原则是有界 fan-out:只围绕受影响对象和已知映射做路由,不扫描整个 context,不遍历完整关系图,也不试图推断所有潜在依赖路径。它要解决的是 SwiftUI 关系链读取的响应问题,而不是在 Core Data 之上构建一套新的对象图响应系统。
难点四:降级策略必须诚实
属性级响应的前提,是确实知道哪个字段发生了变化。
如果 CDE 只能拿到 objectID,就不能把它包装成属性级响应;如果连 objectID 都无法确认,也不能假装自己知道哪个实例应该刷新。因此,这里的难点不在于列出几个降级层级,而在于实现中始终坚持这些层级的语义:objectID-only 的变化不能被伪装成 keyPath 变化;缺少实例信息的生命周期事件不能被包装成精确刷新;保守 fallback 也应尽量限制在可以确认的对象范围内。
这个策略看起来保守,但它让开发者知道自己得到的到底是哪一种保证。对一个观察系统来说,过度承诺比保守降级更危险。前者会让 bug 难以复现,后者至少让边界可以被理解和推理。
从这些难点可以看出,这项能力的核心并不是“让 Core Data 变得更活跃”,而是“让它更克制”。只有把保存时机、通知回声和降级边界处理清楚,SwiftUI 才能真正享受到 Observation 带来的开发体验改善。
成本:为什么不会随规模恶化
性能不是首要诉求,但放手的前提是成本不会随规模反噬。CDE 在实现上刻意回避了所有可能膨胀的路径:
- getter 只增加一次 Observation 登记和轻量的 Domain 关联;
- setter 不做即时发布;
- merge 路径只围绕本次涉及的 objectID 路由;
- 不扫描整个 context,不遍历完整关系图,也不回溯历史变化。
因此路由成本仅取决于“本次事务实际改了多少”,而不是已注册对象数、关系图大小或积压的历史变更。规模增长不会在这些维度上把成本推向线性乃至更陡的曲线。
最坏情况下,CDE 会退回到对象级的全属性失效——这正对应传统 ObservableObject 本就具备的响应上限,不会比今天手写观察更差。需要守住的底线只有一条:不让任何一条路由路径随已注册对象数、关系图规模或历史 pending 信息线性膨胀。只要这条底线在,属性级观察就始终是一种心智减负,而不会在某个规模上悄然变成新的系统负担。
Evolution 与期待
回顾 CDE 的迭代脉络,从 @NSModelActor 到 @PersistentModel,从 TypedPath 到如今的 MainActor Observation,这些功能无一不映射着 SwiftData 带来的现代开发范式。
这并非为了模仿而模仿。反复实践后,一个很难回避的结论是:SwiftData 确实高度契合 Swift 6 与 SwiftUI 的设计直觉。它让持久化模型、并发隔离、状态观察和声明式 UI 之间形成了更自然的连接。
然而,由于性能瓶颈与部分高阶功能的缺失,许多开发者现阶段依然必须依赖 Core Data 来支撑复杂业务。Core Data 成熟、稳定、可控,也拥有丰富的底层能力;但它诞生于另一个时代,在 SwiftUI 和 Observation 的语境中,难免显得有些沉重。
CDE 的目标并不是让 Core Data 伪装成 SwiftData,而是在 SwiftData 尚未完全覆盖复杂 Core Data 场景之前,为现有项目提供一座更符合现代 SwiftUI 心智模型的桥梁。
我由衷期待 SwiftData 能够继续加速成长,在保持优秀开发体验的同时,提供不亚于 Core Data 的底层稳定性和高效性。当那一刻到来时,开发者或许就能真正放下许多历史包袱,全身心地拥抱新的持久化生态。
但在那之前,让 Core Data 以更自然、更克制、也更现代的方式融入 Observation,仍然是一件很有现实意义的事。