问题历史
在 Swift 5.9 之前,SwiftUI 中的响应式编程依赖于 ObservableObject 协议与 Combine 框架的结合。开发人员手动使用 @Published 注解属性以合成发布者,或者调用 objectWillChange.send() 通知视图发生变更。这个模式面临粗粒度更新的问题——任何属性的变化会触发整个视图主体的重新评估——并且需要引用语义,防止使用结构体作为复杂视图模型。引入了 Observation 框架以提供细粒度的自动反应,而无需显式的发布者声明。
问题
核心挑战是如何在没有显式样板代码的情况下检测属性访问和变更,同时保持类型安全和高性能。传统解决方案需要手动的键值观察(KVO),这是一种字符串类型且脆弱的方案,或者是使领域模型混乱的包装类型。系统需要拦截读写操作以动态注册依赖关系,但不能有方法交换或 ObservableObject 的引用计数身份传播所带来的运行时开销。
解决方案
Swift 使用 @Observable 宏,该宏结合了对等体、成员和访问器的宏功能。在编译时,该宏通过注入一个私有的 ObservationRegistrar 实例来转变被注释的类。然后它会将每个存储的属性重写为计算访问器,其中读取用 _$observationRegistrar.track(self, keyPath: ...) 包裹,写入则用 willSet/didSet 通知来处理。这个扩展自动合成了对 Observable 协议的符合性,并实现了所需的 observationRegistrar 计算属性。SwiftUI 在视图主体评估期间与这个注册器集成,仅为实际访问的属性注册自身作为观察者,从而实现了细粒度更新而无需手动 Combine 设置。
@Observable class SettingsViewModel { var isDarkModeEnabled = false var notificationCount = 0 } // 概念编译器扩展: class SettingsViewModel { private let _$observationRegistrar = ObservationRegistrar() var isDarkModeEnabled: Bool { get { _$observationRegistrar.track(self, keyPath: \.isDarkModeEnabled) return _isDarkModeEnabled } set { _$observationRegistrar.willSet(self, keyPath: \.isDarkModeEnabled) let oldValue = _isDarkModeEnabled _isDarkModeEnabled = newValue _$observationRegistrar.didSet(self, keyPath: \.isDarkModeEnabled, oldValue: oldValue) } } private var _isDarkModeEnabled = false // ... 通知数量相同的模式 }
您正在为一个实时金融仪表板 SwiftUI 应用程序构架,该应用程序显示实时股票价格、用户投资组合总额和新闻源。视图模型包含三十个不同的属性,从布尔 UI 标志到复杂的图表数据结构。
最初,团队使用 ObservableObject 和每个属性上的 @Published 封装来实现这一点。这导致了严重的性能下降:当单个股票价格更新时,由于 ObservableObject 通知“某个东西发生了变化”在整个对象中,整个仪表板都会重新计算,缺乏细粒度。这段代码也很冗长,需要重复的 @Published var 声明和手动 AnyCancellable 存储以防止订阅中的内存泄漏。
团队评估了三种架构方法以解决性能和样板问题。
第一种方法涉及手动 Combine 优化。他们为每个关键属性创建单独的 PassthroughSubject 实例,并使用 .onReceive 订阅特定更新。优势是对哪些 UI 组件刷新具有精确控制。然而,缺点是代码膨胀——三十个主题需要三十个订阅和容易出错的手动内存管理,与 Set<AnyCancellable>,使代码库变得难以维护。
第二种方法建议使用 SwiftUI 的 @State 和值类型视图模型。他们会将视图模型视为不可变值,并在每次变更时替换它。优势在于自然的值语义和自动等值检查,防止冗余更新。缺点是丧失引用身份;每次变更都会创建一个新实例,破坏 ScrollView 位置恢复,且因身份丧失使复杂对象协调变得不可能。
第三种方法采用了 @Observable 宏。通过使用 @Observable 注解类并移除所有 @Published 属性,编译器自动将属性转变为使用 ObservationRegistrar。优势有两个:语法保持简洁,只有普通的 var 声明,且 SwiftUI 自动跟踪每个视图主体中访问的属性,仅更新这些特定的子视图。缺点是需要迁移到 Swift 5.9,并培训团队使用宏调试技术。
团队选择了第三种方法,因为它消除了 200 行 Combine 订阅代码,同时解决了细粒度问题。他们观察到 CPU 使用率在高频价格更新期间降低了 40%。结果是一个响应迅速的仪表板,其中每个股票价格标签独立更新,而不会触发静态新闻源部分的布局重计算。
为什么 Observation 框架要求被观察类型为类(引用语义),如果将 @Observable 应用到结构体会发生什么编译错误?
@Observable 宏通过将 ObservationRegistrar 注入作为存储属性并实现 Observable 协议进行扩展。结构体 是值类型,具有写时复制语义;每次变更在概念上都会创建一个具有不同身份的新实例。ObservationRegistrar 维护内部状态——观察者列表和跟踪范围——必须在变更间持久化以维护观察图。如果应用于结构体,变更将错误地复制注册器状态,破坏观察者与实例之间的连接。编译器通过生成错误来防止这种情况,指出该宏无法以满足 Observable 协议要求的稳定身份的方式将所需的存储属性添加到值类型,或者更具体地说,结果类型无法遵循 Observable,因为缺少必要的引用稳定性。
Observation 框架如何处理嵌套的可观察对象,为什么没有像 @Published 那样对单个属性提供投影值(例如 $property)?**
当一个 @Observable 类包含一个自身也是 @Observable 类的属性时,框架在属性级别跟踪访问,而不是自动递归观察嵌套对象。访问 outer.inner.name 注册对外部对象的 inner 属性的依赖。如果完全替换 inner 实例,观察者会收到通知。然而,除非外部对象显式跟踪内层对象,否则对 inner.name 的更改不会通知外部对象的观察者。与 Combine 不同,Observation 中没有单个属性的投影值概念,因为框架通过注册器使用直接属性跟踪,而不是发布者流。在 SwiftUI 中, Observation 的 $ 语法用于整个实例,当使用 @Bindable 包装时(例如,@Bindable var viewModel: SettingsViewModel 允许 $viewModel.isDarkModeEnabled),而不是单独的属性声明。
Observation 框架提供了什么具体的线程安全保证,以及它是如何与 Swift 协程演员交互的?**
ObservationRegistrar 本身并不是本质上线程安全的;它假定对可观察对象的序列化访问。当一个 @Observable 类被隔离到一个演员(例如 @MainActor)时,所有变更和观察自动在该演员的上下文中进行,从而防止数据竞争。该框架确保观察回调遵循观察者的隔离域,并使用 Sendable 检查。一个关键的实现细节是,跟踪机制使用 TaskLocal 存储在视图主体执行期间维护当前观察范围。这意味着观察注册隐式绑定到当前 Task 的上下文中,并且在没有显式传输的情况下,不能泄漏到非结构化并发边界之外,确保观察仅在登记的特定异步事务中处于活动状态。