你写的不是界面,是一个会被重跑的函数
你已经会写 Compose 了。但如果我现在问你:「在输入框里敲一个字,屏幕上 13 个 composable 里有几个会被重新调用?」——你大概答不上来。这一章就是要让你能答上来,而且是精确到个位数地答上来。
从一句听腻了的话开始
「Compose 是声明式的:UI = f(state)。」
这句话每份教程都会写,而且它是对的。问题是它把最关键的一半省掉了。完整版应该是:
UI = f(state) 其中 f 会被反复调用, 你不知道什么时候调、也不知道调多少次, 而且它每次调用的范围可能不一样。
后面这三行才是你每天真正要打交道的东西。f 不是跑一遍就完事的——它每一帧都可能被叫起来一部分。你写在函数体里的每一句话,都会在你不知情的时候被再跑一遍、十遍、一百遍。
于是问题从「怎么把界面画出来」变成了:这一次,到底是哪几句话被重跑了?
一个 composable 在某一轮里有三种下场,不是两种:
- 重跑——它的 body 真的被执行了一遍
- 跳过——走到它了,比过参数,决定不执行
- 没走到——这一轮压根没人调用它
大多数人只知道前两种,于是永远想不明白「为什么改一个字,整屏都在动」。「跳过」和「没走到」的差别,是这本书前六章的全部内容。
先自己点一遍
下面这台东西是真的:一个 slot 表、一套读取观察、一套失效传播、一套跳过判定。你点一下按钮,它就照着 Compose 的规则重组一次,然后把每个节点这一轮的下场标出来。
先别急着往下读,把三个按钮各点一次。你会看到三组完全不同的数字:
同一棵树,13 个节点: 在输入框里敲一个字 重跑 2 · 跳过 0 · 没走到 11 勾掉一条待办 重跑 5 · 跳过 5 · 没走到 3 改标题 重跑 2 · 跳过 4 · 没走到 7
第一行是最反直觉的:敲一个字,13 个节点里有 11 个连碰都没碰。不是「被跳过了」,是这一轮根本没有任何代码调用过它们。
为什么?因为重组不从根开始
这是全书第一个需要你改掉的直觉。
你脑子里的模型大概是这样:state 变了 → 从屏幕最外层的那个 composable 开始重新跑一遍 → 一路往下,能跳过的跳过。
不是这样。Compose 的模型是:
每一个 composable 调用,在 slot 表里对应一段 group, group 上挂着一个 RecomposeScope。 谁在这个 scope 里读了某个 state, 就在那个 state 上登记一笔:「我读了你」。 state 变了 → 它挨个通知登记过的 scope:「你脏了」。 下一帧 → 只从脏的那些 scope 开始重跑。
所以「敲一个字」的时候,事情是这样的:draft 这个 state 的读者只有 InputBar 一个。它被标脏,重组器从它开始跑,跑出一个 SendButton。上面的 TodoScreen、TopBar、TodoList,它们的函数根本没有被调用——不需要跳过,因为没人走到它们那里。
中文的「重组」听起来像是「把整个界面重新组装一遍」。英文原词是 recomposition,但实际发生的事更接近 re-invoke a few functions:重新调用其中几个函数。
如果你把它读成「重新调用那几个被标脏的函数」,后面所有内容都会顺很多。
那「跳过」是什么时候发生的
看第二行:勾掉一条待办,重跑 5 个,跳过 5 个。为什么这次有跳过了?
因为这次被标脏的是屏幕自己。看这段(demo 里那棵树的代码骨架):
@Composable
fun TodoScreen(vm: TodoViewModel) {
TopBar(title = vm.title)
Summary()
TodoList(items = vm.items) // ← 这里读了 items
InputBar()
Footer(txt = "长按可以拖动排序")
}
vm.items 是在 TodoScreen 的函数体里读的——注意,不是在 TodoList 里读的,是在调用 TodoList 的那一行读的,为了把值传进去。所以登记读者的是 TodoScreen 的 scope。
items 变了 → TodoScreen 脏了 → 它的整个 body 重跑一遍 → 五个子调用一个一个走过去。走到 TopBar 的时候,Compose 会问一句:
这个 group 的 scope 脏吗? 不脏 参数和上次一模一样吗? title 还是「今天」,一样 → 那就跳过:整段 group 连同它下面的一切,全部不执行。
于是 TopBar 是「跳过」,而 TopBar 里面的东西是「没走到」。跳过一个节点,等于让它整棵子树都变成「没走到」。
三个词,三种代价
| 下场 | 发生了什么 | 代价 |
|---|---|---|
| 重跑 | 函数体真的执行,里面的 remember 检查 key、子调用一个个发出去 | 最贵。你写的每一行都跑了一遍 |
| 跳过 | 走到它、比一遍参数、决定不执行,然后把 slot 表的游标直接跳到这段 group 的末尾 | 不是零。参数比较 + slot 表游标移动。单个很便宜,一棵大树上每帧走一遍就不便宜 |
| 没走到 | 什么都没发生 | 零 |
中间那一行值得多看两眼。很多人以为「反正会跳过,无所谓」——跳过是有价格的,只是价格低。第 23 章会用一个具体的例子量出来:同一个屏幕、同一串输入,一种写法要走过 150 个节点,另一种只要 30 个,而两边「真的重跑」的次数几乎一样。
看完这一章,很多人的第一反应是:「那我把所有 composable 都拆细一点,就都能跳过了。」
方向是对的,但拆细本身不解决问题。真正决定范围的不是函数有多小,是「这个 state 是在哪一层被读的」。你可以有一个只有三行的 composable,因为在最外层读了一个高频 state,照样每帧带着整棵子树走一遍。
这件事第 6 章会正面处理,而且答案只有一句话,改起来通常不超过两行。
这和你熟悉的那套有什么不一样
如果你写过 View 系统,那边的模型是:你持有对象,你改它。textView.text = "新的"——你精确地知道改了谁,因为是你亲手改的。
Compose 反过来:你不持有任何 UI 对象。你只写「给我这个 state,我告诉你界面长什么样」,然后由框架去决定重新问你哪几个部分。你从「改 UI 的人」变成了「回答问题的人」。
这个交换带来的好处是显然的:界面和数据永远不会不同步,因为界面就是数据算出来的。代价也同样明确——你失去了「什么时候执行」的控制权。你写的每一行都可能被跑很多次,也可能一次都不跑。
@Composable 不是一个普通注解。Compose 编译器插件会把每个 composable 函数改写——加一个隐藏参数 $composer,在函数体前后插入 startRestartGroup/endRestartGroup,把 remember 换成对 slot 表的读写,还会在能跳过的地方插入一段「参数没变就 skipToGroupEnd()」的判断。
换句话说:你写的那个函数和最终跑起来的那个函数不是同一个函数。这和 suspend 是同一种手法——第 9 章会把 suspend 的改写完整地拆开给你看,那台变换器也是真的。
从 Kotlin 2.0 起,这个插件已经跟着 Kotlin 版本一起走了(org.jetbrains.kotlin.plugin.compose),不再需要单独对版本表。
把这一章变成一个习惯
从现在开始,每次你写下一个 composable,脑子里过一遍这三个问题:
- 它读了哪些 state?——这决定了它什么时候被标脏
- 它的参数会不会经常换新对象?——这决定了它能不能被跳过(第 4 章)
- 它上面的那一层,是不是替它读了本该它自己读的东西?——这决定了它的兄弟们要不要陪跑(第 6 章)
这三个问题,就是这本书前两卷的全部。
真机上你可以直接看到这三个数字,不需要猜:
Android Studio ▸ 运行 App ▸ 右下角 Layout Inspector
▸ 打开 Compose 树
▸ 右侧属性面板顶部有两个数:
Recomposition count ← 这一章的「重跑」
Skip count ← 这一章的「跳过」
第一次打开需要在 build.gradle.kts 里开:
android { buildTypes { debug { … } } }
并确保用的是 debuggable 构建
「没走到」那个数它不显示——因为它就是「两个数都没涨」。一个节点的两个数都不动,说明这一轮它根本没被访问过,这通常是好消息。
这一章的一句话
重组不从根开始,从「读了那个 state 的那个 scope」开始。所以一个 composable 有三种下场:重跑、跳过、没走到——而你真正要优化的,是把尽可能多的节点推到第三种。
下一章:上面说的「重跑」其实还不够精确。Compose 一帧分成组合、布局、绘制三个阶段,而「重组」只是第一个阶段。同一个 state,读在哪一层,就只重跑哪一层往后的部分——这里藏着一个只差一对花括号的优化。