谁读了谁重跑:失效是怎么精确传播的
「在哪一行读这个 state」不是风格问题,是性能决策。这一章给出全书最省力的一条优化:它不需要任何注解、不需要改数据结构,改起来通常不超过两行。
失效传播只有三步
① 跑 composable 的时候,把「当前 scope」设成它的 RecomposeScope ② 期间任何一次 state 读取,都在那个 state 的读者名单上登记这个 scope ③ state 被改 → 挨个通知名单上的 scope:「你脏了」 下一帧 → 从脏的那些 scope 开始重跑
三步里没有一步涉及「树」「父子」「层级」。失效不沿着树往下传,它是点对点的:state 直接找到读它的那几个 scope。
这就是为什么第 1 章那个例子里,敲一个字只重跑 2 个节点:读 draft 的 scope 只有一个。
那么问题变成了:读取写在哪一行
下面这台 demo 里是同一个屏幕、同一棵树(10 个节点)、同一个 count,唯一的差别是那一行读取写在哪儿:
结果差得比大多数人预期的大:
同一棵树,10 个节点,改一次 count: ① 在最外层把 state 读出来 重跑 3 · 跳过 4 · 没走到 3 ② 把读取推到用它的那一层 重跑 1 · 跳过 0 · 没走到 9
注意第二行的「跳过 0」。不是「跳过得多所以快」,而是压根没走到那里。Screen、Header、Body、Footer 的函数这一轮一次都没被调用——没有参数比较,没有 slot 表游标移动,什么都没有。
两种写法的代码差别
// ① 在最外层读 —— Screen 自己的 scope 被标脏
@Composable
fun Screen(vm: VM) {
val n = vm.count.value // ← 读在这儿
Header()
Body()
Counter(n) // 值一路传下去
Footer()
}
// ② 把读取推到用它的那一层 —— 只有 CountText 的 scope 被标脏
@Composable
fun Screen(vm: VM) {
Header()
Body()
Counter(count = { vm.count.value }) // ← 传的是「怎么读」,不是「读到的值」
Footer()
}
@Composable
fun Counter(count: () -> Int) {
Row {
CountText(count) // 继续往下传 lambda
PlusButton()
}
}
@Composable
fun CountText(count: () -> Int) {
Text("${count()}") // ← 读取真正发生在这里
}
差别只有一个:参数类型从 Int 变成了 () -> Int。
传 lambda,不传值。
官方文档管这个叫延迟读取(defer reads)。它的原理只有一句:读取发生在哪个 scope 里,被标脏的就是哪个 scope。把读取包进 lambda,就等于把它搬到调用它的那个函数里去执行。
这条优化的好处是它不改变数据结构、不需要注解、不需要新依赖。缺点是可读性——所以别到处用,只用在真正高频的那几个 state 上。
什么时候值得这么做
判据只有一个:这个 state 一秒变几次。
| state 的变化频率 | 例子 | 要不要延迟读取 |
|---|---|---|
| 每帧(60/秒) | 滚动偏移、拖拽位置、动画进度 | 一定要。而且优先用第 2 章那种「推到布局/绘制阶段」的办法,比传 lambda 还彻底 |
| 打字(几次/秒) | 输入框草稿、搜索关键词 | 值得。尤其是这个屏幕上还有一个大列表的时候 |
| 交互(几次/分钟) | tab 选中项、展开收起、勾选 | 不用。可读性更重要 |
| 加载(几次/页面) | 网络返回的数据 | 不用 |
有个看起来更聪明的写法,但它是错的:
// ❌ 参数类型是 State<Int>
@Composable
fun Counter(count: State<Int>) {
Text("${count.value}")
}
这样确实也能延迟读取,但你把 Compose 的类型泄漏进了组件签名:这个 Counter 从此不能在预览里用普通值调用、不能在测试里传常量、不能被非 Compose 的调用方复用。
() -> Int 没有这个问题:Counter { 42 } 随时能用。一律用函数类型,不用 State 类型。
另一个方向:把「读」和「用」分开
除了传 lambda,还有一个更结构化的做法:把读 state 的那部分单独抽成一个 composable。
// ❌ 一个大函数,最外层读了三个 state
@Composable
fun Dashboard(vm: VM) {
val user = vm.user.value
val notifications = vm.notifications.value
val scroll = vm.scroll.value // ← 每帧都变
…一百行界面…
}
// ✅ 把「读 scroll 的那一小块」隔离出来
@Composable
fun Dashboard(vm: VM) {
val user = vm.user.value
val notifications = vm.notifications.value
…一百行界面…
ScrollIndicator(vm) // 它自己在里面读 scroll
}
@Composable
private fun ScrollIndicator(vm: VM) {
val scroll = vm.scroll.value // ← 只有这个小函数会被标脏
LinearProgressIndicator(progress = { scroll })
}
这个手法在 Compose 社区里有个名字,叫「重组范围下沉」。它比传 lambda 可读性好,代价是多一个函数。
什么时候用哪个:需要跨好几层往下传的用 lambda;只在一处用的,抽个私有 composable。
一个反直觉的推论:函数拆得小不一定有用
回到第 1 章那个警告,现在可以把它说透了。
很多人听说「拆小函数有利于跳过」,于是把一个大 composable 拆成十个小的。如果那个高频 state 还是在最外层读的,一点用都没有——最外层的 scope 照样每帧脏一次,照样把十个小函数全部走一遍(能跳的跳掉,但你已经付了走一遍的钱)。
拆函数 → 改变了「哪些东西可以被跳过」 移读取 → 改变了「谁会被标脏」 后者决定了从哪里开始,前者只决定路上能省多少。 从哪里开始,比路上省多少重要得多。
顺序应该反过来:先把读取推到该在的地方,再考虑要不要拆函数。
这一章有一个具体的、二十分钟能做完的动作:
1. 找出项目里最卡的那个屏幕
2. 在它最外层的 composable 里,把所有 `.value` 和 `by` 读取列出来
3. 对每一个问:
这个值一秒变几次?
它被下面几个子组件用到?
高频 + 只被一两个地方用 → 就是你要改的那一个
4. 改法二选一:
跨多层 → 参数从 T 改成 () -> T
只一处 → 把那一小块抽成私有 composable,在里面读
改完用 Layout Inspector 对比 Recomposition count。
这一条优化通常在十分钟内见效,而且不会带来任何新的复杂度。
顺带回答一个常见疑问
「那我在 ViewModel 里把 UiState 拆成好几个 StateFlow,是不是也一样?」
不一样,但方向对。拆成多个 StateFlow 之后,你在 UI 里仍然可能在最外层把它们全读出来——那还是同一个问题。
真正的差别在于:拆开之后,你才有可能让不同的组件各自读自己那一份。一个大 UiState 只能在一个地方读,读了就整屏脏;拆开之后你可以让 TopBar 只读 title、InputBar 只读 draft。
第 23 章有一台 demo 把这笔账算完了:同一个屏幕、敲 30 个字,一个大 UiState 的写法要走过 150 个节点,拆开之后只要走 30 个——而两边「真的重跑」的次数几乎一样。省下的全是「走过去比一遍参数」的钱。
这一章的一句话
失效是点对点的:state 直接通知读它的那几个 scope。所以「在哪一行读」就是「哪一块会重跑」。传 lambda 不传值,是这本书里性价比最高的一条改动。
下一章:既然读取的位置这么重要,那状态本身该放在哪一层就成了一个必须回答的问题。「状态提升」这个词你听过,但它有一条常被忽略的下半句——提得太高,问题一样多。