Compose 性能分析:重组次数、稳定性与掉帧之间到底有什么关系


在 Layout Inspector 里看到某个 Composable 重组一百次,很容易认为它就是卡顿源。但一个每次只做少量读取的组件,可能比一个只执行一次却同步解码大图的组件便宜得多。性能优化首先要回答:哪个用户动作慢,慢在主线程的哪一段,以及修改后是否真的缩短了关键路径。

把帧预算变成问题,而不是口号

60 Hz 屏幕一帧约 16.7 毫秒,120 Hz 则约 8.3 毫秒。这只是帧间隔,不等于应用可以把全部时间都拿来执行组合代码;输入、布局、绘制、RenderThread、GPU 和调度也会消耗时间。

列表滑动时如果偶尔掉帧,要区分首次组合、图片加载、对象分配引发的 GC,以及主线程阻塞。平均帧耗时下降不代表尾部卡顿消失,P95、P99 和具体最慢帧的 Trace 往往更有帮助。优化目标应绑定真实动作,例如“连续滑动五屏时,长帧数量减少”,而不是“让所有重组计数归零”。

状态读取发生在哪个阶段

一个悬浮按钮只需要知道列表是否已经离开顶部。如果父组件直接读取 firstVisibleItemScrollOffset,并把它传给整页,会让高频滚动值扩大失效范围。应把读取放到确实需要它的最小组件,必要时把高频输入映射成低频结果。

下面适用于 Kotlin 2.x、Jetpack Compose Foundation 与 Material3 的常见 API。示例省略导入与应用主题,NoteRow 是项目自己的行组件,代码未在 Android 工程编译。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Immutable
data class NoteCard(val id: String, val title: String, val selected: Boolean)

@Composable
fun NotesList(
notes: List<NoteCard>,
onOpen: (String) -> Unit
) {
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val showTop by remember(listState) {
derivedStateOf { listState.firstVisibleItemIndex > 0 }
}

Box {
LazyColumn(state = listState) {
items(
items = notes,
key = { it.id },
contentType = { "note" }
) { note ->
NoteRow(note = note, onOpen = { onOpen(note.id) })
}
}
if (showTop) {
FloatingActionButton(
onClick = { scope.launch { listState.animateScrollToItem(0) } },
modifier = Modifier.align(Alignment.BottomEnd)
) { Text("顶部") }
}
}
}

derivedStateOf 在这里有价值,因为像素级滚动变化远多于“是否离开首项”的变化;若只是把两个低频字符串拼在一起,直接计算通常更清楚。它也不是昂贵计算缓存的万能替代。Compose 性能实践讨论了这些状态读取和列表键的使用场景。

若需求是每帧改变元素位置,可以考虑在布局或绘制阶段读取状态,而不让组合阶段每帧重建;但要先确认改变的是位置、尺寸还是内容。把本该改变布局尺寸的逻辑硬塞到绘制变换里,可能造成点击区域和视觉位置不一致。

key 保身份,稳定性决定比较方式

列表开头插入一项后,位置索引都变了。如果以位置作为身份,行内 remember 状态可能错误关联到其他记录,动画与重用也会受到影响。key 应使用稳定且唯一的业务 ID,不能用会变化的标题,也不要每次组合生成随机 UUID。

contentType 则描述可复用的结构类型,例如标题行和笔记行。把每条记录的 ID 都当成 contentType 会过度细分复用机会;不同布局却使用相同类型,也可能失去预期优化。key 和 contentType 都不会自动让原本可变的数据模型变成可观察状态。

从 Kotlin 2.0.20 起,强跳过默认启用:可重启 Composable 即使含不稳定参数也可跳过,不稳定参数通常按实例身份比较,稳定参数按 equals 比较。判断老文章里的优化建议时必须核对编译器配置。强跳过官方说明明确了这些行为。

因此,“参数是 List,所以组件永远不能跳过”已经不适合作为不分版本的结论。不过如果上游每次都构造一个内容相同的新 List,不稳定参数的实例比较仍可能阻止跳过。是否值得保留实例、使用不可变集合或调整模型,应由实际成本决定。

@Immutable 是契约,不能用来消音

上例 NoteCard 只有不可变标量属性,因此声明 @Immutable 有清晰含义。如果把它改成 val labels: MutableList,即使属性本身是 val,列表内容仍可变,原有标记就可能不再成立。

标记不会监视普通字段变化,也不会替应用发出状态通知。错误承诺可能让 Compose 跳过一次本来需要的更新,比多做几次廉价重组更难排查。应该先保证发布的数据不会在外部继续变动,再考虑稳定性优化。

另一方面,大对象深层 equals 也有代价。十万项集合的结构比较未必比重绘一个小行组件更便宜。把大量数据包进一个“稳定的大状态”并不自然优于细分状态,关键仍是失效范围与比较成本。

昂贵计算应该有明确的输入边界

在组合函数里对一万条笔记排序、分词搜索,会让滚动和选择等无关状态变化反复支付计算成本。若只用 remember(notes, query) 缓存,当 notes 每次都重建或查询每个字符变化时,计算依旧频繁。

更好的分层往往是:数据层负责查询、索引与分页;ViewModel 把查询条件映射成结果;UI 展示已经排序的不可变快照。短列表可以在 UI 简单计算,长列表则应避免把数据库擅长的查询搬到主线程。去抖只降低请求频率,还需要取消与请求版本隔离,才能防止旧搜索结果覆盖新输入。

把正确性陷阱一起纳入性能审查

一个常见的错误缓存是 remember { notes.sortedBy { it.title } }:没有声明 notes 这个输入,后续数据变化时排序结果可能永远停在第一次。改成 remember(notes) 可以表达依赖,但如果在原可变列表上直接增删,实例和相等性仍未必触发期望更新。缓存不能替代正确的数据发布方式。

另一个陷阱是在组合过程中顺手启动网络请求或修改共享状态。组合可能重跑、被跳过或放弃,业务副作用不应把“这个函数执行了一遍”当作提交时机。需要由事件或合适的 Effect API 管理工作,并对作用域和 key 的变化负责;否则优化重组后可能反而改变业务调用次数。

测试数据也要覆盖行内容差异。一千条只有短标题的占位文本,无法代表真实的多行正文、图片与不同字体。若长标题导致测量成本上升,单纯比较空白行的基准会给出错误信心。可以固定几种真实结构比例,保证每轮输入相同,又覆盖最昂贵的常见行类型。

最后要检查测试温度和设备状态。首次启动的编译与资源缓存、图片磁盘缓存、后台负载和设备温控都会改变结果。把预热、缓存状态和编译模式写进实验记录,比只贴一张帧率截图更能帮助后来者复现。某次改善如果只在特定设备出现,应如实保留范围,而不要扩大成对所有 Compose 页面都成立的技巧。

建立可以重放的性能证据

用固定 1,000 条笔记、固定图片来源和固定初始滚动位置,设计三个动作:首次进入、连续滑动、切换一个筛选条件。通过 Macrobenchmark 驱动相同操作,选择一致的编译模式,并在适合性能测量的构建和真机上运行。Macrobenchmark 官方指南说明了测试模块与测量方式。

第一轮保存基线帧指标与系统 Trace,定位最慢帧中的同步 I/O、组合、布局或 GC。第二轮只修改一个变量,例如把排序移出组合,重新运行多次,比较分布和 Trace。不要同时更改图片库、列表结构和编译模式,然后把改善全部归功于某个稳定性标记。

还要做功能回归:列表插入、删除、重排后行内状态是否仍对应正确 ID;更新标题后 UI 是否立即刷新;快速搜索时是否出现旧结果;进入后台再返回是否存在重复任务。性能优化不能以丢更新为代价。

本文没有实测帧率或声称代码通过编译。重组计数适合提供线索,系统 Trace 解释时间去了哪里,重复基准测试证明改变是否有效,三者共同构成结论。


 上一篇
CameraX 图像分析:读懂 YUV 平面步长,才能正确处理背压与帧生命周期 CameraX 图像分析:读懂 YUV 平面步长,才能正确处理背压与帧生命周期
相机预览正常而分析画面错位,往往不是算法错误,而是把有填充的 YUV 平面当成连续数组。本文从像素寻址推导裁剪区域的读取方式,结合 ImageProxy 生命周期与背压策略,设计可验证、内存受控的分析管线。
2026-10-18
下一篇 
Room 离线优先同步:本地事务、Outbox 与旧响应覆盖新编辑的竞态 Room 离线优先同步:本地事务、Outbox 与旧响应覆盖新编辑的竞态
离线保存与后台上传之间存在崩溃窗口,上传响应还可能覆盖用户的新编辑。本文以笔记同步为例设计本地版本、待发送记录和条件确认,说明 Room 事务能保证什么,以及跨设备冲突为什么需要服务端参与。
2026-10-18
  目录