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 常见错误与避坑指南
| 错误代码 | 问题描述 | 正确做法 |
|---|---|---|
|
rememberLambda 中调用了高开销初始化,每次组合阶段都执行 | 将 expensiveObj() 移到 remember 外部或使用 LaunchedEffect |
|
键值改变了,但内部状态重新初始化为旧值,并未同步更新 | 使用 SnapshotFlow 或 LaunchedEffect(user) { name = user.name } |
|
在 Bundle 中存储了 Activity 引用,导致内存泄漏或 Parcelable 异常 |
只存储可序列化的原始类型或数据类,避免引用 Activity 或 View |
|
键值为 null,导致所有重组都重新计算,失去缓存意义 | 永远使用非 null 的稳定键值 |
2.7 性能验证:重组路径可视化
在 Android Studio 中运行 App,选择 View → Tool Windows → Layout Inspector,勾选 "Show recomposition counts"。点击组件后可见其被重组的次数。结合下图的流程,可以快速定位哪个状态源触发了过多的节点重组。
key 是决定是否跳过重组的唯一依据。选择一个不可变、不无故变化的键,能让 remember 的缓存效率最大化,从根源降低重组范围。
2.8 部署检查清单
- ✅ 所有计算密集型表达式都已包裹在
remember中并指定了正确的键值。 - ✅ 配置变更后需要保留的 UI 状态(输入框内容、滚动位置、选中索引)均使用
rememberSaveable。 - ✅ 未在
rememberSaveable的calculationlambda 中放置大型非序列化对象。 - ✅ 已使用 Compose Inspector 查看真实重组次数,确认片段级重组而非全树重组。
- ✅ 使用
derivedStateOf或SnapshotFlow替代了不必要的显式remember计算。