用 Observation 重构 SwiftUI 状态管理
Observation 的价值不只是少写几个 @Published。它改变了 SwiftUI 读取状态的方式:视图在执行 body 时访问了哪些属性,系统就跟踪哪些属性。状态更新后,依赖这些属性的视图才需要重新计算。要真正得到这份好处,关键仍然是明确“谁拥有状态、谁只读取状态、谁可以修改状态”。
从所有权开始
下面的模型由页面创建并持有,因此使用 @State 保存。@Observable 负责属性访问跟踪;@MainActor 则明确所有 UI 状态只能在主执行器上修改。
import Observation
import SwiftUI
@Observable
@MainActor
final class ReadingList {
var books: [String] = []
var draft = ""
var canAdd: Bool {
!draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
func addBook() {
guard canAdd else { return }
books.append(draft)
draft = ""
}
}
struct ReadingListView: View {
@State private var model = ReadingList()
var body: some View {
@Bindable var model = model
Form {
TextField("Book title", text: $model.draft)
Button("Add", action: model.addBook)
.disabled(!model.canAdd)
ForEach(model.books, id: \.self) { book in
Text(book)
}
}
}
}
这里的 @Bindable 不拥有模型。它只为当前视图需要编辑的属性生成绑定。模型依旧由 @State 保存,视图重建时不会无意创建另一份阅读列表。
子视图只接收所需能力
不要因为模型已经可以观察,就把整个模型传遍视图树。只显示数量的子视图可以接收一个整数;负责录入的子视图才需要可绑定模型。较窄的参数让依赖关系更直观,也让 Preview 和单元测试更容易准备数据。
struct BookCount: View {
let count: Int
var body: some View {
Text("\(count) books")
.foregroundStyle(.secondary)
}
}
这也避免了一个常见误区:Observation 能减少不必要的更新,却不能替代模块设计。如果一个对象同时负责网络、缓存、导航和十几个页面的状态,换成 @Observable 后仍然是一个过深、过重的依赖。
审查实际依赖范围
迁移后可以在关键视图中暂时使用 SwiftUI 的变化诊断工具,观察哪些输入导致 body 重算。频繁变化的计时器、下载进度或搜索输入不应让完全无关的页面区域一起刷新。若仍然出现大范围更新,先检查父视图是否读取了整个模型并把派生值广泛传递,再决定是否拆分模型。拆分依据应是生命周期和业务职责,而不是单纯追求更小文件。
环境注入也要保持克制。把模型放进 environment 很适合应用级会话或明确的功能子树,但会让依赖在初始化器中不可见。对于可复用组件,显式参数通常更易理解;对于页面协调器,环境可能更自然。关键是团队能够从一个入口判断模型由谁创建、何时释放,以及退出账号时由谁清空。
异步工作放在边界之外
UI 模型可以协调加载状态,但网络客户端应是独立依赖。先获取值,再回到主执行器更新模型,比在模型里隐藏一个无法替换的全局客户端更容易测试。测试时可以直接构造 ReadingList,调用同步领域方法,并断言 books 与 draft;网络行为则在客户端自己的测试中验证。
迁移旧代码时不必一次替换所有 ObservableObject。可以从一个叶子页面开始:移除 @Published,用 @Observable 标记模型,把拥有者改为 @State,只在需要双向绑定的位置引入 @Bindable。随后利用 Instruments 和实际交互确认更新范围,而不是仅凭代码变短就判断迁移成功。
Observation 提供的是更精细的状态追踪机制。稳定的 SwiftUI 架构仍然来自清楚的所有权、窄接口和可替换的副作用边界。