← 所有文章

@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 自动视图体追踪之外使用的显式追踪原语。 

相关文章

HealthKit + SwiftUI on iOS 26: Authorization, Sample Types, and Cross-Platform Patterns

Real production patterns from Water (water tracking, HKQuantitySample) and Return (mindful sessions, HKCategorySample). …

17 分钟阅读

Liquid Glass in SwiftUI: Three Patterns From Shipping Return on iOS 26

Apple's Liquid Glass is a one-line SwiftUI API. Three patterns from Return go beyond .glassEffect(): glass on text via C…

19 分钟阅读

The Cleanup Layer Is the Real AI Agent Market

Charlie Labs pivoted from building agents to cleaning up after them. The AI agent market is moving from generation to pr…

15 分钟阅读