4. derivedStateOf 与 map 优化:派生状态计算、避免链式重组、依赖稳定
4.1 重组触发原理
Compose 的重组是基于 快照状态 变化的。当可观测状态(mutableStateOf、StateFlow 等)的值发生改变时,读取该状态的可组合函数会进入重组。但在实际项目中,UI 常常只需要状态的派生值(如过滤、映射、聚合结果),而不是原始状态本身。
直接读取原始状态然后进行计算,会导致每次原始状态变化时都触发重组,即使派生结果没有实际改变(例如列表 item 的 text 由两个属性拼接而成,但只有其中一个属性变化)。derivedStateOf 正是为了解决这个问题而设计的:它仅在 依赖的稳定状态 发生变化后重新计算,且只在计算结果与上次不同时才触发现有的重组。
4.2 derivedStateOf 使用与优化策略
derivedStateOf 的 API 签名:
fun <T> derivedStateOf(
calculation: () -> T
): State<T>
它返回一个只读的 State 对象,其值由 calculation 计算得出。Compose 会自动收集 calculation 中读取的所有 State 对象作为依赖,当这些依赖发生变化时,重新执行 calculation,若新值与旧值不同,则通知组合树中读取该派生 State 的组件重组。
使用原则:
- 将 UI 中纯计算逻辑(filter、map、count、拼接等)提取到
derivedStateOf中。 - 确保
calculation是纯函数,不产生副作用。 - 避免在
derivedStateOf内部创建新的非稳定对象(如每次返回新 List),否则会导致频繁“不等”判断。
4.3 避免链式重组
链式重组 是指多个可组合函数因同一个状态变化而依次触发重组的情况。例如:
@Composable
fun Parent() {
var text by remember { mutableStateOf("hello") }
ChildA(text)
ChildB(text.length) // 每次 text 变化时,ChildA 和 ChildB 都重组
}
这里 ChildB 只需要 text.length,但每次 text 改变时它都会重组。使用 derivedStateOf 可隔离依赖:
@Composable
fun Parent() {
var text by remember { mutableStateOf("hello") }
val length by remember { derivedStateOf { text.length } }
ChildA(text)
// ChildB 只依赖于 length,不会因 text 值变化而重组
ChildB(length)
}
更复杂的链式重组常发生在列表过滤、排序场景:
坏示例: 直接在 Composable 内 list.filter { ... } → 每次原始列表变化都重新 filter 并重新创建新列表 → 触发重组。
好示例: 使用 derivedStateOf { list.filter { ... } } → 仅当 filter 结果不同时才通知重组。
4.4 依赖稳定与稳定性注解
derivedStateOf 的依赖是否稳定直接决定了是否能跳过不必要重组。Compose 编译器会检查依赖的 稳定性(即是否实现了 equals,且字段不可变)。对于非稳定类型(如普通 List、Map),每次创建新实例都会被判定为“不同”,导致 derivedStateOf 计算始终执行新值。
解决方案:
- 使用
immutable集合库(如 Kotlinx Immutable Collections)确保同一内容返回同一实例。 - 或手动添加
@Stable/@Immutable注解标记自己的数据类:
@Immutable
data class User(val id: Int, val name: String)
Compose 编译器会信任这些注解,避免对稳定类型的多余计算。
4.5 代码实现示例
以下示例展示一个待办列表,使用 derivedStateOf 将未完成项的数量独立出来,避免每次添加/删除 todo 时都重新计算数量并触发整个 UI 重组。
@Composable
fun TodoApp() {
var todos by remember {
mutableStateListOf(Todo("Learn Compose", false), Todo("Write blog", false))
}
// 派生状态:未完成数量
val incompleteCount by remember {
derivedStateOf {
todos.count { !it.isCompleted }
}
}
Column {
Text("未完成: $incompleteCount") // 仅当 incompleteCount 变化时重组
todos.forEachIndexed { index, todo ->
TodoItem(todo, onToggle = {
todos[index] = todo.copy(isCompleted = !todo.isCompleted)
})
}
}
}
若直接在 Composable 内写 todos.count { !it.isCompleted },每次 todos 列表变化(哪怕是添加已完成项)都会导致这行表达式重新求值,进而触发 Text 的重组——但这里 incompleteCount 可能没变,造成了浪费。
4.6 调试工具与重组计数器
使用 Compose 编译器自带的 重组计数 工具验证优化效果:
// build.gradle.kts
composeOptions {
kotlinCompilerExtensionVersion = "1.5.10"
}
android {
composeOptions {
kotlinCompilerExtensionVersion = "1.5.10"
}
}
在 Composable 函数上添加 @Composable 后,配合 Layout Inspector 的 Compose 模块可查看每个组件的重组次数。推荐使用第三方库 compose-recompose-analyzer 或直接在 Logcat 输出:
// 调试辅助
@Composable
fun DebugRecompose(tag: String) {
var recomposeCount by remember { mutableIntStateOf(0) }
val currentCount = recomposeCount
LaunchedEffect(Unit) {
recomposeCount++
Log.d("Recompose", "$tag recompose $currentCount")
}
}
将 DebugRecompose 插入可疑组件,对比优化前后重组次数。
4.7 常见错误与避坑
| 错误模式 | 原因 | 正确做法 |
|---|---|---|
在 derivedStateOf 内创建新对象 |
每次计算都返回新实例(如 list.sorted()),导致总是触发重组 |
使用不可变集合或缓存结果 |
| 依赖非稳定对象而未加注解 | Compose 认为依赖总在变化,derivedStateOf 失效 |
添加 @Immutable 或使用稳定集合 |
在 derivedStateOf 中读取多个状态且顺序不确定 |
Compose 依赖收集可能遗漏,导致计算值不更新 | 确保 calculation 内显式读取所有依赖,或使用 snapshotFlow + map |
将 derivedStateOf 用于副作用 |
在重组前多次调用计算,造成额外开销 | 仅用于纯派生计算;副作用用 LaunchedEffect |
小心: derivedStateOf 内部不要使用 mutableStateOf 或修改外部变量,否则会导致状态不一致或无限循环。
4.8 性能验证
通过以下步骤验证优化:
- 使用 Compose Layout Inspector 记录优化前每个组件的重组次数。
- 应用
derivedStateOf重构派生逻辑后,再次记录重组次数。 - 对比 派生计算次数 与 UI 重组次数:理想情况下,派生计算次数应远小于原始状态变化次数,且 UI 重组次数等于派生值真实变化次数。
- 打开 Android Studio 的 Profiler 观察帧率,确保优化后不会引入额外 GC 压力。
典型结果对比:
| 场景 | 原始方案重组次数(1000次变化) | derivedStateOf方案重组次数 |
|---|---|---|
| 列表过滤(过滤条件不变) | 1000 | 1 |
| 展示页面标题长度 | 按 text 变化次数 | 仅在 length 实际变化时 |
| 多条件排序结果 | 排序逻辑每次执行 + UI 重组 | 排序逻辑仅在输入变化时执行 |
通过上述原理、代码示例和验证方法,可以确保 derivedStateOf 在项目中正确运用,显著减少不必要的重组,提升滑动列表、动态表单等场景的性能。