卷 II · 记住CH 06深度 6/24

谁读了谁重跑:失效是怎么精确传播的

「在哪一行读这个 state」不是风格问题,是性能决策。这一章给出全书最省力的一条优化:它不需要任何注解、不需要改数据结构,改起来通常不超过两行。

RecomposeScope延迟读取lambda重组范围

失效传播只有三步

① 跑 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 选中项、展开收起、勾选不用。可读性更重要
加载(几次/页面)网络返回的数据不用
⚠ 别把 lambda 写成 State

有个看起来更聪明的写法,但它是错的:

// ❌ 参数类型是 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 照样每帧脏一次,照样把十个小函数全部走一遍(能跳的跳掉,但你已经付了走一遍的钱)。

拆函数    →  改变了「哪些东西可以被跳过」
移读取    →  改变了「谁会被标脏」

后者决定了从哪里开始,前者只决定路上能省多少。
从哪里开始,比路上省多少重要得多。

顺序应该反过来:先把读取推到该在的地方,再考虑要不要拆函数。

⌗ 到你手上

这一章有一个具体的、二十分钟能做完的动作:

顺带回答一个常见疑问

「那我在 ViewModel 里把 UiState 拆成好几个 StateFlow,是不是也一样?」

不一样,但方向对。拆成多个 StateFlow 之后,你在 UI 里仍然可能在最外层把它们全读出来——那还是同一个问题。

真正的差别在于:拆开之后,你才有可能让不同的组件各自读自己那一份。一个大 UiState 只能在一个地方读,读了就整屏脏;拆开之后你可以让 TopBar 只读 titleInputBar 只读 draft

第 23 章有一台 demo 把这笔账算完了:同一个屏幕、敲 30 个字,一个大 UiState 的写法要走过 150 个节点,拆开之后只要走 30 个——而两边「真的重跑」的次数几乎一样。省下的全是「走过去比一遍参数」的钱。

这一章的一句话

失效是点对点的:state 直接通知读它的那几个 scope。所以「在哪一行读」就是「哪一块会重跑」。传 lambda 不传值,是这本书里性价比最高的一条改动。

下一章:既然读取的位置这么重要,那状态本身该放在哪一层就成了一个必须回答的问题。「状态提升」这个词你听过,但它有一条常被忽略的下半句——提得太高,问题一样多。