@Observable 内部机制:宏、注册器以及 ObservableObject 的设计失误
Observation 框架在 iOS 17 和 Swift 5.9 中引入,以基于宏、按属性访问追踪的系统取代了基于 Combine 的 ObservableObject 模型1。在调用点上看变化不大(只需一个 @Observable 宏,无需 : ObservableObject 加上到处都是的 @Published),但运行时行为的差异会影响性能、正确性和迁移路径。一句话概括这一转变:未读取已变更属性的视图,在该属性变更时不再重新求值。
本文将该框架的内部机制与 Apple 文档及 SE-0395 提案对照解读2。讨论的视角是”宏到底生成了什么、为什么这么生成”,因为大多数团队采用 @Observable 只是为了语法更简洁,却忽视了更新传播机制的结构性变化——而真正的性能收益(以及迁移陷阱)恰恰隐藏其中。
TL;DR
@Observable是一个 Swift 宏,它将类展开为遵循Observable标记协议的类型,并合成一个_$observationRegistrar: ObservationRegistrar实例作为存储属性3。- 每个属性的 getter 包装
_$observationRegistrar.access(self, keyPath:),setter 包装_$observationRegistrar.withMutation(of:keyPath:_:)。注册器追踪哪个作用域访问了哪些 key path。 - 替换词汇表如下:
class Foo: ObservableObject变为@Observable class Foo;@Published var name变为var name;@StateObject var foo = Foo()变为@State var foo = Foo();@EnvironmentObject变为@Environment(Foo.self);@ObservedObject var foo直接使用该属性即可。 @Bindable是新的属性包装器,用于为可观察实例的属性创建绑定(在绑定场景下取代了部分@ObservedObject的用法)。- 迁移陷阱:
@State搭配引用类型时,在视图标识相关的细节上与@StateObject行为不同。盲目替换的应用可能会在视图重建时出现令人困惑的初始化行为。
宏展开
当编译器看到 @Observable 时,会向类型添加三样东西3:
@Observable
class UserProfile {
var name: String = ""
var email: String = ""
var preferences: [String] = []
}
展开结果(简化版)如下:
class UserProfile: Observable {
@ObservationIgnored private let _$observationRegistrar = ObservationRegistrar()
private var _name: String = ""
var name: String {
get {
access(keyPath: \.name)
return _name
}
set {
withMutation(keyPath: \.name) {
_name = newValue
}
}
}
// ... email 和 preferences 同理
func access<Member>(keyPath: KeyPath<UserProfile, Member>) {
_$observationRegistrar.access(self, keyPath: keyPath)
}
func withMutation<Member, T>(
keyPath: KeyPath<UserProfile, Member>,
_ mutation: () throws -> T
) rethrows -> T {
try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation)
}
}
三处结构性变化:
注册器。 一个私有的 ObservationRegistrar 实例持有追踪状态。注册器是模型变更与依赖作用域重新求值之间的桥梁。宏将其标记为 @ObservationIgnored,使注册器自身不被追踪。
属性存储重写。 每个声明的存储属性都被改写为一个私有后备字段,加上一个其 getter 和 setter 调用注册器的计算属性。编译器生成的访问器正是按属性追踪得以工作的关键。
遵循 Observable。 这是注册器的 API 所要求的标记协议。该协议没有任何要求,只是一个一致性检查,而非接口契约。
注册器的职责
ObservationRegistrar 做两件事3:
追踪访问。 当 withObservationTracking { ... } onChange: { ... }(SwiftUI 视图体使用的底层追踪 API)运行该闭包时,注册器会记录每一次被读取的 (self, keyPath) 对。被访问的路径集合就是该作用域的”依赖足迹”。
触发失效。 当一个属性发生变更时,注册器会找到所有访问过该具体 keyPath 的作用域,并触发它们的 onChange 闭包。未访问该 keyPath 的作用域不受影响。
与 ObservableObject 的对比体现了结构性的转变。ObservableObject 的 objectWillChange 发布者会在每一次 @Published 变更时触发,所有订阅者都会收到通知。SwiftUI 的视图体机制依赖该发布者来获知”有东西变了,需要重新求值”。重新求值会针对整个视图体执行;SwiftUI 随后再计算实际发生变化的依赖视图并仅更新它们,但视图体的重新求值早已发生。而使用 @Observable 时,视图体本身的重新求值就被门控了:如果视图体没有读取该变更属性,就不会再次执行。
对一个有三个属性、视图只读取 name 的 UserProfile 来说,差异是实实在在的:@ObservableObject 模型在 email 和 preferences 变更时也会触发视图体重新求值;@Observable 模型则不会。在拥有大量模型和视图的复杂应用中,累积下来的节省非常可观。
迁移映射
迁移词汇表对照4:
| ObservableObject | @Observable |
|---|---|
class Foo: ObservableObject |
@Observable class Foo |
@Published var name: String |
var name: String |
@StateObject var foo = Foo() |
@State var foo = Foo() |
@ObservedObject var foo: Foo |
var foo: Foo(若需绑定则用 @Bindable var foo: Foo) |
@EnvironmentObject var foo: Foo |
@Environment(Foo.self) var foo |
.environmentObject(foo) |
.environment(foo) |
@Bindable 包装器值得单独说明。它是为 @Observable 实例的属性创建 Binding 的新方式:
@Bindable var profile: UserProfile
TextField("Name", text: $profile.name)
TextField("Email", text: $profile.email)
如果不使用 @Bindable,$profile.name 这种语法就不能工作,因为 @Observable 类型不会自动提供投影值。加上 @Bindable 之后,每个属性都能拿到绑定形式。当子视图需要双向绑定到父视图的可观察模型时,使用 @Bindable;若子视图只读取,使用普通引用(var profile: UserProfile)即可。
@State 与 @StateObject 的陷阱
迁移过程中最容易引发线上 bug 的一条是:@StateObject var foo = Foo() 变为 @State var foo = Foo()。这种修改可以编译通过,但行为会通过一个微妙的机制产生分歧——默认值表达式的求值时机5。
当视图标识保持稳定时,@State 与 @StateObject 都会跨越 SwiftUI 的视图重建保留实例;两者按标识键控的后备存储都会丢弃父级驱动的重新初始化。差异在于初始化器表达式何时执行。
@StateObject 通过 @autoclosure 声明其参数。Foo() 初始化器表达式被包装起来,只有在 SwiftUI 真正需要构造实例时才会被求值。当父级重建时视图标识保持不变、复用已存在的实例,该表达式根本不会被调用。昂贵的初始化器永远不会触发。
@State 没有 autoclosure 包装。Foo() 初始化器表达式在视图的 init 每次运行时都会被急切求值(每次父级重建都会运行,即便视图标识保持不变、已存在的实例仍在存储中)。Foo() 的内存分配会发生;SwiftUI 随后丢弃这个新实例,继续使用已存储的那个。对 init() 开销低的模型来说,这种浪费的分配看不见也摸不着。但对 init() 昂贵的模型(网络请求、大块数据加载、在 init 里启动的异步任务)而言,这就是”应用能正常工作”和”应用在每次父级重建时对自家后端发起 DDoS 攻击”之间的差距。
防御性的做法是:让模型的 init() 保持轻量,使这种差异无关紧要;或者在应用层面初始化一次昂贵模型,通过 .environment() 向下传递。需要昂贵初始化的模型,无论由哪种属性包装器持有,都不应在 init 中执行那些工作;懒加载或显式 setup 方法才是 @State 和 @StateObject 两种场景下都正确的模式。
用 withObservationTracking 进行显式追踪
在 SwiftUI 之外,追踪原语是 withObservationTracking { ... } onChange: { ... }6:
import Observation
let profile = UserProfile()
withObservationTracking {
print("Name: \(profile.name)")
} onChange: {
print("Something we read changed")
}
profile.name = "Alice" // 触发 onChange
profile.email = "..." // 不会触发 onChange(我们没有读取它)
闭包运行一次,记录每一次可观察访问。当任何一次访问的源属性发生变化时,onChange 恰好触发一次(它是一个一次性回调)。要继续追踪,必须重新设置闭包。SwiftUI 内部正是用这种模式追踪视图体依赖;对非 SwiftUI 代码(NSWindowController、Cocoa 应用、命令行工具)来说,withObservationTracking 才是合适的原语。
ObservableObject 仍是正确选择的场景
有三种情况,ObservableObject 依然有它的位置:
面向 iOS 16 及更早版本的应用。 Observation 框架要求 iOS 17+。部署目标更早的应用必须使用 ObservableObject。一旦部署目标提升到 17+,迁移就可以放心进行。
需要在值图之外发布通知的模型。 ObservableObject 的 objectWillChange 是一个 Combine 发布者;希望通过 Combine 管道(防抖、节流、转换事件流)订阅”任何变化”的代码,在 ObservableObject 上能免费获得这种能力,而在 @Observable 上则需要自己重新构建等价物。Observation 框架优先考虑视图重新求值的效率,而非任意发布者订阅。
迁移成本超过收益的现有代码库。 对一个尚未测出性能问题、运行良好的 ObservableObject 代码库来说,迁移带来的收益不足以证明审计成本是合理的。该迁移的时机是:你本来就在动这个文件,或者性能剖析定位到了热点。
对面向 iOS 17+ 的新代码而言,@Observable 是现代默认选择,迁移路径也很清晰。
这一模式对 iOS 26+ 应用意味着什么
三点结论。
-
新代码默认采用
@Observable。 宏简洁,按属性追踪在常见场景下能改善性能,迁移词汇表也很清晰。iOS 17+ 代码库中的新模型应当使用@Observable。 -
审视
@StateObject→@State的迁移对视图标识的影响。 这种替换可以顺利编译,但在带条件结构的视图中可能产生意料之外的重新初始化。init()开销大的模型需要谨慎迁移;开销低的则可以放心。 -
审慎地使用
@Bindable。 它是双向绑定到可观察模型的新模式。当子视图需要修改父视图的模型时,选用@Bindable;只读视图保持普通引用(var foo: Foo)即可。
完整的 Apple 生态系列:类型化 App Intents;MCP 服务器;路由问题;Foundation Models;运行时与工具链 LLM 之分;三个表面;单一数据源模式;两台 MCP 服务器;Apple 开发中的 hooks;Live Activities;watchOS 运行时;SwiftUI 内部机制;RealityKit 的空间心智模型;SwiftData schema 纪律;Liquid Glass 模式;多平台发布;平台矩阵;Vision 框架;Symbol Effects;Core ML 推理;Writing Tools API;Swift Testing;隐私清单;作为平台的无障碍性;SF Pro 字体系统;visionOS 空间模式;Speech 框架;SwiftData 迁移;tvOS 焦点引擎;我拒绝写的内容。系列入口在 Apple 生态系列。如需 iOS 与 AI 智能体结合的更广背景,请参阅 iOS Agent 开发指南。
常见问题
Apple 为什么要替换 ObservableObject?
两个原因。第一,性能:ObservableObject 的 objectWillChange 发布者在每次 @Published 变更时触发,无论视图是否真正读取了变更的属性,都会触发每个依赖视图的视图体重新求值。@Observable 的按属性追踪将视图体重新求值门控在视图实际访问的属性上。第二,语法:每个属性都加 @Published 注解,以及 @StateObject/@ObservedObject/@EnvironmentObject 这套阶梯,对于一个概念上只是”这是可变共享状态”的事情来说太过冗长。@Observable 加 @State 加 @Environment 更短。
@Observable 能用于结构体吗?
不能。@Observable 要求引用语义,结构体不符合。该宏面向的是跨视图持有可变状态的类。如果是单个视图内的值类型状态,直接对值类型使用 @State 即可。
我能在同一个应用里同时使用 @Observable 和 ObservableObject 吗?
可以。两者并存不会冲突。迁移可以按文件逐步推进。边界以类型为单位:一个类要么是 ObservableObject,要么是 @Observable,不能两者兼有,但同一应用中的不同类可以采用不同方案。
那些向 Combine 管道触发事件的 @Published 属性怎么办?
@Observable 不为单个属性提供 Combine 发布者的等价物。使用 @Published 属性的 $foo.publisher 模式的代码,在 @Observable 下需要以不同方式重建该订阅(例如,将属性包装到值类型模型中并通过 SwiftUI 的更新周期观察,或反复使用 withObservationTracking)。对于重度依赖 Combine 的代码路径,迁移是一项实打实的工程工作。
@Observable 与 SwiftData 的 @Model 如何交互?
@Model(SwiftData)类型自动是 @Observable。该持久化框架在其代码生成中加入了 Observable 一致性,因此 SwiftData 模型与普通 @Observable 类型一样参与按属性追踪。观察 @Model 类型属性的视图获得相同的细粒度重新求值行为。本系列的 SwiftData 迁移 与 SwiftData schema 纪律 两篇文章覆盖了同一观察机制下的持久化侧。
@ObservationIgnored 是做什么用的?
它让一个存储属性退出观察追踪。该宏通常会重写每个存储属性,使其经由注册器;被标记为 @ObservationIgnored 的属性则保留直接存储,不参与追踪。适合用在不应触发视图重新求值的属性上:缓存、文件句柄、指标计数器,以及注册器自身。
参考资料
-
Apple 开发者文档:Observation framework。该框架参考涵盖了
Observable协议与@Observable宏。可用于 iOS 17+、macOS 14+、Swift 5.9+。 ↩ -
Swift Evolution:SE-0395 Observability。被采纳的 Swift 提案,包含设计动机、语义要求,以及注册器协议契约。 ↩
-
Apple 开发者文档:
ObservationRegistrar与Observable。宏所生成一致性的运行时类型,以及合成访问器调用的注册器 API。 ↩↩↩ -
Apple 开发者文档:Migrating from the Observable Object protocol to the Observable macro。Apple 官方迁移指南,涵盖属性包装器映射表与 SwiftUI 集成变化。 ↩
-
Apple 开发者文档:
State与StateObject。这两个属性包装器关于视图标识与重建生命周期的初始化语义文档。 ↩ -
Apple 开发者文档:
withObservationTracking(_:onChange:)。在 SwiftUI 自动视图体追踪之外使用的显式追踪原语。 ↩