← 所有文章

@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 的对比体现了结构性的转变。ObservableObjectobjectWillChange 发布者会在每一次 @Published 变更时触发,所有订阅者都会收到通知。SwiftUI 的视图体机制依赖该发布者来获知”有东西变了,需要重新求值”。重新求值会针对整个视图体执行;SwiftUI 随后再计算实际发生变化的依赖视图并仅更新它们,但视图体的重新求值早已发生。而使用 @Observable 时,视图体本身的重新求值就被门控了:如果视图体没有读取该变更属性,就不会再次执行。

对一个有三个属性、视图只读取 nameUserProfile 来说,差异是实实在在的:@ObservableObject 模型在 emailpreferences 变更时也会触发视图体重新求值;@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+ 应用意味着什么

三点结论。

  1. 新代码默认采用 @Observable 宏简洁,按属性追踪在常见场景下能改善性能,迁移词汇表也很清晰。iOS 17+ 代码库中的新模型应当使用 @Observable

  2. 审视 @StateObject@State 的迁移对视图标识的影响。 这种替换可以顺利编译,但在带条件结构的视图中可能产生意料之外的重新初始化。init() 开销大的模型需要谨慎迁移;开销低的则可以放心。

  3. 审慎地使用 @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?

两个原因。第一,性能:ObservableObjectobjectWillChange 发布者在每次 @Published 变更时触发,无论视图是否真正读取了变更的属性,都会触发每个依赖视图的视图体重新求值。@Observable 的按属性追踪将视图体重新求值门控在视图实际访问的属性上。第二,语法:每个属性都加 @Published 注解,以及 @StateObject/@ObservedObject/@EnvironmentObject 这套阶梯,对于一个概念上只是”这是可变共享状态”的事情来说太过冗长。@Observable@State@Environment 更短。

@Observable 能用于结构体吗?

不能。@Observable 要求引用语义,结构体不符合。该宏面向的是跨视图持有可变状态的类。如果是单个视图内的值类型状态,直接对值类型使用 @State 即可。

我能在同一个应用里同时使用 @ObservableObservableObject 吗?

可以。两者并存不会冲突。迁移可以按文件逐步推进。边界以类型为单位:一个类要么是 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 的属性则保留直接存储,不参与追踪。适合用在不应触发视图重新求值的属性上:缓存、文件句柄、指标计数器,以及注册器自身。

参考资料


  1. Apple 开发者文档:Observation framework。该框架参考涵盖了 Observable 协议与 @Observable 宏。可用于 iOS 17+、macOS 14+、Swift 5.9+。 

  2. Swift Evolution:SE-0395 Observability。被采纳的 Swift 提案,包含设计动机、语义要求,以及注册器协议契约。 

  3. Apple 开发者文档:ObservationRegistrarObservable。宏所生成一致性的运行时类型,以及合成访问器调用的注册器 API。 

  4. Apple 开发者文档:Migrating from the Observable Object protocol to the Observable macro。Apple 官方迁移指南,涵盖属性包装器映射表与 SwiftUI 集成变化。 

  5. Apple 开发者文档:StateStateObject。这两个属性包装器关于视图标识与重建生命周期的初始化语义文档。 

  6. Apple 开发者文档:withObservationTracking(_:onChange:)。在 SwiftUI 自动视图体追踪之外使用的显式追踪原语。 

相关文章

iOS 26 上的 HealthKit + SwiftUI:来自两款已上架应用的授权流程、样本类型与跨平台模式

来自 Water(饮水追踪,HKQuantitySample)和 Return(正念会话,HKCategorySample)的真实生产模式。权限 UX、async 封装、watchOS 变体,以及需要避开的陷阱。

5 分钟阅读

SwiftUI 中的 Liquid Glass:在 iOS 26 上交付 Return 总结的三种模式

苹果的 Liquid Glass 是一行 SwiftUI API。Return 的三种模式超越了 .glassEffect():通过 Core Text 字形路径在文本上实现玻璃效果、镜面反射,以及 HUD 叠层。

7 分钟阅读

循环工程:在验证成本低廉处,循环才能取胜

循环工程,对照 Boris Cherny 的完整访谈记录来检验:他点名的每一个循环,验证成本都很低。正是这一约束决定了什么值得自动化。

4 分钟阅读