卷 I · 重跑CH 02深度 2/24

一次「重跑」到底重跑了什么

上一章说「重组」,其实说的只是三个阶段里的第一个。搞清楚三个阶段之后,你会发现一件很划算的事:同一个滚动动画,写法只差一对花括号,代价能差一个数量级。

组合布局绘制延迟读取

一帧里发生的三件事

Compose 每一帧固定按这个顺序走:

① 组合 Composition   跑你的 @Composable 函数,产出一棵「要显示什么」的树
② 布局 Layout        每个节点测量自己、摆放孩子,产出位置和尺寸
③ 绘制 Draw          把像素画到画布上

三件事的顺序不可逆,而且只能往后传染

  • 组合重来 → 布局和绘制一定跟着重来
  • 布局重来 → 绘制跟着重来,但组合不动
  • 绘制重来 → 只有绘制动

所以「让一次改动只影响后面的阶段」,是 Compose 里最便宜的优化。而控制它的开关,就是你在哪一层读那个 state

同一个滚动效果,三种写法

三个选项对应三段代码,请对着 demo 一个个切:

// ① 在组合里读 —— 最贵
val dp = offset.value.dp
Box(Modifier.offset(x = dp))

// ② 在布局里读 —— 中等
Box(Modifier.offset {
    IntOffset(offset.value, 0)      // 这是个 lambda,布局阶段才被调用
})

// ③ 在绘制里读 —— 最便宜
Box(Modifier.drawBehind {
    drawRect(colorFor(offset.value))  // 绘制阶段才被调用
})

差别在哪?括号。

Modifier.offset(x = dp) 要求你现在就给出一个具体的 dp 值,于是你必须在组合阶段读 offset.value。而 Modifier.offset { … } 收的是一个 lambda——你给的不是「值」,是「怎么算出值」。这个 lambda 要到布局阶段才被调用,读取也就发生在那时候。

◆ 这一章的核心

Compose 的三个阶段,每个阶段都挂着自己的一套读取观察者。你在哪个阶段读了 state,被标脏的就是哪个阶段的那个「作用域」。

于是同一件事有一个通用的省钱办法,官方叫它延迟读取(defer reads),一句话就是:

传 lambda,不传值。

凡是 Modifier 提供了 lambda 版本的地方(offset {}graphicsLayer {}drawBehind {}padding 的某些重载……),都是在给你这个机会。

这不是理论问题

最典型的场景是跟随滚动的视差效果:顶部图片随着列表滚动往上飘一点。滚动是每帧都在变的,一秒 60 次。

// ❌ 每帧都触发一次组合
val offsetY = listState.firstVisibleItemScrollOffset
Image(
    modifier = Modifier.offset(y = (-offsetY / 2).dp)
)

// ✅ 每帧只触发布局,组合一次都不动
Image(
    modifier = Modifier.offset {
        IntOffset(0, -listState.firstVisibleItemScrollOffset / 2)
    }
)

上面那种写法,滚动一秒钟会让这段 composable 及其所在的 scope 重组 60 次。而这 60 次组合完全是白干的——因为组合产出的树根本没变,只有位置变了。

更狠一点的是 graphicsLayer:它连布局都能跳过。

// ✅✅ 只触发绘制(其实连绘制都是 GPU 层的变换)
Image(
    modifier = Modifier.graphicsLayer {
        translationY = -listState.firstVisibleItemScrollOffset / 2f
        alpha = computeAlpha()
    }
)
✎ 那 animate*AsState 呢

animateFloatAsState 之类的返回的是一个 State<Float>,它每帧都在变。如果你直接 .value 读出来传给 offset(x = …),那这个动画每帧都会引起一次组合。

正确的用法是把这个 State 整个传进 lambda

val alpha by animateFloatAsState(if (visible) 1f else 0f)

// ❌ Modifier.alpha(alpha)           —— 每帧组合
// ✅ Modifier.graphicsLayer { this.alpha = alpha }  —— 每帧只绘制

注意第二行里 alpha 的读取在 lambda 内部,所以它是在绘制阶段被读的。同样一个变量,同样一次读,位置差一层花括号,代价差两个阶段。

怎么判断「我这一下影响了几个阶段」

不用背,问自己一句话就行:

这个值,是现在就要算出来给别人,
还是可以晚点再算?

现在就要 → 组合阶段读 → 三个阶段全重来
晚点再算 → 塞进 lambda → 只重来后面的

再具体一点,三条经验:

  • 会改变「显示什么」的(文字内容、列表项数、显示还是隐藏)——躲不掉组合,也不该躲
  • 只改变「在哪儿、多大」的(偏移、大小、间距)——用布局阶段的 lambda
  • 只改变「什么颜色、多透明、旋转多少」的——用 graphicsLayer 或者绘制 lambda
⚠ 别把这条当成万能药

「延迟读取」只在高频变化的场景下值钱:滚动、拖拽、动画、手势。

一个一天变三次的 state,你把它塞进 lambda 除了让代码难读一点,什么都不会改变。先确认它高频,再动手。

怎么确认?第 23 章会给三个工具,其中一个可以直接在屏幕上画出「哪块区域刚刚重组过」。

顺带说清楚一件事:重组不等于重绘

很多人把「重组次数高」直接当成「性能差」,这不完全对。真实的开销分布大致是这样(数量级,具体值取决于机器和内容):

阶段它在干什么贵在哪
组合调用你的函数、比较参数、维护 slot 表、发射/更新/删除节点你的代码本身 + slot 表操作。如果里面有分配对象、格式化字符串、跑循环,这里最容易爆
布局测量 + 摆放。Compose 保证每个节点每帧最多被测量一次(单遍布局)树的深度和节点数。Modifier 链长也算
绘制录制绘制指令,交给渲染线程过度绘制、大图、复杂 Path、blur 之类的特效

所以「重组 60 次」本身不可怕,可怕的是「重组 60 次,而且每次里面都做了一次 String.format 和一次列表排序」。

组合阶段真正的成本,是你自己在函数体里写的东西。这也是为什么第 1 章那条铁律要反复强调:composable 的 body 里不要做重活,也不要有副作用。

⌗ 到你手上

本章的可操作产出只有一条清单,明天就能在项目里搜一遍:

这一章的一句话

一帧分三段:组合、布局、绘制。读取写在哪一段,就只从哪一段开始重来。而「传 lambda 不传值」这一个动作,就能把一次全阶段重来降级成只重画一次。

下一章:既然函数会被反复调用,那 remember 里的东西凭什么能活下来?它到底存在哪儿、按什么认领?答案会解释一个让很多人栽过跟头的现象——把同一段代码从 if 的一个分支挪到另一个分支,状态就没了。