0Pricing
Android Academy · 课时

驾驭重组

使用稳定参数,减少重组次数

驾驭重组 是 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 反馈 — 无需本地设置。

此课程中的所有课时

  1. 衡量性能
  2. 驾驭重组
  3. 内存泄漏与修复
  4. 启动与基线配置文件
← 返回 Android Academy