一次「重跑」到底重跑了什么
上一章说「重组」,其实说的只是三个阶段里的第一个。搞清楚三个阶段之后,你会发现一件很划算的事:同一个滚动动画,写法只差一对花括号,代价能差一个数量级。
一帧里发生的三件事
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()
}
)
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 里不要做重活,也不要有副作用。
本章的可操作产出只有一条清单,明天就能在项目里搜一遍:
在项目里搜这几个模式,看看有没有跟着高频 state 走的:
Modifier.offset( → 能不能换成 offset { }
Modifier.alpha( → 能不能换成 graphicsLayer { alpha = … }
Modifier.padding( → 值是不是动画/滚动算出来的
.value 紧跟着传给 Modifier 参数的地方
判断标准只有一个:这个值一秒钟变几次?
变几十次 → 值得改
变几次 → 别动,可读性更重要
这一章的一句话
一帧分三段:组合、布局、绘制。读取写在哪一段,就只从哪一段开始重来。而「传 lambda 不传值」这一个动作,就能把一次全阶段重来降级成只重画一次。
下一章:既然函数会被反复调用,那 remember 里的东西凭什么能活下来?它到底存在哪儿、按什么认领?答案会解释一个让很多人栽过跟头的现象——把同一段代码从 if 的一个分支挪到另一个分支,状态就没了。