2. Compose渲染原理:测量、布局、绘制与帧时间预算
这一章咱们聊聊Compose的底层渲染流程。说实话,很多开发者写Compose卡成PPT,就是因为没搞懂这三阶段和重组触发条件。我当年刚开始做Compose性能优化时,也经常一脸懵——明明代码逻辑没问题,为什么滑动列表就掉帧?后来追到渲染管线才明白,问题出在重组被触发得毫无节制。
2.1 重组的触发条件
重组(Recomposition)是Compose最核心的机制,说白了就是当可观察状态发生变化时,框架自动重新执行那些读了这个状态的Composable函数。注意,它不是整个树都重跑,而是只更新受影响的子树。
触发重组的条件主要有这几种:
- 状态对象变化:你用了
mutableStateOf、StateFlow、LiveData等,只要值变了,订阅的地方就会触发重组。 - 传入的Key变了:比如
LazyColumn的key参数,或者直接用key()包裹内容块。我记得有一次写列表,忘了给item加唯一key,结果每次刷新整个列表都重组成白屏,半天没找到原因。 - Composable参数变了:父组件重新组合时传进来的参数如果变了,子组件也会跟着重组。但这里有个坑——如果参数是lambda表达式或者对象引用,每次重组都会生成新实例,导致不必要的子节点重组。
- 读取了派生状态:像
derivedStateOf或snapshotFlow,它们计算的结果变了也会触发重组。
你可能会问:那怎么知道哪些组合函数会被重跑?其实Compose在编译期会打标,只有那些读到了变化状态的函数才会被标记为“脏”。我习惯在怀疑的地方加日志打印,或者直接用Layout Inspector看Recomposition次数,一目了然。
2.2 测量、布局、绘制三阶段
Compose的三阶段和传统View系统有点像,但更精简。它是严格的单次遍历,不会像View那样Measure两次。说白了一个Composable节点在屏幕上出现一次,就会经历:
- 测量(Measure):父节点向子节点询问它的尺寸约束,子节点回答“我最小要多大、最大能多大”。这一阶段只会发生一次,不会重新测量。
- 布局(Place):父节点根据测量结果,确定每个子节点放在什么位置(x, y)以及宽高。
- 绘制(Draw):最后每个节点在自己的边界内绘制内容,比如文本、图形、阴影等。
下面这张表能帮你快速对比:
| 阶段 | 输入 | 输出 | 能否跳过? |
|---|---|---|---|
| 测量 | Constraints(约束) | Placeable(可放置对象) | 不能,必须执行 |
| 布局 | Placeable | 节点位置、层级 | 若尺寸没变可以复用 |
| 绘制 | 布局结果+Canvas | 像素 | 若内容没变可以复用 |
我之前遇到一个场景:一个自定义组件内部做了大量计算,结果每次滚动都触发测量——虽然位置没变,但测量阶段每次都跑。其实像这种只需第一次测量的情况,可以用 IntrinsicSize 或者把计算放到 remember 里。嗯,这里要提醒你:测量阶段千万不要做耗时操作,它会被频繁调用。
2.3 帧时间预算与ANR
现在来说说帧时间预算。你想想看,手机屏幕每秒刷新60次,每次留给应用的时间大约16.6ms(如果120Hz就是8.3ms)。这16ms要完成重组+三阶段的所有工作。一旦超时,GPU就来不及渲染下一帧,卡顿、掉帧就来了,严重时直接ANR。
LaunchedEffect 外部的lambda里做了复杂计算,结果首帧卡了200ms,直接被系统弹了ANR。
所以帧预算管理说白了就是:
- 重组阶段:保证每次组合执行时间小于1ms(理想情况)。如果这个阶段超过5ms,就得检查是不是做了不该做的事。
- 测量+布局阶段:这部分通常很快,除非你有嵌套很深的复杂布局。我个人习惯用
Modifier.layout做自定义布局,但每次都要确认它不会触发二次测量。 - 绘制阶段:小心
Canvas里的复杂绘图操作,尤其是drawPath或者大量drawArc。实测一个复杂的SVG路径绘制就能吃掉8ms。
你可以用Android自带的 Layout Inspector 查看每帧执行时间,或者用 FrameMetrics API。我一般会在测试工程里加个简单的性能统计,比如用 compose-runtime 里的 Recomposer 的 currentRecomposeCount 观察重组次数。
2.4 实战经验与避坑
最后分享几个我踩过的坑,希望对你有帮助。
Thread.sleep() 或 runBlocking!这是最直接的ANR元凶。我曾经调试一个同事的代码,他在 Box 的 Modifier 里调了 runBlocking { delay(100) },结果每次重组都卡住。记住:组合函数必须是非阻塞的。
derivedStateOf 的连锁反应。如果你的派生状态里又依赖另一个派生状态,一旦底层状态变化频繁,整个链条都得重跑,帧预算很容易爆炸。我自己的做法是尽量用 snapshotFlow 配合 distinctUntilChanged 做去重,或者在派生状态里加一个 remember 做缓存。
好了,这一章的内容就到这里。核心记住三件事:重组是分区段的,不是全量;三阶段顺序执行不回头;帧预算16ms是大限。
📌 课后思考: 如果你在LazyColumn里用了一个复杂的三方库自定义控件,每次滑动都卡顿,你会优先从哪个阶段排查?试试用Layout Inspector去看看是不是测量阶段被重复执行了。