返回博客

SwiftUI 状态管理的选择逻辑:从 @Observable 说起

用 SwiftUI 写界面有一段时间之后,我最常遇到的困惑不是「这个效果怎么实现」,而是「这个状态该放在哪」。@State@Binding@Observable@Environment——API 不少,每个都能用,但用不对就会踩坑:要么 View 更新太频繁拖慢渲染,要么父子组件之间传来传去的状态越来越乱。

梳理这些规则之后,我发现背后有一条一致的原则:用能表达意图的最简包装

@Observable 改变了什么

SwiftUI 最初的状态管理靠的是 ObservableObject + @Published:在 ViewModel 里把每个属性都标成 @Published,View 通过 @StateObject@ObservedObject 订阅。这套方案能用,但有一个代价——只要 @Published 属性中的任意一个发生变化,所有订阅这个 ViewModel 的 View 都会重新渲染,无论那个 View 有没有实际用到这个属性。

@Observable(iOS 17+ / macOS 14+)在观察粒度上做了根本改进:它追踪的是属性级别的访问,而不是对象级别的发布。View 渲染时读取了哪些属性,就只订阅那些属性的变更。一个包含十几个字段的 ViewModel,如果某个 View 只读了其中两个字段,它就只会在这两个字段变化时重新渲染。

这个差异在长列表、复杂表单场景里很明显。迁移成本不高,收益却相当直接。

包装选择:从意图出发

@Observable 解决了「订阅粒度」的问题,但「用什么包装持有状态」还是需要判断。我整理了一个简单的选择逻辑:

  • View 自己的临时状态(展开折叠、弹框开关、输入框内容)→ @State,值类型,生命周期绑定 View,用完就消失
  • 需要向下传、但不想破坏单一真相(子组件需要修改父组件的某个字段)→ @Binding,传引用而不是复制值
  • 拥有多个字段的业务状态(列表数据、加载状态、搜索词)→ @Observable class + @State,View「拥有」这个 ViewModel
  • 从父组件收到、只读不改(一个展示型子组件)→ @Observable class 不加任何包装,直接当参数传入,SwiftUI 会自动追踪
  • 跨多层组件共享的服务(路由、认证状态)→ .environment() 注入 + @Environment 消费,避免一层层向下传

最容易犯的错误是:子组件不拥有数据,却用 @State 自己创建一个 ViewModel 实例——这样父子组件各持一份状态,同步立刻变成问题。

View 拆分即性能优化

@Observable 的属性级追踪有一个隐含前提:View 要足够小,才能精确描述「我只需要这几个属性」。

如果一个 View 的 body 里既读了 isLoading,又读了 items,又读了 searchText,那它就需要在所有这些属性变化时都重新渲染——即便每次变化时,视觉上可能只有一个局部区域在更新。

解决方法是把大 View 拆成若干聚焦的小 struct:顶部信息区只读标题和副标题,列表区只读数据数组,底部操作区只读一个枚举状态。每个小 View 的渲染范围从语义上就被限定了。这是「提取子视图」这个技巧真正有价值的地方——不只是为了代码整洁,而是在结构层面控制渲染范围。

类似的思路也适用于性能热点:大数据量列表用 LazyVStack 延迟创建;渲染成本高但数据不频繁变化的视图,可以实现 Equatable 让 SwiftUI 跳过不必要的比较。

body 里只做声明,不做计算

body 是描述「界面应该是什么样子」的地方,不是执行副作用的地方。I/O、网络请求、重计算都应该搬到 .task {} 或 ViewModel 的方法里——.task {} 的一个好处是它会在 View 消失时自动取消,不需要手动管理生命周期。

类型安全的路由

SwiftUI 的 NavigationStack 配合 NavigationPath 可以做到完全类型安全的路由。核心思路是把所有路由目标定义成一个 enum,路由操作(跳转、回退、回根)集中在一个 Router 对象里,整个 App 通过 .environment() 共享这个对象。

这样做的好处是路由逻辑集中可审查,跳转动作不散落在各个 View 里,而且编译器会检查 enum case 的完整性——少处理一个目标类型,编译就不通过。

一句话心法

声明式 UI 的设计工作,本质上是在划定「谁拥有什么状态」的边界——边界清楚了,渲染正确性和性能几乎是附赠品。