Swift @Observable 宏完全指南:iOS 26 从 ObservableObject 迁移到 Observation 框架

@Observable 是 Swift 5.9+ 的现代观察模式,替代 ObservableObject 与 @Published。本文覆盖 KeyPath 级追踪、@Bindable 绑定、withObservationTracking、跨平台差异与迁移实战。

Swift @Observable 完全指南 iOS 26
On this page
  1. 什么是 @Observable 宏?
  2. @Observable 与 ObservableObject 有什么区别?
  3. 快速开始:一个可运行的完整示例
  4. 从 ObservableObject 迁移到 @Observable 的步骤
  5. 如何在 SwiftUI 中使用 @Bindable 实现双向绑定?
  6. @ObservationTracked 与 @ObservationIgnored 的用法
  7. withObservationTracking 手动追踪变化
  8. 跨 iPad、Mac Catalyst、visionOS 的行为差异
  9. 常见性能陷阱与最佳实践

更新时间: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 观察引用类型的状态,但底层机制、粒度、性能特征都不相同。下表汇总了实际项目中最常被问到的对比维度:

维度@ObservableObservableObject
最低系统iOS 17 / macOS 14 / visionOS 1iOS 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 时代那样默认所有 $ 前缀都能生效。

使用场景有三种:

  1. 子视图属性:@Bindable var model: MyModel,然后 $model.field 得到 Binding。
  2. 局部变量:在 body 里 @Bindable var model = model,一次性把外部传入的 model 局部包一层,适合当前视图既要读又要绑定。
  3. 可选值: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 订阅集合或缓存字典。