卷 I · 重跑CH 01深度 1/24

你写的不是界面,是一个会被重跑的函数

你已经会写 Compose 了。但如果我现在问你:「在输入框里敲一个字,屏幕上 13 个 composable 里有几个会被重新调用?」——你大概答不上来。这一章就是要让你能答上来,而且是精确到个位数地答上来。

重组跳过没走到RecomposeScope

从一句听腻了的话开始

「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。上面的 TodoScreenTopBarTodoList它们的函数根本没有被调用——不需要跳过,因为没人走到它们那里。

✎「重组」这个词有点误导

中文的「重组」听起来像是「把整个界面重新组装一遍」。英文原词是 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 的人」变成了「回答问题的人」。

这个交换带来的好处是显然的:界面和数据永远不会不同步,因为界面就是数据算出来的。代价也同样明确——你失去了「什么时候执行」的控制权。你写的每一行都可能被跑很多次,也可能一次都不跑。

⎇ 顺带说 Kotlin:这为什么必须靠编译器插件

@Composable 不是一个普通注解。Compose 编译器插件会把每个 composable 函数改写——加一个隐藏参数 $composer,在函数体前后插入 startRestartGroupendRestartGroup,把 remember 换成对 slot 表的读写,还会在能跳过的地方插入一段「参数没变就 skipToGroupEnd()」的判断。

换句话说:你写的那个函数和最终跑起来的那个函数不是同一个函数。这和 suspend 是同一种手法——第 9 章会把 suspend 的改写完整地拆开给你看,那台变换器也是真的。

从 Kotlin 2.0 起,这个插件已经跟着 Kotlin 版本一起走了(org.jetbrains.kotlin.plugin.compose),不再需要单独对版本表。

把这一章变成一个习惯

从现在开始,每次你写下一个 composable,脑子里过一遍这三个问题:

  1. 它读了哪些 state?——这决定了它什么时候被标脏
  2. 它的参数会不会经常换新对象?——这决定了它能不能被跳过(第 4 章)
  3. 它上面的那一层,是不是替它读了本该它自己读的东西?——这决定了它的兄弟们要不要陪跑(第 6 章)

这三个问题,就是这本书前两卷的全部。

⌗ 到你手上

真机上你可以直接看到这三个数字,不需要猜:

「没走到」那个数它不显示——因为它就是「两个数都没涨」。一个节点的两个数都不动,说明这一轮它根本没被访问过,这通常是好消息。

这一章的一句话

重组不从根开始,从「读了那个 state 的那个 scope」开始。所以一个 composable 有三种下场:重跑、跳过、没走到——而你真正要优化的,是把尽可能多的节点推到第三种。

下一章:上面说的「重跑」其实还不够精确。Compose 一帧分成组合、布局、绘制三个阶段,而「重组」只是第一个阶段。同一个 state,读在哪一层,就只重跑哪一层往后的部分——这里藏着一个只差一对花括号的优化。