驾驭重组
使用稳定参数,减少重组次数
驾驭重组 是 CoddyKit 上的免费 Android Academy 课时。 这是第 2 节课,共 4 节。 你可以在下方免费阅读本课时的完整内容 — 然后在浏览器中使用内置代码编辑器和全天候 AI 导师进行实践。 这是 Android Academy 学习路径的一部分,你的进度在网页和 CoddyKit 应用中同步。 Android Academy 课程共包含 4 节课。
重组:Compose 性能调节旋钮
在 Jetpack Compose 中,界面由函数描述。当状态发生变化时,Compose 会进行重组:重新运行读取了该状态的可组合函数,以更新屏幕。
只要范围控制得当,重组就是正常且开销很小的操作。但如果重组过于频繁,或影响了树中的过多内容,它就会成为 Compose 卡顿的头号原因。本课程将教您如何让重组范围更小、发生得更少。
查看重组次数
在修复之前,先进行测量。Android Studio 中的布局检查器可以实时显示每个可组合函数的重组次数。您也可以使用 Compose 编译器的指标,或使用一个简单的调试计数器。
一个简单的方法是:每当可组合函数发生重组时,让 SideEffect 递增一个引用计数,这样您就能在开发过程中记录那些意外的重组。
@Composable
fun RecompositionCounter(tag: String) {
val count = remember { mutableStateOf(0) }
SideEffect { count.value++ }
Log.d("Recompose", "$tag recomposed ${count.value} times")
}
// Drop RecompositionCounter("PriceLabel") inside a composable
// to watch how often it re-runs while you interact.尽可能晚地读取状态
Compose 只会重组读取某个状态值的可组合函数。如果父级读取了该状态,整个父级都会重组;如果只有一个较小的子级读取它,就只有该子级会重组。
因此,请将状态读取下移到树的更深处。这里的错误版本会在每次计时变化时重组整个 Column;正确版本则将重组范围限制在标签上。
// BAD: Column reads `seconds`, so everything recomposes each second
@Composable
fun TimerBad(seconds: Int) {
Column {
ExpensiveHeader()
Text("Elapsed: $seconds")
}
}
// GOOD: only the Text reads the value via a lambda
@Composable
fun TimerGood(seconds: () -> Int) {
Column {
ExpensiveHeader()
Text("Elapsed: ${seconds()}")
}
}使用 Lambda 延迟读取
一种非常有效的模式是:不要传递频繁变化的值,而是传递一个返回该值的 Lambda。最终调用这个 Lambda 的可组合函数才是唯一会重组的部分。
这就是 Modifier.offset { ... } 和 graphicsLayer { ... } 接收 Lambda 的原因:滚动和动画值每帧都会变化,而 Lambda 可以让重组完全避开布局阶段。
// Passing the value: parent recomposes every frame of scroll
Box(Modifier.offset(y = scrollOffset.dp))
// Passing a lambda: skips recomposition, updates in the layout phase
Box(Modifier.offset { IntOffset(x = 0, y = scrollOffset.roundToInt()) })
// Same idea for alpha/scale during animation:
Image(
painter = painter,
contentDescription = null,
modifier = Modifier.graphicsLayer { alpha = animatedAlpha() }
)稳定性:Compose 为什么能够跳过重组
如果可组合函数的所有参数都稳定且没有变化,Compose 就可以跳过对它的重组。当 Compose 可以相信 equals 能反映真实变化,并且公共字段不会在它不知情的情况下发生变化时,一个类型就是稳定的。
- 稳定:基本类型、
String、不可变数据类、State。 - 不稳定:
List/Map接口、包含var字段的类、来自未使用编译器模块的类型。
不稳定的参数会强制触发重组,即使实际上没有任何变化。
// UNSTABLE: List is an interface; Compose cannot assume immutability,
// so UserList recomposes even if the contents are identical.
@Composable
fun UserList(users: List<User>) { /* ... */ }
data class User(val id: Long, val name: String) // stable: all vals让参数保持稳定
针对不稳定参数,有两种常见的修复方法:
- 使用
kotlinx.collections.immutable中的不可变集合(例如ImmutableList),Compose 会将它们视为稳定类型。 - 使用
@Immutable或@Stable注解标记您可以控制的类,向 Compose 保证它不会发生变化。
这样,当再次传入同一个实例时,Compose 就可以安全地跳过重组。
import kotlinx.collections.immutable.ImmutableList
import androidx.compose.runtime.Immutable
@Immutable
data class UiState(
val title: String,
val users: ImmutableList<User>
)
// Stable parameter -> Compose can skip this when state is unchanged
@Composable
fun UserList(users: ImmutableList<User>) { /* ... */ }remember:不要在每帧重新计算
可组合函数可能会运行很多次。直接写在函数体中的任何非简单计算,都会在每次重组时运行。请将其放入 remember 中,这样只有在其键发生变化时才会重新计算。
对于从其他状态派生的值,优先使用 derivedStateOf,它只会在计算结果确实发生变化时重新发出值。
// Recomputes the sorted list only when `items` changes
val sorted = remember(items) { items.sortedBy { it.name } }
// derivedStateOf: only triggers readers when the BOOLEAN flips,
// not on every scroll pixel
val showButton by remember {
derivedStateOf { listState.firstVisibleItemIndex > 5 }
}
if (showButton) ScrollToTopButton()惰性列表中的稳定键
在 LazyColumn/LazyRow 中,请为每个项目指定一个稳定的键。如果没有键,插入或重新排序项目会迫使 Compose 重新组合并重新测量那些实际上没有变化的项目,因为 Compose 会根据位置跟踪它们。
使用稳定的键后,Compose 就能在更新前后匹配项目,并跳过没有变化的项目。
LazyColumn {
items(
items = users,
key = { user -> user.id } // stable identity
) { user ->
UserRow(user)
}
}
// Now adding a user at the top reuses existing rows
// instead of recomposing the whole list.提升状态,向上传递事件
如果每次重组都会创建新的 Lambda 实例,不稳定的Lambda参数也会破坏跳过重组的能力。请通过记住回调,或引用稳定函数,来稳定这些回调。
再结合状态提升(状态位于调用方,事件向上流动),就能让叶子可组合函数保持低开销,并且可以被跳过。
@Composable
fun SearchBar(query: String, onQueryChange: (String) -> Unit) {
TextField(value = query, onValueChange = onQueryChange)
}
// In the caller, a remembered lambda keeps the reference stable:
val onChange = remember { { newValue: String -> viewModel.setQuery(newValue) } }
SearchBar(query = query, onQueryChange = onChange)避免在过高层级读取滚动/动画状态
一个常见错误是在高层级可组合函数中读取快速变化的状态(滚动偏移量、动画进度)。这会迫使整个子树每帧都进行重组。
请将这类读取放在 Modifier Lambda 中(offset {}、graphicsLayer {}、drawBehind {}),让工作发生在布局阶段或绘制阶段,而不是重组阶段。这就是流畅的 60fps 滚动与卡顿滚动之间的区别。
// Animated color used only for drawing -> stay in the draw phase
Box(
Modifier.drawBehind {
drawRect(color = animatedColor()) // lambda read, no recomposition
}
)重组检查清单
当屏幕在交互过程中感觉卡顿时,请逐项检查:
- 参数是否稳定(不可变数据、
ImmutableList)? - 您是否将快速变化的状态尽可能在低层级读取,最好放在修饰符 Lambda 中?
- 惰性列表是否具有稳定的键?
- 耗时计算是否放在
remember/derivedStateOf后面? - 布局检查器是否确认重组次数确实减少了?
每勾选一项,就能从每一帧中移除一部分浪费的工作。
快速检查
您将 List<User> 传递给一个可组合函数,并发现即使数据没有变化,它仍然会重组。最直接的修复方法是什么?
回顾:更少、更少发生的重组
您已经学会控制 Compose 中最主要的性能问题——重组:
- 使用布局检查器或调试计数器测量次数。
- 将状态读取放在尽可能低的层级;使用Lambda延迟读取。
- 使用不可变数据和
@Immutable/ImmutableList让参数保持稳定。 - 使用
remember和derivedStateOf,避免每帧重新计算。 - 为惰性列表项目指定稳定的键;将快速变化的状态读取放在修饰符 Lambda 中。
接下来,我们将从 CPU 和重组转向内存:查找并修复泄漏。
常见问题解答
「驾驭重组」课时是免费的吗?
是的 — 「驾驭重组」的完整文本可在网页上免费阅读。要进行交互式练习(内置代码编辑器和全天候 AI 导师)并解锁 Android Academy 课程的其余内容,请升级到 CoddyKit PRO。 Android Academy 课程共包含 4 节课。
「驾驭重组」这节课中我会学到什么?
使用稳定参数,减少重组次数 你通过在浏览器中直接运行的动手代码来练习 Android Academy,全天候 AI 导师会在你学习这节课的过程中回答你的问题。
学习 Android Academy 需要有经验吗?
无需任何先前经验。CoddyKit 上的 Android Academy 课程适合初学者到高级学习者,你可以从这里开始或从头开始,按照自己的节奏学习。 这是第 2 节课,共 4 节。
「驾驭重组」课时需要多长时间?
大多数 CoddyKit 课程大约需要 5–10 分钟。每节课都很精短且互动,所以你能稳步进步,并在网页和应用中从离开的地方继续。
我能在这节 Android Academy 课中编写并运行代码吗?
能。每节 Android Academy 课都包含内置代码编辑器,你可以在浏览器中直接编写并运行真实代码,并获得即时 AI 反馈 — 无需本地设置。