重组范围比你以为的大:三个真实的过度重组案例
用 Layout Inspector 抓到三处过度重组,逐个分析范围是怎么被撑大的。结论是:lambda 捕获和不稳定参数造成的影响,比状态提升不当更常见。
·755 字 · 约 3 分钟
目录 · 6 节
「重组范围」这件事,官方文档讲的是原理,但真到线上代码里,撑大范围的原因往往很朴素。这篇记三个从我们 App 里抓出来的案例。
怎么抓
先说方法,不然结论没法复现。
- 打开 Layout Inspector 的 Recomposition Counts
- 在
build.gradle.kts里开 Compose 编译器报告 - 跑一遍典型交互路径,记录计数
- 用
Modifier.recomposeHighlighter()做二次确认(可选,我没用,计数已经够定位了)
编译器报告的开关:
composeCompiler { reportsDestination = layout.buildDirectory.dir("compose_reports") metricsDestination = layout.buildDirectory.dir("compose_metrics")}产出的 *-composables.txt 里会标出每个 Composable 是不是 restartable 和 skippable。不 skippable 的那些就是范围被撑大的地方。
案例一:lambda 每次都是新对象
列表项里传了一个回调:
@Composablefun FeedList(items: List<Feed>, vm: FeedViewModel) { LazyColumn { items(items) { item -> // 每次重组都创建一个新的 lambda 实例 FeedRow(item = item, onLike = { vm.like(item.id) }) } }}onLike 是一个捕获了 item 的 lambda。虽然 Compose 编译器会对 lambda 做记忆化,但捕获了不稳定参数的 lambda 记忆化会失效——Feed 如果不是 @Stable,item 就是不稳定的,lambda 跟着不稳定,FeedRow 就永远 skippable 不了。
改法有两种:
- 把
Feed标成稳定(如果它确实是不可变的),或者换成data class+ 全val+ 稳定字段类型 - 把回调改成只捕获稳定值:
LazyColumn { items(items, key = { it.id }) { item -> val id = item.id // String 是稳定的 val onLike = remember(id) { { vm.like(id) } } FeedRow(item = item, onLike = onLike) }}案例二:把整个 ViewModel 传进去了
@Composablefun ProfileHeader(vm: ProfileViewModel) { /* ... */ }ViewModel 是不稳定类型,作为参数会让整个 Composable 不可跳过。而且它把「这个组件依赖什么」这个信息藏起来了——从签名上看不出它到底用了哪几个字段。
传具体的值,不传容器:
@Composablefun ProfileHeader( name: String, avatarUrl: String?, onEdit: () -> Unit,)这条改完之后,ProfileHeader 在滚动时的重组次数从 60+ 降到 2。
案例三:读状态的位置太靠上
@Composablefun Screen(state: ScreenState) { Column { Header(title = state.title) Body(items = state.items) Footer(count = state.items.size) // 这里读了 items }}问题不在这段代码,而在于 state 是一个 MutableState<ScreenState>,在 Screen 顶层就被读了。读状态的位置决定了重组范围的顶点:在 Screen 里读,整个 Screen 就是范围。
把读取推迟到真正用它的地方,用 lambda 传递而不是值:
@Composablefun Screen(state: () -> ScreenState) { Column { Header(title = { state().title }) Body(items = { state().items }) }}这个写法在参数多的时候会很啰嗦,所以我只在确认有性能问题的路径上用。默认还是传值,测出来慢了再改。1
归纳
三个案例的共同点是:范围被撑大都不是因为「状态放错了地方」这种架构问题,而是因为某个参数不稳定,导致跳过失效,然后连锁往上。
排查顺序建议:
- 先看编译器报告里的
skippable标记,找不可跳过的 - 对每个不可跳过的,看它的参数里哪个是
unstable - 不稳定参数里,优先处理 lambda 和容器类型(ViewModel、List、自定义 class)
- 最后才考虑调整状态提升的层级
前三步能解决八成问题,第四步成本最高、收益最小。
Footnotes
-
反过来做——一上来就全用 lambda——会让代码可读性变差,而且大部分屏幕根本没有重组压力。过早优化在 Compose 里同样成立。 ↩
评论
由自建 Waline 驱动