更新时间:2026 年 9 月 14 日
@Observable 是 Swift 5.9 引入、随 Observation 框架发布的宏(macro),它把普通的 class 转换为可被 SwiftUI 观察的引用类型,无需继承 ObservableObject,也无需为每个属性标注 @Published。iOS 17 起可用,iOS 26 与 Swift 6.2 已把它列为官方推荐的观察模型,性能明显优于旧的 Combine 方案,因为 SwiftUI 只会在视图真正读取的属性变化时才重绘。
什么是 @Observable 宏?
@Observable 是 Apple 在 Observation 框架中提供的宏,作用于普通的引用类型(class)。它在编译期展开成一段观察注册代码:为每个存储属性生成一层 _$observationRegistrar 调用,把 get 时的访问和 set 时的变更通知 SwiftUI 的依赖追踪系统。你写的仍然是一个普通的 Swift 类,但视图能够按属性粒度感知变化。
与 ObservableObject 依赖 Combine 的 ObjectWillChangePublisher 不同,@Observable 不产生 Combine 发布者。SwiftUI 会在视图 body 执行时记录当前访问的 KeyPath,属性写入时只让"真正读过这个 KeyPath"的视图重建。这意味着你可以把很多状态塞进同一个 model 里,而不用担心"改一个字段整个视图树都重绘"。
说实话,宏展开后的代码在 Xcode 中可通过右键 model 类 → "Expand Macro" 直接查看,这一步在排查观察未触发的问题时特别有帮助。我上个月在一个 iPad 项目里就踩过一次坑:属性明明变了,SwiftUI 却没重绘。最后发现是 @ObservationIgnored 加错了地方(我把它加在了一个本该被追踪的字段上)。当时把宏展开看一眼,比对着源码猜快得多。
@Observable 与 ObservableObject 有什么区别?
两者都能让 SwiftUI 观察引用类型的状态,但底层机制、粒度、性能特征都不相同。下表汇总了实际项目中最常被问到的对比维度:
| 维度 | @Observable | ObservableObject |
|---|---|---|
| 最低系统 | iOS 17 / macOS 14 / visionOS 1 | iOS 13 / macOS 10.15 |
| 底层机制 | Observation 框架 + 编译期宏 | Combine 的 ObjectWillChangePublisher |
| 属性标注 | 无需标注,宏自动追踪 | 每个属性需要 @Published |
| 视图重绘粒度 | KeyPath 级(只重绘读到该属性的视图) | 对象级(订阅者全部重绘) |
| SwiftUI 包装器 | @State / @Bindable / 直接传递 | @StateObject / @ObservedObject / @EnvironmentObject |
| Combine 支持 | 不自动产生 Publisher | 原生 Publisher,可直接订阅 |
| 并发安全 | 非线程安全(配合 @MainActor 或 actor) | 非线程安全(同上) |
| 迁移成本 | 去掉协议与 @Published 即可,视图包装器需替换 | - |
最关键的差异是重绘粒度。ObservableObject 的 objectWillChange 是一个全局信号:任意 @Published 属性变化,所有订阅这个对象的视图都会重新计算 body。@Observable 则借助 SwiftUI 的依赖追踪,只让"真的读过被修改的 KeyPath"的视图重绘。对于把多个屏幕状态放在同一个 ViewModel 里的项目,这一点是很大的性能收益。
快速开始:一个可运行的完整示例
下面是一个最小可运行示例,展示 @Observable 的定义、@State 拥有、@Bindable 传递和 KeyPath 级重绘。把它复制到一个新的 SwiftUI iOS 项目的 ContentView.swift 就能跑(Xcode 16+,iOS 17+ 部署目标)。
import SwiftUI
import Observation
@Observable
final class CounterModel {
var count: Int = 0
var title: String = "计数器"
// 非 UI 相关的状态:用 @ObservationIgnored 关闭追踪
@ObservationIgnored var lastPersistedAt: Date?
func increment() {
count += 1
lastPersistedAt = .now
}
}
struct ContentView: View {
// @State 用来"拥有"引用类型的 model 实例
@State private var model = CounterModel()
var body: some View {
VStack(spacing: 20) {
TitleEditor(model: model) // 只订阅 title
CounterDisplay(model: model) // 只订阅 count
Button("加 1") { model.increment() }
}
.padding()
}
}
struct TitleEditor: View {
// @Bindable 让子视图可以做双向绑定
@Bindable var model: CounterModel
var body: some View {
TextField("标题", text: $model.title)
.textFieldStyle(.roundedBorder)
}
}
struct CounterDisplay: View {
let model: CounterModel // 只读传递,不需要 @Bindable
var body: some View {
Text("\(model.title): \(model.count)")
.font(.title)
}
}
关键细节:TitleEditor 只读取 title,所以修改 count 时它不会重绘;CounterDisplay 读了两个属性,两个变化都会触发它。你可以在 body 中加一个 let _ = Self._printChanges() 观察 SwiftUI 的重绘日志验证这一点。
从 ObservableObject 迁移到 @Observable 的步骤
如果你有一个既有的 ObservableObject 项目,迁移基本是机械的四步。以典型的登录页面 ViewModel 为例:
// 迁移前
final class LoginViewModel: ObservableObject {
@Published var email: String = ""
@Published var password: String = ""
@Published private(set) var isLoading = false
private var cancellables = Set<AnyCancellable>()
func submit() async { /* ... */ }
}
struct LoginView: View {
@StateObject private var vm = LoginViewModel()
var body: some View {
TextField("邮箱", text: $vm.email)
SecureField("密码", text: $vm.password)
Button("登录") { Task { await vm.submit() } }
.disabled(vm.isLoading)
}
}
迁移后:
import Observation
@Observable
final class LoginViewModel {
var email: String = ""
var password: String = ""
private(set) var isLoading = false
// Combine 相关的状态用 @ObservationIgnored 隔离
@ObservationIgnored private var cancellables = Set<AnyCancellable>()
func submit() async { /* ... */ }
}
struct LoginView: View {
@State private var vm = LoginViewModel() // 用 @State,不是 @StateObject
var body: some View {
@Bindable var vm = vm // 局部 @Bindable 用于 $ 绑定
TextField("邮箱", text: $vm.email)
SecureField("密码", text: $vm.password)
Button("登录") { Task { await vm.submit() } }
.disabled(vm.isLoading)
}
}
四步总结:(1)移除 ObservableObject 协议、加 @Observable;(2)删除所有 @Published;(3)非追踪属性加 @ObservationIgnored;(4)视图里 @StateObject 换成 @State、@ObservedObject 换成 @Bindable、@EnvironmentObject 换成 @Environment。做这类批量迁移时,我通常会配合Swift 6.2 的 Approachable Concurrency 改造一起做,因为把 model 显式标注 @MainActor 能顺手解决很多并发警告(一次动两处比来回反复动省事多了)。
如何在 SwiftUI 中使用 @Bindable 实现双向绑定?
@Bindable 是一个属性包装器,专门用于把 @Observable 对象的属性变成 Binding<T>。它的存在,是因为 SwiftUI 需要一种明确的方式知道"我要对这个引用类型做绑定",而不是像 ObservableObject 时代那样默认所有 $ 前缀都能生效。
使用场景有三种:
- 子视图属性:
@Bindable var model: MyModel,然后$model.field得到 Binding。 - 局部变量:在 body 里
@Bindable var model = model,一次性把外部传入的 model 局部包一层,适合当前视图既要读又要绑定。 - 可选值:
if let model展开后配合@Bindable做条件绑定,例如 sheet 里编辑一个可能为 nil 的 draft。
struct EditorSheet: View {
@Bindable var document: Document
var body: some View {
Form {
TextField("标题", text: $document.title)
Toggle("已完成", isOn: $document.isCompleted)
DatePicker("截止", selection: $document.dueDate)
}
}
}
如果误把 @Bindable 当成 @ObservedObject 的替代加在顶层视图,会导致视图被重建时 model 也被重建。记住:@State 负责"拥有"(生命周期),@Bindable 只负责"生成绑定"(不管理生命周期)。这个分工比 ObservableObject 时代的 @StateObject 与 @ObservedObject 划分更清晰。
@ObservationTracked 与 @ObservationIgnored 的用法
@ObservationTracked 通常不需要你手写,@Observable 宏会自动为所有存储属性加上它。你会看到它,是在读源码或 Xcode 的宏展开视图里。真正需要手写的是它的反面:@ObservationIgnored。
把这些类型的属性标为 @ObservationIgnored:
- Combine 的
AnyCancellable集合、订阅句柄 - 缓存与懒加载的辅助值(每次访问都变,追踪会导致视图重绘风暴)
- 持有的服务、Repository、Logger 实例(这些不会变)
- 调试用的时间戳、命中率统计
- 与视图无关的定时器(Timer、DispatchSourceTimer)
@Observable
final class FeedModel {
var items: [FeedItem] = []
var isRefreshing = false
@ObservationIgnored private let api: FeedAPI
@ObservationIgnored private var refreshTask: Task<Void, Never>?
@ObservationIgnored private var cache: [String: FeedItem] = [:]
init(api: FeedAPI) { self.api = api }
}
一个常见误区是给 let 属性加 @ObservationIgnored。没有必要,宏本来就不追踪不可变属性。宏展开只对 var 存储属性起作用。
withObservationTracking 手动追踪变化
withObservationTracking 是 Observation 框架暴露的底层 API。它让你在 SwiftUI 之外也能对 @Observable 对象的属性变化做出反应,例如在 UIKit 视图控制器里、在命令行工具里,或者在自己的 render loop 里。
import Observation
final class LegacyViewController: UIViewController {
let model: CounterModel
init(model: CounterModel) {
self.model = model
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError() }
override func viewDidLoad() {
super.viewDidLoad()
observeCount()
}
private func observeCount() {
withObservationTracking {
// 在闭包里读到的属性会被自动追踪
countLabel.text = "\(model.count)"
} onChange: { [weak self] in
// 每次任意被追踪属性变化都会触发一次
// 注意:onChange 会在旧值即将被改变时同步触发
DispatchQueue.main.async {
self?.observeCount() // 需要手动重新订阅
}
}
}
}
两个容易踩的坑:(1)onChange 只会触发一次,需要在其中再次调用 withObservationTracking 来续订;(2)onChange 在属性 willSet 阶段触发,读回的仍然是旧值,通常需要 dispatch 到下一 runloop 才能拿到新值。这两点让它更像"变化通知"而非"值观察",用起来接近 KVO 的心智模型。
跨 iPad、Mac Catalyst、visionOS 的行为差异
@Observable 的观察机制本身在所有 Apple 平台上是一致的,同一份宏展开、同一份 Observation runtime。但它与视图系统交互的方式,会受平台特性影响。以下是我在跨平台项目里实际遇到的差异(每一条都是在真机上翻过车之后总结的):
iPadOS:多场景(Scene)共享 model 的所有权
iPad 上一个 App 可能同时打开多个 Scene(分屏、Stage Manager)。如果 model 放在 @State 里,每个 Scene 有一份独立实例,互不影响。要让多个 Scene 共享同一份状态,需要在 App 层持有实例,并通过 .environment(model) 注入。
Mac Catalyst:菜单栏与 SwiftUI Commands 的绑定
Mac Catalyst 下的 Commands 修饰符不属于任何视图层级,它读取的 @Observable 对象必须通过 @FocusedValue 或 @Environment 传入。直接 @State 一个 model 再从菜单里读,Command 里读到的会是旧值,因为 Command 的 body 不在视图追踪范围内。
visionOS:ImmersiveSpace 的独立追踪上下文
在 visionOS 上,ImmersiveSpace 与主 WindowGroup 是独立的 Scene。同一个 @Observable model 在两个 Scene 里读,Observation 的追踪没问题,但 RealityView 的 update 闭包不是 SwiftUI 视图 body,需要在里面显式用 withObservationTracking 才能感知 model 变化。不然你会看到 SwiftUI UI 更新了,但 3D 内容没动,这个错误特别难定位。
watchOS:内存敏感场景下减少订阅者
watchOS 上视图层级尽量扁平,把大 model 拆小、让每个视图只订阅需要的字段特别重要。这也正是 @Observable 相对 ObservableObject 最有价值的场景。ObservableObject 时代 watch app 上"改一个 tick 全屏重绘"是常见性能问题。
常见性能陷阱与最佳实践
@Observable 天生比 ObservableObject 快,但并不意味着可以随便写。以下是我在实际项目里排查过的几个真实问题:
陷阱 1:在 body 里对整个集合调用会改变追踪结果的 API
Text("共 \(model.items.count) 条") 只订阅 items 的写入(因为读的是集合本身)。而 ForEach(model.items) { ... } 读的也是 items,同理。但如果你写成 ForEach(model.items.filter { $0.active }),SwiftUI 追踪的仍然是 items,filter 是在读取之后运行的。这里的性能问题不是追踪粒度,而是每次重绘都要重新执行 filter;把它移到 model 的计算属性并配合缓存能明显改善。
陷阱 2:把巨型 model 全局注入
用 .environment(bigModel) 向下注入一个字段极多的 @Observable,虽然 KeyPath 级追踪保证了正确性,但会让 NavigationStack 的路由匹配在动画帧内做很多次依赖检查。更好的做法是拆成多个小 model 分别注入。
陷阱 3:与 Combine 混用时忘了桥接
@Observable 不产生 Publisher。如果代码里还有依赖 Combine 的旧组件(例如把 model 变化桥接到 AsyncSequence),需要用 observationTracked + AsyncStream 手动搭桥。SE-0395 Macros 提案里对宏系统的边界有详细说明。理解宏不能捕获运行时 API 这一点后,就不会指望 @Observable 自动给你一个 Publisher。
陷阱 4:非主线程写属性
Observation 的 registrar 不是线程安全的。从后台线程写 @Observable 属性会触发运行时的观察通知在非主线程发出,SwiftUI 视图更新崩溃或数据错乱都可能发生。默认给 model 加 @MainActor 是最稳妥的做法,与 SwiftData 模型的隔离策略思路一致。老实说,我一开始也偷懒不加 @MainActor,直到线上炸了一次 crash 才乖乖补上。
最佳实践清单
- 默认给 @Observable model 加
@MainActor,除非明确用 actor 隔离。 - 把 Combine 订阅、Task 句柄、缓存都放到
@ObservationIgnored。 - 不要在同一个 model 里混用
@Observable与@Published。 - 视图能用
let model(只读)就不用@Bindable。 - 调试时用
Self._printChanges()定位是哪个 KeyPath 触发了重绘。 - 跨 Scene 共享状态用
.environment()注入,不要用@State。
常见问题
@Observable 需要用 @StateObject 吗?
不需要。@Observable 类应该用 @State(拥有实例)、@Bindable(生成绑定)或普通属性(只读传递)来使用。@StateObject 是 ObservableObject 的配套,混用会导致编译错误。
@Observable 的类是线程安全的吗?
不是。Observation 框架的 registrar 不做同步。你需要用 @MainActor 把 model 隔离到主线程,或者用 actor 包裹底层数据,让 @Observable 只做 UI 快照。
最低支持哪些系统版本?
Observation 框架从 iOS 17、iPadOS 17、macOS 14、tvOS 17、watchOS 10、visionOS 1 开始提供。iOS 16 及以下需要继续用 ObservableObject 或做条件编译降级。
@Observable 能和 Combine 一起用吗?
可以共存,但 @Observable 不会自动生成 Publisher。如果需要把变化桥接到 Combine,可以用 withObservationTracking 手动包装成 AsyncStream,再用 values 或 publisher(for:) 转换。
@ObservationIgnored 和普通 let 属性有什么区别?
宏本来就不为 let 生成追踪代码,所以对不可变属性写 @ObservationIgnored 没有意义。它只对 var 存储属性有效果,用来把那些不想让 SwiftUI 追踪的可变字段排除在外,例如 Combine 订阅集合或缓存字典。




