在 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 |
|
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
标记不会监视普通字段变化,也不会替应用发出状态通知。错误承诺可能让 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 解释时间去了哪里,重复基准测试证明改变是否有效,三者共同构成结论。