先把结论放在前面:Compose 的重组能跳过,靠的是"参数稳定性判定 + 编译器插入的依赖追踪"两件事。 编译器在编译期给每个 Composable 算出"读了哪些状态"的依赖集合,运行时状态变化时只把依赖它的重组作用域放进待执行队列;而"能否跳过整个 Composable"则由参数是否"稳定"决定——稳定参数在两次调用间用 equals 比较,相同就直接返回,连函数体都不执行。
这道题的答案确实藏在演进过程里:早期 Compose 依赖一个"Positional Memoization"方案(按调用位置记缓存),位置一变缓存就失效;后来改成"按调用点分组 + 编译器生成 group key",才有了今天"位置无关"的跳过能力。能讲出这段演进,说明理解的是设计动机而不是背下来的规则。
为什么能跳过:两套机制
机制一:依赖追踪(决定"谁要重组")。
@Composable
fun Counter(count: Int, onClick: () -> Unit) {
val doubled = count * 2 // 依赖 count
val label = remember(count) {
"值:$count" } // 依赖 count,且有缓存
Text(text = label, modifier = Modifier.clickable(onClick = onClick))
}
编译后 Counter 大致变成:
fun Counter($composer: Composer, count: Int, onClick: Function0) {
val doubled = $composer.changed(count)?.let {
count * 2 } ?: 1 // 记录依赖 count
...
$composer.startReplaceableGroup(key)
Text(...) // 子组合有自己的作用域
$composer.endReplaceableGroup()
}
$composer.changed(value) 的语义是"值相对上次是否变化",同时把这次读取注册进当前作用域的依赖表。 这就是"重执行"的机制:函数体还是会跑,只是运行时能拿到"哪些参数变了"的信息。
机制二:稳定性判定(决定"能否整体跳过")。
稳定性由编译器判定,规则是:
| 类型 | 是否稳定 | 判定依据 |
|---|---|---|
基本类型、String |
稳定 | 值类型 |
List/Set/Map(只读接口) |
稳定 | 编译器假定其内容不外泄(kotlinx.collections.immutable 更可靠) |
MutableList 等 |
不稳定 | 可变接口 |
| 自定义类 | 看 equals 实现 |
有 equals 且非可变字段 → 稳定 |
Compose State 实现类 |
不稳定 | 每次读都变 |
函数类型 () -> Unit |
稳定 | 引用比较,但每次传入的 lambda 实例不同 |
关键的坑就在最后一类:lambda 传递会破坏跳过。 如果每次重组都传一个新建的 lambda,引用不同 → 参数不稳定 → 子 Composable 逐一重组。解法是 remember 缓存 lambda、或用 Modifier.clickable 这类"有稳定重载"的 API(编译器会为它生成稳定的 lambda)。
最常见的坑是
第一层坑:传可变集合参数导致整列表重组,滚动性能断崖下降。
// 反模式:传 MutableList,编译器判定为不稳定 → 每个 item 都会重组
LazyColumn {
items(viewModel.mutableItems) {
item ->
ItemRow(item)
}
}
// 正模式一:转成不可变 List
val items by viewModel.items.collectAsStateWithLifecycle() // StateFlow<List<Item>>
LazyColumn {
items(items, key = {
it.id }) {
item -> ItemRow(item) }
}
// 正模式二:强稳定性包装(数据确实可变时)
@Immutable
data class UiState(val items: List<Item>, val loading: Boolean)
第二个反模式的变体更隐蔽:把整个 UiState 传给每个 item。
// 错误:任意字段变化都会让所有 item 重组
items(state.items, key = {
it.id }) {
item -> ItemRow(state, item) }
// 正确:只传 item 需要的字段
items(state.items, key = {
it.id }) {
item -> ItemRow(title = item.title, isLiked = item.isLiked) }
这三条(不可变集合、只传必要字段、@Immutable 标注)是这题的实用答案。
第二层坑:在重组中执行副作用,结果被反复执行多次。
前面讲过机制,这里补一个更隐蔽的形态:把副作用写在了传给子 Composable 的 lambda 参数里,但那个 lambda 是不稳定的,导致每次重组都产生新 lambda、进而触发子重组、进而再次执行。规范是副作用只在 LaunchedEffect/DisposableEffect/SideEffect 里写,且 LaunchedEffect 的 key 精确。
第三层坑:误以为"重组 = 重建",从而过度优化或过度悲观。
重组只是重新执行函数体,节点树是 diff 后更新的——所以"重组一次的成本"远低于"重建整个页面"。但重组也有真实成本:函数体执行、创建新的 lambda 与对象、内存分配。因此优化的目标是"减少不必要的重组",而不是"消灭重组"。 能说清这个区分,避免了"Compose 性能差"的错误结论。
还有一个更隐蔽的坑:derivedStateOf 用错位置。 把 derivedStateOf 写在组合函数体之外(比如 ViewModel 里)会因为没有 Composition 而失效;写在每个重组都重新创建的位置(不使用 remember 包裹)会导致派生对象每次都是新的,失去去重作用。规范是 remember { derivedStateOf { ... } }。
代码里见真章
先看稳定性如何影响跳过,以及如何用编译器工具确认:
// Compose 稳定性推断配置:可显式声明哪些类型稳定/不稳定
// 在源码根目录放 compose_compiler_config.conf,或用
// @Stable / @Immutable 注解标注
@Immutable
data class UserUi(
val id: Long,
val name: String,
val avatarUrl: String
) // @Immutable 承诺"所有字段都是 val 且不可变",编译器据此判定为稳定
@Stable
class FormState(initial: String) {
// @Stable 承诺"属性变化会伴随 equals/hashCode 变化",字段可以是 var
var query: String by mutableStateOf(initial)
private set
override fun equals(other: Any?): Boolean =
other is FormState && other.query == query
override fun hashCode(): Int = query.hashCode()
}
@Immutable 与 @Stable 的区别是这题的高分点:@Immutable 用于"所有属性都是不可变值",@Stable 用于"属性可变但会正确实现 equals/hashCode"。用错会导致跳过判断失效。
稳定 lambda 与不稳定 lambda 的对比:
@Composable
fun BadItem(item: Item, onClick: () -> Unit) {
// 每次重组都创建新 lambda → onClick 不稳定 → 子 Composable 逐一重组
Row(Modifier.clickable {
onClick() }) {
Text(item.title) }
}
@Composable
fun GoodItem(item: Item, onClick: () -> Unit) {
// remember 缓存 lambda → 引用稳定 → 可跳过
val handler = remember(item.id) {
{
onClick() } }
Row(Modifier.clickable(onClick = handler)) {
Text(item.title) }
}
@Composable
fun BestItem(item: Item, onClick: () -> Unit) {
// 更好的做法:用 Modifier 的 onClick 参数形式
// 编译器会为它在 group 内生成稳定的 lambda
Row(Modifier.clickable(onClickLabel = "打开 ${item.title}", onClick = onClick)) {
Text(item.title)
}
// 关键:lambda 的稳定性由"捕获的变量是否稳定"决定
// 若捕获了可变局部变量,编译器仍判定为不稳定
}
这段代码的第三种写法是"编译器友好"的——因为 onClick 本身是父级传来的稳定参数,编译器能把它作为稳定的引用传下去。
读取重组范围的调试手段:
@Composable
fun DebugRecomposition() {
// 开启后 Layout Inspector 会高亮重组范围与次数
val counter = remember {
AtomicInteger(0) }
SideEffect {
counter.incrementAndGet() }
// 生产环境可用自定义 Modifier 统计
val count = counter.get()
Log.d("Recompose", "本屏重组第 $count 次")
}
// 精确控制重组范围:把状态读下沉
@Composable
fun Parent(vm: ParentVm) {
val whole by vm.wholeState.collectAsStateWithLifecycle() // 顶层读取
Column {
Header(whole.title) // 只重组 Header
// 关键:把读取下沉到子组件,让子组件自己订阅
Body(vm) // 传 vm 而不是 whole
}
}
@Composable
fun Body(vm: ParentVm) {
val body by vm.bodyState.collectAsStateWithLifecycle() // 只重组 Body
Text(body)
}
这段代码的价值在于给出了一条可执行的排查动作——先量化重组次数,再定位到具体作用域,而不是凭感觉优化。
derivedStateOf 的正确与错误用法:
@Composable
fun WrongWay(listState: LazyListState, total: Int) {
// 错误:listState 每帧变化 → 每帧重组
val atEnd = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index == total - 1
if (atEnd) LoadMoreButton()
}
@Composable
fun RightWay(listState: LazyListState, total: Int) {
// 正确:派生值去重,只有布尔值翻转才重组
val atEnd by remember {
derivedStateOf {
val last = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
last >= total - 3
}
}
if (atEnd) LoadMoreButton()
}
@Composable
fun AlsoRight(total: Int) {
// 正确:多个状态组合时用 remember 明确缓存
val showBadge by remember(total) {
mutableStateOf(false) }
if (showBadge && total > 0) Badge()
}
这三段代码的对照把"derivedStateOf 的作用是去重而非计算"讲清楚了——这一点与"remember 是缓存计算"是不同层次的职责。
面试中的经典考点
问:Compose 为什么能跳过重组?判定依据是什么?
答:两级判定。 ① 依赖追踪——编译器把状态读取编译成 composer.changed() 依赖注册,状态变化时只有订阅它的作用域进待重组队列;② 参数稳定性——若该 Composable 的所有参数都是"稳定"的(不可变值类型或正确实现了 equals),运行时用 equals 比较,都相同就直接返回,函数体不执行。"重执行 + 稳定性跳过"是两层不同的优化。
问:什么是"稳定"类型?编译器怎么判定?
答:稳定 = 相同引用的两个实例其 equals 恒为 true。基本类型、String、只读集合接口(编译器假定不可变)、有正确 equals 的自定义类都稳定。MutableList 不稳定(改内容不改引用);List 声明但实际传 MutableList 也可能被误判为稳定——这是"用 mutableListOf 传参导致跳过失效"的根因。
问:为什么传 lambda 会让跳过失效?怎么解决?
答:每次重组新建的 lambda 是新引用,equals 为 false → 参数不稳定 → 子 Composable 重组。 解法三条:① remember 缓存 lambda;② 用 Modifier.clickable(onClick = ...) 这类编译器会生成稳定 group lambda 的 API;③ 尽量减少需要传 lambda 的层级。能主动提"稳定 group"或"group key",说明理解到编译器层面。
问:重组的开销到底有多大?该不该优化?
答:重组的成本 = 函数体执行 + 状态读取检查 + lambda/对象分配 + 节点 diff。 它远低于"重建整个视图树",所以"Compose 重组很慢"是错误结论。该优化的是"不必要的重组"——用 remember 避免重计算、把状态读下沉、缩小参数范围。优化前先测(重组次数与帧耗时),优化后再测。
问:@Stable 与 @Immutable 有什么区别?用错会怎样?
答:@Immutable 要求所有属性都是不可变值(val + 不可变类型);@Stable 允许属性可变,但要求 equals/hashCode 随属性变化而变。用错的后果是"跳过判断失效"——比如给 var 属性用 @Immutable,运行时对象内容变了但 equals 返回 true,UI 不更新。这是 Compose 里最难查的一类 bug,必须靠正确注解避免。
问:为什么把状态读放到子 Composable 能优化性能?
答:因为重组作用域与状态读取的位置绑定——状态在哪个 Composable 的函数体内被读取,就订阅哪个作用域。子组件自己读状态,父组件就不需要因为这个状态重组(子重组不连带父重组)。这是最有效的重组优化手段,因为它把"整页重组"降级为"局部重组"。
落到项目里怎么做
一条能写进规范的红线:UI 状态类型统一用 @Immutable data class 表达,集合字段声明为 List/Map 且实际传入不可变实现。 这条规则直接保证稳定性判定成立,比任何优化手段的基础收益都大。
配套实践三条:① 每个 Composable 组件的入参控制在"该组件真正需要的最小集合",不要把整个 UiState 传下去;② 性能验证固化为动作——在真机用 Layout Inspector 统计关键屏的重组次数,把它当作回归指标,改动后对比;③ 用 lint 规则(ComposeUnstableCollections、ModifierParameter)在 CI 上拦住可变集合传参与不稳定 lambda。
给正在准备面试的你
这题的答法要落到"两级判定 + 稳定性规则 + 演进动机"这条主线。推荐这条线:先讲依赖追踪(编译器把状态读取变成 changed() 注册)→ 讲稳定性判定(稳定参数相同即跳过)→ 讲稳定性表(只读集合稳定、可变集合不稳定、lambda 要 remember)→ 讲三个坑:传 MutableList、lambda 每次新建、误以为重组=重建 → 落到"@Immutable 状态 + 状态读下沉 + 用 Layout Inspector 量化"。能被追问到"为什么传 lambda 会失效"并答出"引用不同导致 equals 为 false",说明踩过性能问题的坑;能主动讲 @Stable 与 @Immutable 的区别与用错的后果,说明理解到注解的语义层面。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android软件开发面试·从入门到精通」连载系列
上一篇:Jetpack-Compose-入门:声明式-UI-的思维转换
下一篇预告:Compose-状态管理:remember-与状态提升
有任何问题欢迎在评论区留言交流。