4. derivedStateOf 与 map 优化:派生状态计算、避免链式重组、依赖稳定

4.1 重组触发原理

Compose 的重组是基于 快照状态 变化的。当可观测状态(mutableStateOf、StateFlow 等)的值发生改变时,读取该状态的可组合函数会进入重组。但在实际项目中,UI 常常只需要状态的派生值(如过滤、映射、聚合结果),而不是原始状态本身。

直接读取原始状态然后进行计算,会导致每次原始状态变化时都触发重组,即使派生结果没有实际改变(例如列表 item 的 text 由两个属性拼接而成,但只有其中一个属性变化)。derivedStateOf 正是为了解决这个问题而设计的:它仅在 依赖的稳定状态 发生变化后重新计算,且只在计算结果与上次不同时才触发现有的重组。

核心思想: 将“状态 → 计算 → UI”中的计算从重组阶段提升到派生阶段,降低重组频率。

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 性能验证

通过以下步骤验证优化:

  1. 使用 Compose Layout Inspector 记录优化前每个组件的重组次数。
  2. 应用 derivedStateOf 重构派生逻辑后,再次记录重组次数。
  3. 对比 派生计算次数 与 UI 重组次数:理想情况下,派生计算次数应远小于原始状态变化次数,且 UI 重组次数等于派生值真实变化次数。
  4. 打开 Android Studio 的 Profiler 观察帧率,确保优化后不会引入额外 GC 压力。

典型结果对比:

场景 原始方案重组次数(1000次变化) derivedStateOf方案重组次数
列表过滤(过滤条件不变) 1000 1
展示页面标题长度 按 text 变化次数 仅在 length 实际变化时
多条件排序结果 排序逻辑每次执行 + UI 重组 排序逻辑仅在输入变化时执行
derivedStateOf 优化原理示意 无 derivedStateOf State (text) length → 计算 ChildA ChildB text 变化 → 两组件都重组 使用 derivedStateOf State (text) derivedStateOf length ChildA ChildB text 变化 → ChildA 重组;ChildB 仅当 length 变化 派生计算节省:减少 50%+ 重组

通过上述原理、代码示例和验证方法,可以确保 derivedStateOf 在项目中正确运用,显著减少不必要的重组,提升滑动列表、动态表单等场景的性能。