构建日志
Compose

重组范围比你以为的大:三个真实的过度重组案例

用 Layout Inspector 抓到三处过度重组,逐个分析范围是怎么被撑大的。结论是:lambda 捕获和不稳定参数造成的影响,比状态提升不当更常见。

·755 字 · 约 3 分钟

目录 · 6 节

「重组范围」这件事,官方文档讲的是原理,但真到线上代码里,撑大范围的原因往往很朴素。这篇记三个从我们 App 里抓出来的案例。

怎么抓

先说方法,不然结论没法复现。

  • 打开 Layout Inspector 的 Recomposition Counts
  • build.gradle.kts 里开 Compose 编译器报告
  • 跑一遍典型交互路径,记录计数
  • Modifier.recomposeHighlighter() 做二次确认(可选,我没用,计数已经够定位了)

编译器报告的开关:

app/build.gradle.kts
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_reports")
metricsDestination = layout.buildDirectory.dir("compose_metrics")
}

产出的 *-composables.txt 里会标出每个 Composable 是不是 restartableskippable不 skippable 的那些就是范围被撑大的地方。

案例一:lambda 每次都是新对象

列表项里传了一个回调:

@Composable
fun 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 如果不是 @Stableitem 就是不稳定的,lambda 跟着不稳定,FeedRow 就永远 skippable 不了。

改法有两种:

  1. Feed 标成稳定(如果它确实是不可变的),或者换成 data class + 全 val + 稳定字段类型
  2. 把回调改成只捕获稳定值:
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 传进去了

@Composable
fun ProfileHeader(vm: ProfileViewModel) { /* ... */ }

ViewModel 是不稳定类型,作为参数会让整个 Composable 不可跳过。而且它把「这个组件依赖什么」这个信息藏起来了——从签名上看不出它到底用了哪几个字段。

传具体的值,不传容器:

@Composable
fun ProfileHeader(
name: String,
avatarUrl: String?,
onEdit: () -> Unit,
)

这条改完之后,ProfileHeader 在滚动时的重组次数从 60+ 降到 2。

案例三:读状态的位置太靠上

@Composable
fun 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 传递而不是值:

@Composable
fun Screen(state: () -> ScreenState) {
Column {
Header(title = { state().title })
Body(items = { state().items })
}
}

这个写法在参数多的时候会很啰嗦,所以我只在确认有性能问题的路径上用。默认还是传值,测出来慢了再改。1

归纳

三个案例的共同点是:范围被撑大都不是因为「状态放错了地方」这种架构问题,而是因为某个参数不稳定,导致跳过失效,然后连锁往上。

排查顺序建议:

  • 先看编译器报告里的 skippable 标记,找不可跳过的
  • 对每个不可跳过的,看它的参数里哪个是 unstable
  • 不稳定参数里,优先处理 lambda 和容器类型(ViewModel、List、自定义 class)
  • 最后才考虑调整状态提升的层级

前三步能解决八成问题,第四步成本最高、收益最小。

Footnotes

  1. 反过来做——一上来就全用 lambda——会让代码可读性变差,而且大部分屏幕根本没有重组压力。过早优化在 Compose 里同样成立。

评论

由自建 Waline 驱动

输入关键词开始搜索