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节点在屏幕上出现一次,就会经历:

  1. 测量(Measure):父节点向子节点询问它的尺寸约束,子节点回答“我最小要多大、最大能多大”。这一阶段只会发生一次,不会重新测量。
  2. 布局(Place):父节点根据测量结果,确定每个子节点放在什么位置(x, y)以及宽高。
  3. 绘制(Draw):最后每个节点在自己的边界内绘制内容,比如文本、图形、阴影等。

下面这张表能帮你快速对比:

阶段输入输出能否跳过?
测量Constraints(约束)Placeable(可放置对象)不能,必须执行
布局Placeable节点位置、层级若尺寸没变可以复用
绘制布局结果+Canvas像素若内容没变可以复用

我之前遇到一个场景:一个自定义组件内部做了大量计算,结果每次滚动都触发测量——虽然位置没变,但测量阶段每次都跑。其实像这种只需第一次测量的情况,可以用 IntrinsicSize 或者把计算放到 remember 里。嗯,这里要提醒你:测量阶段千万不要做耗时操作,它会被频繁调用。

2.3 帧时间预算与ANR

现在来说说帧时间预算。你想想看,手机屏幕每秒刷新60次,每次留给应用的时间大约16.6ms(如果120Hz就是8.3ms)。这16ms要完成重组+三阶段的所有工作。一旦超时,GPU就来不及渲染下一帧,卡顿、掉帧就来了,严重时直接ANR。

注意:这里有一个容易被忽略的点——重组是在主线程执行的。如果你在组合函数里直接写了网络请求、数据库查询、大文件读写,那16ms肯定打不住。我曾经在一个 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 做缓存。
关键指标: - 重组次数:每次重组耗时 < 1ms 是绿色,> 5ms 是红色。 - 帧时间:用Profile工具看,如果总有几帧超过20ms,就需要优化了。 - 测量次数:正常一个可滚动列表的每个item测量一次就好,如果出现反复测量,说明约束条件不稳定。

好了,这一章的内容就到这里。核心记住三件事:重组是分区段的,不是全量;三阶段顺序执行不回头;帧预算16ms是大限。

Compose渲染三阶段与帧时间预算 状态变化 触发重组 测量(Measure) 布局(Place) 绘制(Draw) 帧时间预算 60Hz → 16.6ms 120Hz → 8.3ms 超时 = 掉帧/ANR 注意: • 重组只重跑脏分支 • 测量布局不会重复 • 绘制可被跳过(复用)

📌 课后思考: 如果你在LazyColumn里用了一个复杂的三方库自定义控件,每次滑动都卡顿,你会优先从哪个阶段排查?试试用Layout Inspector去看看是不是测量阶段被重复执行了。