卷 II · 记住CH 05深度 5/24

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 不需要你 postValuewithContext(Main) 来更新状态。改 state 本身是线程安全的;需要在主线程做的只有「应用变更」,而这件事框架替你做了。

✎ 那我平时用到快照 API 了吗

用到了,只是没看见。你平时的每一次 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。于是读取这个动作本身就完成了订阅——你不需要写任何 observesubscribewatch

上一章说的三个阶段各有自己的观察者,就是这个机制:组合阶段设一个、布局阶段设一个、绘制阶段设一个。你在哪个阶段读的,就被哪个观察者记下。

「读即订阅」是 Compose 全部魔法的来源,也是它全部坑的来源。因为读得太早(在最外层就把值取出来)会订阅到不该订阅的地方——那正是下一章要处理的事。

⚠ 三个常见的「读错地方」
  • remember 的花括号里读 stateremember { state.value * 2 }。这里的读取不会建立订阅(remember 的计算只跑一次),state 变了这个值也不会更新。要么把它写进 key,要么用 derivedStateOf(第 20 章)。
  • LaunchedEffect 的协程里读 state:那不是组合阶段,没有观察者,读到的是当时那一瞬的值。想跟着变,用 snapshotFlow
  • 在事件回调里读onClick = { println(count) }。这是对的、也是安全的——回调不在组合阶段跑,读到的就是当前值。

State 和 StateFlow,到底该用哪个

这是 Android 开发者最常问的一个问题,这里给一个能用的判据。

mutableStateOfMutableStateFlow
属于Compose 运行时kotlinx.coroutines
怎么被观察读取即订阅,不需要协程collect,要协程
线程快照系统保证一致性本身线程安全,值是 conflated 的
能用操作符吗不能(要先 snapshotFlow能,一整套
适合放在UI 层:只有 Compose 会看的状态ViewModel 及以下:要跨层、要变换、要和别的流合并的状态

实际项目里最省事的分法:

  • ViewModel 暴露 StateFlow——它不依赖 Compose,可测试,能和 Repository 的流合并
  • UI 内部的临时状态用 mutableStateOf——展开/收起、输入框草稿、滚动位置。这些东西 ViewModel 不该知道

如果你的 ViewModel 只服务 Compose,直接在 ViewModel 里用 mutableStateOf 也完全可以,官方也支持这么写。唯一别做的事是两边各存一份然后手动同步——那是所有「数据不一致」bug 的源头。

这一章的一句话

State 是「一条带版本的记录链 + 一本读者登记簿 + 一条相等策略」。读取即订阅,赋值先比相等,提交是原子的——你遇到的「改了不动」和「多线程也没事」,都是这三样东西的直接后果。

下一章:既然「谁读了谁重跑」,那么「在哪一行读」就成了一个性能决策。这一章会给出全书最省力的一条优化——它不需要任何注解,改起来通常不超过两行,效果是把 3 个节点的重跑降到 1 个、把 4 次跳过降到 0 次。