第 2 章 状态是唯一的事实来源
15 分钟阅读
第 2 章:状态是唯一的事实来源
第 1 章说"视图是描述"。这一章回答下一个问题:描述依据什么生成? 答案是状态。而 SwiftUI 里绝大多数的 bug,都能归结成一句话——你以为的那个状态,框架没在看。
2.1 @State 到底是什么
先看一段最普通的代码:
| |
第 1 章刚说过"body 里不能改 self",可这里 count += 1 明明改的就是 self 的一部分——为什么合法?
因为 @State 不是存储属性,它是属性包装器:编译器在背后把它换成另一个类型,并提供了一个可写的投影。展开之后大致是这样(📘 这是概念模型,不是合法源码——写出来是为了让你看清 nonmutating 从哪来):
| |
关键在于最后那个 nonmutating set:count 的 setter 不改 self,它改的是 _count 指向的那块外部存储。
🔥 这就是全部秘密:@State 把值存到了视图结构体之外的某个地方,视图里只留一个"取用它的入口"。所以:
- 视图结构体可以是一次性的、被丢弃的值,状态却不会跟着丢;
body里的count += 1改的是那块外部存储,不违反"self不可变";- 这块存储一变,SwiftUI 就知道"这个视图要重画了"。
⚠️ 一条铁律顺势而来:@State 必须标在 var 上。标在 let 上编译器直接拒绝,因为它需要那个 setter:
| |
2.2 @State 的三条规则
规则一:@State 是"视图私有"的,外面不该碰它
@State 的正确用法是这个视图自己用的、外部不关心的数据。一旦你想让父视图也能改它,就该换成 @Binding(下一节)。
⚠️ 常见的错法是给 @State 加 private 之外的可访问性、然后从父视图直接赋值——那等于绕过 SwiftUI 的数据流,会出现"父视图改了、子视图不刷新"的怪事。
💭 惯例:@State 永远配 private。如果你发现需要去掉 private,那通常说明这个状态放错了地方。
规则二:@State 的初始值"惰性 + 只算一次"
像这样给初值(📘 单行示意,不是完整类型):
| |
这个 Self.loadItems() 什么时候执行?答案是两个都出乎直觉:
🔬 实测一:它是惰性的——创建视图值的时候一次都不执行。只有真正开始渲染时才求值:
| |
🔬 实测二:同一个视图反复布局,只执行一次:
| |
💭 这两条合起来的意思是:“这个视图的记忆"在它第一次真正出现时建立,之后就一直沿用。 对性能是好事(Self.loadItems() 这种重活不会反复跑),但也带来一个陷阱:
🝖 顺手看一眼机制:翻 SwiftUICore 的接口文件,State 有两个初始化器——
| |
第二个带 @autoclosure,也就是"先不求值,等真要用了再算”——这正是惰性的来源,而且它专门服务于 @Observable 类。这解释了为什么"用 @State 持有一个 @Observable 模型"在新代码里被推荐:初始化的开销被推迟到你真正需要它的时候。
| |
🔬 实测:父视图把 seed 从 1 改成 2,value 仍然是 1。
| |
init 确实被重新执行了(所以 seed 变成了 2),但那句 _value = State(...) 对已经建立的存储不再起作用。
🔥 记住这条:@State 表达的是"这个视图自己的记忆",不是"父视图传入的值的副本"。 想让内部跟着外部走,你要么用 @Binding,要么用 .onChange(of:) / .task(id:) 显式响应。
规则三:@State 装引用类型时,只认"换掉整个实例"
这是最贵的一课。@State 判断"变没变"的方式是比较那个值本身。装结构体时一切正常;装普通类时:
🔬 实测对比(用 Observation 的追踪机制直接观测,不涉及界面):
| |
| 你写的 | 会发生什么 |
|---|---|
@State private var c = Plain()c.value += 1 | 🛑 界面不刷新。Plain 实例本身没变(还是同一个指针),框架看不出任何变化 |
@State private var c = Plain()c = Plain() | ✅ 刷新。整个实例被换掉了,框架看得见 |
@State private var c = Tracked()(@Observable)c.value += 1 | ✅ 刷新。@Observable 会逐属性上报变化 |
💭 一句话:@State 适合装值类型;要装对象并观察它的属性变化,那个对象必须标 @Observable。 下面第 2.4 节会把这件事讲透。
2.3 @Binding:把"写权限"借出去
子视图想改父视图的状态,但它不该拥有那份状态。这时用 @Binding:
| |
$ 是什么
$count 拿到的是 Binding<Int>——一个读写通道,不是值的拷贝。子视图通过它读到的永远是父视图当前的值,写进去的也直接落到父视图的状态上。
| 写法 | 拿到什么 | 用在哪 |
|---|---|---|
count | Int(当前值) | 在 body 里读、参与运算 |
$count | Binding<Int>(读写通道) | 传给子视图、传给需要绑定的控件 |
⚠️ 三个高频报错,都跟这个有关。为了看清它们,先约定一个"需要绑定"的子视图:
| |
现在看三种写法错在哪:
| |
🔥 三条规律:
| 情况 | $ 能不能用 |
|---|---|
@State / @Binding / @Bindable 属性 | ✅ 有 $ |
普通 let / 普通 var 属性 | ❌ 没有 $,报 cannot find '$x' in scope |
| 需要绑定参数的控件,却传了裸值 | ❌ 报 cannot convert value of type 'T' to expected argument type 'Binding<T>' |
💭 记忆法:$ 只在"需要别人能改"的时候用。读值不加 $,传绑定加 $。
@Binding 值不值得用
@Binding 会让子视图不能被单独预览(它需要一个外部的状态来源)。如果你的子视图只是想显示数据、不想改数据,就老老实实传值:
| |
🔥 判据:子视图要"改"才用 @Binding,只"看"就传普通值。
2.4 @Observable:给对象装上"变化上报"
@State 装普通类不刷新,那共享的对象数据该怎么办?答案是 @Observable(Swift 5.9 / iOS 17 起):
| |
@Observable 是宏,它给每个存储属性加上"被读时登记、被写时上报"的能力。关键特性是它是按属性追踪的:
🔬 实测:只读过 count,然后改 name,不会触发;改 count 才触发。
| |
这意味着"改一个属性只刷新用到它的视图",而不是"改任何属性全量刷新"。这是它相比老的 ObservableObject 最大的改进。
怎么用
| |
| |
⚠️ 注意第二个例子:子视图接收 @Observable 对象时用朴素的 let,不需要任何 @ 前缀。这和老的 @ObservedObject 写法完全不同,也是很多人从旧教程迁移时最容易多写的地方。
| 你的意图 | 该写什么 |
|---|---|
| 我创建并拥有这个模型 | @State private var model = Model() |
| 别人给我,我只读 | let model: Model |
| 别人给我,我要改它的属性并生成绑定 | @Bindable var model: Model |
| 从环境里取 | @Environment(Model.self) private var model |
@Bindable:给可观察对象造绑定
@Observable 对象的属性要接到 TextField、Toggle 这类需要 Binding 的控件上时,用 @Bindable:
| |
⚠️ @Bindable 只能用在对象上。套在结构体上会报:
| |
💭 @State 和 @Bindable 的分工:@State 负责"拥有",@Bindable 负责"给别人的对象造绑定"。
2.5 @Environment:看不见的传递通道
有些值需要穿过很多层视图,一层层传太啰嗦。@Environment 让它们"从空气里取":
| |
自定义环境值现在用 @Entry(Swift 6 / iOS 18 起),比老的 EnvironmentKey 样板省很多:
| |
⚠️ 环境值的默认值必须有。忘了给默认值、又没有注入,会直接崩溃(@Entry 要求你写默认值,就是为了堵住这个)。
2.6 选择判据:一张决策表
不用背,遇到新状态时按顺序问自己:
| 问题 | 是 | 否 |
|---|---|---|
| 这个数据是这个视图自己的记忆吗? | @State private | 往下问 |
| 是父视图给的、我需要改它吗? | @Binding | 往下问 |
| 是一个要在多个视图间共享的对象吗? | @Observable 类型 + 拥有者用 @State | 往下问 |
| 需要穿过很多层、或者本身就是环境概念吗? | @Environment | 传普通值 |
再补三条经验:
| 场景 | 建议 |
|---|---|
| 派生数据(能算出来的) | 不要存成状态。写成计算属性,避免两份数据打架 |
| 只在子视图内部用 | 放在最靠近使用处的那个视图里,别提前上提到顶层 |
| 需要持久化 | @AppStorage(小配置)或写文件/数据库(正式数据) |
🔥 最重要的一条:能算出来的就不要存。 两份互相矛盾的状态,是所有诡异 bug 的根源。
2.7 「改了数据界面不刷新」的三种病因
这是 SwiftUI 最经典的 bug,症状一样,病因通常是这三种。按出现频率排:
病因一:用普通类当状态(最高频)
⚠️ 先说清楚:下面这段能编译,问题全在运行期——点按钮,界面纹丝不动。
| |
诊断:给类加上 @Observable。根因:Model 实例本身没变(还是同一个引用),SwiftUI 没有任何途径知道它的属性被改了。
💭 这类"编译通过、运行期不工作"的问题,才是 SwiftUI 最费时间的一类 bug——编译器帮不了你,只能靠数据流的模型去推。
病因二:状态放错了位置
⚠️ 同样能编译,问题在于"够不着":
| |
单独看没问题,但如果你想"一键全部取消赞",就会发现够不着这些状态。
诊断:把状态上提到父视图,用 @Binding 传下去,或者在模型里存一个 Set<ID>。
病因三:数组里的元素是引用类型
这是病因一的"变种",而且更隐蔽,因为它看起来是在改数组:
| |
为什么不动? 因为 items 这个数组本身没有变——变的只是它指向的那个对象内部的属性。SwiftUI 比较的是数组的值,数组里存的还是同一个引用,所以它认为"什么都没发生"。
⚠️ 对照组:如果 Item 是 struct,那么 items[0].name = ... 会真的修改数组里那个元素的值,数组变了,界面就刷新。
| 数组元素类型 | items[0].name = "x" 的结果 |
|---|---|
struct Item | ✅ 数组值变了,界面刷新 |
普通 class Item | 🛑 数组看起来没变,界面不动 |
@Observable class Item | ✅ 属性变化会被上报,界面刷新 |
诊断:把 Item 改成 struct(首选,值语义更简单),或者给它加 @Observable。
顺便澄清一个流传很广的误解
网上常有一种说法:"@Observable 的追踪只在 body 里直接读属性才有效,一旦把值存进局部变量、或者通过函数读,就追踪不到了。"
🔬 实测:这是错的。 三种读法全都正常触发(用 withObservationTracking 观测,改 total 后看回调):
| 读法 | 是否触发 |
|---|---|
body: { _ = $0.total } 直接读 | ✅ 触发 1 次 |
body: { let s = $0.total; _ = s } 先存局部常量 | ✅ 触发 1 次 |
body: { _ = helper($0) } 通过普通函数读 | ✅ 触发 1 次 |
原因:@Observable 追踪的是"这个属性有没有在本次求值过程中被访问过",跟"在哪一行、隔着几层函数"毫无关系。
💭 所以你不必为了"保住追踪"而刻意避免辅助函数或局部变量——该抽函数就抽。真正会让追踪失效的,是前面那三种病因(类没标 @Observable、状态放错位置、数组装普通类)。
定位工具:Self._printChanges()
这是 SwiftUI 自带的诊断方法(macOS 12 / iOS 15 起),在 body 开头调用,它会在控制台打印出这次 body 为什么被调用、哪个属性变了:
| |
输出形如:
| |
🔥 这是排查"刷新问题"的第一把工具。看到 @self changed 说明这个视图被整个重建了(通常是身份变了,见第 6 章);看到具体属性名就说明是那个属性触发的。
⚠️ 它会污染控制台、也有性能开销,只用来临时定位,定位完删掉。
2.8 完整可运行文件
| |
📦 这一段是
View+#Preview,没有@main:新建Chapter02.swift放进 Xcode 的 App 项目,再把 App 文件里的WindowGroup { ContentView() }改成WindowGroup { Chapter02Demo() };只想看效果就直接看#Preview——预览不需要入口。
⚠️ 这个文件里藏着三个容易写错的地方,对照一下你有没有踩:
| 位置 | 为什么这么写 |
|---|---|
CartSummary(cart: cart) | 只读,传对象(不是 $cart) |
CartEditor(cart: cart) | 同样传对象;@Bindable 在子视图内部声明 |
AddButton(names: $cart.items) | 要改数组,传绑定($);注意 $ 加在 cart 上而不是 items 上 |
2.9 本章易错点速查
| 你会怎么写 | 实际发生什么 | 正确做法 |
|---|---|---|
@State private let x = 0 | '@State' can only be applied to a 'var' declaration | 改成 var |
普通类 + @State,改属性 | 界面不刷新 | 类加 @Observable |
从 let 属性取 $x | cannot find '$x' in scope | 只有 @State/@Binding/@Bindable 才有 $ |
Child(n: count) 想传绑定 | cannot convert value of type 'Int' to expected argument type 'Binding<Int>' | 写 Child(n: $count) |
子视图收 @Observable 对象写成 @ObservedObject | 老教程的写法,新代码里多余甚至报错 | 直接写 let model: Model |
@Bindable 用在 struct 上 | The wrapped value must be an object | @Bindable 只用于类 |
派生数据也存一份 @State | 两份数据不一致,bug 难查 | 写成计算属性 |
初始值放进 init 里赋给 @State | 只在该视图首次出现时生效,seed 变了它不跟随(实测 seed=2 时 value 仍是 1) | 用 @Binding 或 .onChange(of:) |
以为 @State 的初值表达式在创建视图时就算 | 它是惰性的:不渲染就不求值 | — |
忘了给 @Environment 自定义值默认值 | 运行期崩溃 | @Entry var x: T = 默认值 |
2.10 下一章
到现在为止,你已经能正确管理状态了。但还有一个更基础的问题没解决:这些视图到底怎么决定自己该多大、摆在哪?
这是 SwiftUI 里唯一"不看数字就学不会"的部分,也是绝大多数界面问题的根源。下一章我们用实测数据把布局规则拆开——包括 Spacer 到底分走多少、frame 的四层含义、以及为什么你的视图总是不听话。