2. remember 与 rememberSaveable 正确使用:缓存对象、避免重复计算、状态重建

2.1 重组触发条件中的状态源头

重组(Recomposition)是 Compose 响应式核心机制。当可组合函数读取的 State 对象发生变化时,Compose 运行时调度器会将该函数标记为“无效”,并在下一帧触发重组。而 remember 与 rememberSaveable 正是控制状态声明周期的关键 API —— 它们决定了状态何时创建、何时保留、何时重建,从而直接影响重组的频率与范围。

  • remember:在组合(Composition)期间缓存对象,当键值不变且组合仍在组合树中时,跳过重新计算。
  • rememberSaveable:在 remember 的缓存能力基础上,额外通过 Bundle 保存状态,使其在 Activity 重建或配置变更后恢复。

只有正确理解这两个函数的适用范围与键值规则,才能从源头控制不必要的重组开销。

2.2 状态声明与缓存策略对比

特性 remember rememberSaveable
声明周期 仅组合期间;离开组合树则释放 穿过 Activity 重建、进程杀(有限)
参数列表 key + calculation key + Saver + calculation
缓存类型 任意对象(不要求序列化) 必须可打包到 Bundle(或自定义 Saver)
适用场景 计算缓存、非持久 UI 状态、闭包对象 文本输入框内容、Tab 选中项、滚动偏移
错误代价 重启 App 或配置变更后状态丢失 过度使用导致 Parcelable 异常或性能劣化

2.3 使用步骤与代码实现

步骤一:使用 remember 缓存昂贵计算

以下代码展示了在列表项中使用 remember 避免重复执行格式化函数:

@Composable
fun MessageItem(message: Message, modifier: Modifier = Modifier) {
    val formattedTime = remember(message.timestamp) {
        // 高开销的日期格式化操作
        formatTimestamp(message.timestamp)
    }
    Text(text = "${message.content} — $formattedTime", modifier = modifier)
}

关键点:key 参数为 message.timestamp。仅当时间戳改变时才会重新计算 formattedTime,否则复用缓存结果。

步骤二:使用 rememberSaveable 重建关键状态

对于必须在 Activity 重建后恢复的 UI 状态,必须使用 rememberSaveable:

@Composable
fun SearchScreen() {
    var query by rememberSaveable { mutableStateOf("") }
    var searchResults by rememberSaveable { mutableStateOf(listOf<SearchResult>()) }

    // 输入框状态会自动在配置变更后恢复
    TextField(
        value = query,
        onValueChange = { query = it },
        placeholder = { Text("输入搜索内容") }
    )
}
⚠ 常见陷阱:不要将 rememberSaveable 用于大型数据列表或不可序列化的对象(如 DBFlow 查询结果)。必须在 Saver 中正确处理 Parcelable/Serializable,否则会引发运行时崩溃。

2.4 优化策略:键值选择与派生状态

策略一:细粒度的键值

键值应使用稳定且不可变的类型。避免将整个数据类实例作为键,而是提取真正影响缓存结果的属性字段:

// 不好的实践 – 整个 user 对象作为键
val displayName by remember(user) { buildDisplayName(user) }

// 推荐的实践 – 只取 key 属性
val displayName by remember(user.id, user.locale) { buildDisplayName(user) }

策略二:使用 derivedStateOf 避免重复计算

当状态由多个上游状态派生而来时,手动 remember + 计算可能仍会因键值变化被反复调用。改用 derivedStateOf 内部能自动追踪依赖:

val listState = rememberLazyListState()
val isScrolledUp by remember {
    derivedStateOf {
        listState.firstVisibleItemIndex > 0 || 
        listState.firstVisibleItemScrollOffset > 0
    }
}

2.5 调试工具:验证重组次数

Android Studio 提供 Compose 重组计数工具。在可组合函数中添加局部状态,并使用 “Compose Inspector” 观察重组标记:

@Composable
fun DebugCounter() {
    val recomposeCount = remember { mutableIntStateOf(0) }

    // 每重组一次,计数就会增加
    LaunchedEffect(Unit) {
        recomposeCount.intValue++
    }
    Text("重组次数: ${recomposeCount.intValue}")
}

在 Logcat 中使用 Compose Recomposition Logging 过滤 Compose:Recompose 标签,可看到精确的重组路径与耗时。

2.6 常见错误与避坑指南

错误代码 问题描述 正确做法
var state by remember { mutableStateOf(expensiveObj()) }
rememberLambda 中调用了高开销初始化,每次组合阶段都执行 将 expensiveObj() 移到 remember 外部或使用 LaunchedEffect
remember(user) { mutableStateOf(user.name) }
键值改变了,但内部状态重新初始化为旧值,并未同步更新 使用 SnapshotFlow 或 LaunchedEffect(user) { name = user.name }
rememberSaveable { mutableStateOf(activityRef) }
在 Bundle 中存储了 Activity 引用,导致内存泄漏或 Parcelable 异常 只存储可序列化的原始类型或数据类,避免引用 Activity 或 View
remember(null) { … }
键值为 null,导致所有重组都重新计算,失去缓存意义 永远使用非 null 的稳定键值

2.7 性能验证:重组路径可视化

在 Android Studio 中运行 App,选择 View → Tool Windows → Layout Inspector,勾选 "Show recomposition counts"。点击组件后可见其被重组的次数。结合下图的流程,可以快速定位哪个状态源触发了过多的节点重组。

remember/rememberSaveable 状态生命周期与重组控制流程图 状态声明 → 重组触发 → 跳过重组 → 状态保存/恢复 1. 状态声明 remember / rememberSaveable 2. 键值比较 key 是否改变? 3. 跳过重组 复用缓存结果 4. 重新计算 执行 calculation lambda 5. 保存/恢复 Bundle 序列化 调试建议 使用 Layout Inspector 观察重组计数变化
▸ 关键结论:键值 key 是决定是否跳过重组的唯一依据。选择一个不可变、不无故变化的键,能让 remember 的缓存效率最大化,从根源降低重组范围。

2.8 部署检查清单

  • ✅ 所有计算密集型表达式都已包裹在 remember 中并指定了正确的键值。
  • ✅ 配置变更后需要保留的 UI 状态(输入框内容、滚动位置、选中索引)均使用 rememberSaveable。
  • ✅ 未在 rememberSaveable 的 calculation lambda 中放置大型非序列化对象。
  • ✅ 已使用 Compose Inspector 查看真实重组次数,确认片段级重组而非全树重组。
  • ✅ 使用 derivedStateOf 或 SnapshotFlow 替代了不必要的显式 remember 计算。