State 不是变量,是一个被人盯着的格子
mutableStateOf(0) 看起来只是个装了 0 的盒子。它其实是一条带版本号的记录链,外加一本「谁读过我」的登记簿。搞清楚这两样东西,Compose 里一半的「为什么」就有答案了。
一个 State 里有三样东西
MutableState<T> ├ 一条记录链 同一个格子在不同快照里可以有不同的值 ├ 一本读者登记簿 谁在读我的时候被观察到了 └ 一条相等策略 什么样的赋值算「真的变了」
三样各管一件事,而且各自解释了一类现象:
- 记录链 → 为什么 UI 一帧里看不到半截数据,为什么你可以在后台线程改 state
- 读者登记簿 → 为什么「谁读了谁重跑」(下一章的全部内容)
- 相等策略 → 为什么你「明明改了」界面却不动
从最后一条开始,因为它最常咬人。
先说相等策略:为什么改了却不动
mutableStateOf 默认用结构相等(structuralEqualityPolicy)。赋值时它先比一下:
state.value = newValue // 内部大意: // if (policy.equivalent(oldValue, newValue)) return // 相等 → 什么都不做 // 记录新值,通知所有读者
「什么都不做」包括:不通知任何人。于是这段代码什么也不会发生:
// ❌ 界面纹丝不动 val list = state.items // 拿到的是同一个 MutableList list.add(newTodo) // 就地改 state.items = list // 赋回去 —— 但 old === new,equals 当然相等
你改了内容,但你给 state 的还是同一个对象。Compose 一比:一样。于是它当作没发生。
这不是 bug。Compose 判断「变没变」的唯一依据就是这条相等策略,你没有告诉它变了,它就不知道。修法只有一条:
// ✅ 换一个新对象
state.items = state.items + newTodo
// 或者用一开始就会自己通知的容器
val items = remember { mutableStateListOf<Todo>() }
items.add(newTodo) // SnapshotStateList,它自己会通知
三种相等策略,选择的场合很清楚:
| 策略 | 什么算「变了」 | 什么时候用 |
|---|---|---|
structuralEqualityPolicy()默认 | !=(也就是 equals 不等) | 绝大多数情况。data class 用它最合适 |
referentialEqualityPolicy() | !==(引用不同) | 类型没写 equals、或者 equals 很贵的时候 |
neverEqualPolicy() | 永远算变了 | 你确实需要每次赋值都触发一次。用到它通常说明建模有问题 |
记录链:一个格子可以有好几个值
这是 Compose 最漂亮、也最少被讲到的一块设计。
MutableState 内部存的不是一个值,是一串带 id 的记录:
count 这个格子:
┌ 记录 { snapshotId = 12, value = 0 }
├ 记录 { snapshotId = 17, value = 10 }
└ 记录 { snapshotId = 21, value = 99 }
「在快照 S 里读 count」= 找 id ≤ S.id 且对 S 可见的最后一条
而快照(Snapshot)就是「看世界的一个视角」。UI 渲染一帧的时候会拿一个快照,在这个快照里读到的所有 state 都是同一个时刻的值。别人在这期间怎么改,都不会影响这一帧看到的东西。
demo 里的最后两行是重点:
- A.apply() 是原子的——它一次改了 2 个 state,外面要么全看见、要么全看不见。不会出现「count 是新的、name 还是旧的」这种半截状态
- B.apply() 失败了——两个可变快照改了同一个格子,后提交的那个拿到冲突
如果这套东西让你想起数据库的 MVCC 和事务,那你想对了:它就是 MVCC,只是跑在内存里、给 UI 用。
快照系统让 Compose 能同时保证两件通常互相打架的事:
- 你可以在任何线程上改 state——写进的是你自己那个快照的记录
- UI 那一帧永远看到一致的数据——它读的是自己那个快照
这就是为什么 Compose 不需要你 postValue/withContext(Main) 来更新状态。改 state 本身是线程安全的;需要在主线程做的只有「应用变更」,而这件事框架替你做了。
用到了,只是没看见。你平时的每一次 state.value = x 都写在全局快照里,框架会定期把它应用出去。
需要你显式用到的场景很少,最常见的一个是「一批修改必须一起生效」:
Snapshot.withMutableSnapshot {
// 这几行要么一起生效,要么一起不生效
// UI 不会看到中间状态
selection = newSelection
cursor = newCursor
scrollTarget = newTarget
}
另一个是在非 Compose 线程里改一堆状态之后统一提交。日常业务代码里几乎碰不到,但知道它存在,你就能理解为什么 Compose 的状态更新不用切线程。
读者登记簿:Compose 怎么知道谁读了
第三样东西最简单,但它是整个响应式机制的核心。
state.value 的 getter 里有一句大意如此的代码:
get() {
currentReadObserver?.invoke(this) // ← 告诉当前的观察者:有人读我了
return currentRecord().value
}
而 Compose 在跑一个 composable 之前,会把「当前观察者」设成这个 composable 的 RecomposeScope。于是读取这个动作本身就完成了订阅——你不需要写任何 observe、subscribe、watch。
上一章说的三个阶段各有自己的观察者,就是这个机制:组合阶段设一个、布局阶段设一个、绘制阶段设一个。你在哪个阶段读的,就被哪个观察者记下。
「读即订阅」是 Compose 全部魔法的来源,也是它全部坑的来源。因为读得太早(在最外层就把值取出来)会订阅到不该订阅的地方——那正是下一章要处理的事。
- 在
remember的花括号里读 state:remember { state.value * 2 }。这里的读取不会建立订阅(remember的计算只跑一次),state 变了这个值也不会更新。要么把它写进 key,要么用derivedStateOf(第 20 章)。 - 在
LaunchedEffect的协程里读 state:那不是组合阶段,没有观察者,读到的是当时那一瞬的值。想跟着变,用snapshotFlow。 - 在事件回调里读:
onClick = { println(count) }。这是对的、也是安全的——回调不在组合阶段跑,读到的就是当前值。
State 和 StateFlow,到底该用哪个
这是 Android 开发者最常问的一个问题,这里给一个能用的判据。
mutableStateOf | MutableStateFlow | |
|---|---|---|
| 属于 | Compose 运行时 | kotlinx.coroutines |
| 怎么被观察 | 读取即订阅,不需要协程 | 要 collect,要协程 |
| 线程 | 快照系统保证一致性 | 本身线程安全,值是 conflated 的 |
| 能用操作符吗 | 不能(要先 snapshotFlow) | 能,一整套 |
| 适合放在 | UI 层:只有 Compose 会看的状态 | ViewModel 及以下:要跨层、要变换、要和别的流合并的状态 |
实际项目里最省事的分法:
- ViewModel 暴露
StateFlow——它不依赖 Compose,可测试,能和 Repository 的流合并 - UI 内部的临时状态用
mutableStateOf——展开/收起、输入框草稿、滚动位置。这些东西 ViewModel 不该知道
如果你的 ViewModel 只服务 Compose,直接在 ViewModel 里用 mutableStateOf 也完全可以,官方也支持这么写。唯一别做的事是两边各存一份然后手动同步——那是所有「数据不一致」bug 的源头。
StateFlow 作为一个语言层面的并发原语——它的 conflation、WhileSubscribed(5000) 里那个 5000 是什么、和 SharedFlow 的关系——《坩埚》第 19 章讲得更细。
这本书从另一个角度切:它什么时候引起重跑、后台的时候要不要停。第 16 章和第 19 章会把这两件事量出来。
这一章的一句话
State 是「一条带版本的记录链 + 一本读者登记簿 + 一条相等策略」。读取即订阅,赋值先比相等,提交是原子的——你遇到的「改了不动」和「多线程也没事」,都是这三样东西的直接后果。
下一章:既然「谁读了谁重跑」,那么「在哪一行读」就成了一个性能决策。这一章会给出全书最省力的一条优化——它不需要任何注解,改起来通常不超过两行,效果是把 3 个节点的重跑降到 1 个、把 4 次跳过降到 0 次。