Jetpack Compose 状态管理深度剖析:从 remember 到 Snapshot State
Compose 的状态系统表面上是 remember + mutableStateOf,背后其实是一套基于 snapshot 的细粒度依赖追踪。理解它才能避免 recomposition 的常见陷阱。
Jetpack Compose 已经成为 Android 声明式 UI 的事实标准,但“会用”和“用对”之间的差距在状态管理上格外明显。一不留神就会遇到:“列表为什么重绘”、“状态怎么不更新”、“recomposition 数量爆炸”这类问题。本文从底层 snapshot 机制讲起,给出工程上可落地的实践。
一、Snapshot State:Compose 的“响应式引擎”
mutableStateOf 不是简单的 observable,它底层是 Kotlin 编译器插件 + Snapshot 系统:
var count by mutableStateOf(0)
// 在 Composable 里读 count
@Composable
fun Counter() {
val c = count // Compose 在这里注册"读"依赖
Button(onClick = { count++ }) {
Text("$c")
}
}
当你读 count 时,Compose runtime 会记录“这个 Composable 依赖了这个 state”。当 count 被写入,Compose 找到所有依赖它的 Composable,标记为需要 recompose。
关键点:依赖追踪是读时建立的,不是声明时建立的。所以下面两段代码行为不同:
// 写法 A:每次 recompose 都建立依赖
@Composable
fun A() {
val list = someState.value.list // ← 读 list
// ...
}
// 写法 B:包在 lambda 里,依赖延迟到 lambda 执行时
@Composable
fun B() {
val list by remember { derivedStateOf { someState.value.list } }
// ...
}
写法 A 在某些路径下可能根本不读 list(条件分支跳过),导致依赖建立不稳定。用 derivedStateOf 把“读”行为收敛到一个稳定位置,是稳健的做法。
二、remember 的“四象限”
remember 有四种常见用法,心智模型容易混:
| API | 用途 | 失效时机 |
|---|---|---|
remember { ... } |
缓存计算结果 | 配置变更/进程销毁 |
rememberSaveable { ... } |
同上,但可跨配置变更 | 仅跨配置变更,进程销毁时由 Bundle 决定 |
derivedStateOf { ... } |
派生状态,依赖变化才重算 | 自动跟随源 state |
LaunchedEffect { ... } |
副作用,key 变化时重启 | key 变化 |
@Composable
fun SearchScreen(vm: SearchViewModel) {
val query by vm.query.collectAsStateWithLifecycle()
val results by remember(query) {
derivedStateOf { filter(vm.allItems, query) }
}
// ...
}
derivedStateOf 在这里扮演“防抖”角色——只有 query 真正变化时才重算 results,且 results 变化时下游才 recompose。
三、key 与稳定性:recomposition 的隐形开关
Compose 用 key 判断两个调用是否“同源”。当你写 LazyColumn 时,key 不仅是为了 diff,更是为了保留状态:
LazyColumn {
items(items, key = { it.id }) { item ->
val expanded = remember { mutableStateOf(false) }
// ...
}
}
没有 key,列表项重排时 expanded 状态会“跟着位置走”而不是“跟着数据走”。加了 key,状态正确绑定到 item id。
稳定性(Stability)
Compose 编译器会推断类型是否 stable。data class 默认 stable,List<String> 不 stable(因为是 interface)。不 stable 的参数会让 Composable 即使参数未变也 recompose。
// 用 @Immutable 强制声明
@Immutable
data class User(val id: String, val name: String, val avatar: String)
// 或用 @Stable 表示"可变但相等时内容相同"
@Stable
class UserHolder(var user: User)
四、副作用:用 LaunchedEffect 而不是 init
// 反例:在 Composable 顶层直接调用副作用
@Composable
fun Bad(vm: ViewModel) {
vm.loadData() // 每次 recompose 都触发!
}
// 正例:用 LaunchedEffect 把副作用限定到 lifecycle
@Composable
fun Good(vm: ViewModel) {
LaunchedEffect(Unit) {
vm.loadData()
}
}
LaunchedEffect(Unit) 表示“进入 composition 时执行一次”。LaunchedEffect(key) 则在 key 变化时取消上一次并重新执行。
LaunchedEffect(userId) {
// userId 变化时重新加载
vm.loadUser(userId)
}
五、collectAsStateWithLifecycle:节省电量的关键
// 反例:collectAsState 不感知 lifecycle,后台时仍在收集
val state by vm.state.collectAsState()
// 正例:用 collectAsStateWithLifecycle,后台时停止收集
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.0")
val state by vm.state.collectAsStateWithLifecycle()
后者在 Lifecycle 进入 STOPPED 时自动取消订阅,回到前台时恢复。这对电量敏感场景(地图、传感器)至关重要。
六、CompositionLocal:少用,慎用
val LocalTheme = staticCompositionLocalOf { Theme.Light }
@Composable
fun App() {
CompositionLocalProvider(LocalTheme provides Theme.Dark) {
Content()
}
}
CompositionLocal 看起来像“全局变量”,但它会穿透整个子树——任何子 Composable 读取它都会建立依赖。一旦 LocalTheme 变化,整个子树 recompose。
经验法则:
- 主题、字体、颜色这类“几乎不变”的值用
staticCompositionLocalOf(无依赖追踪开销) - 频繁变化的值不要用 CompositionLocal,显式传参
七、诊断工具:Layout Inspector + Compose Compiler Metrics
// build.gradle.kts
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_reports")
metricsDestination = layout.buildDirectory.dir("compose_metrics")
}
打开 Compose 编译器报告后,每个 Composable 会标记:
restartable:可被独立 recomposeskippable:参数未变可跳过readonly:参数是只读的
如果一个本应 skippable 的 Composable 被标记为 non-skippable,通常是参数列表里有不稳定类型——这就是性能优化的切入点。
八、实战:一个稳健的列表 + 详情页
@Composable
fun UserListScreen(vm: UserViewModel) {
val users by vm.users.collectAsStateWithLifecycle()
val selectedId by vm.selectedId.collectAsStateWithLifecycle()
LazyColumn {
items(users, key = { it.id }) { user ->
UserRow(
user = user,
isSelected = user.id == selectedId,
onClick = { vm.select(user.id) },
)
}
}
}
@Composable
private fun UserRow(
user: User, // @Immutable data class
isSelected: Boolean,
onClick: () -> Unit,
) {
val bgColor by animateColorAsState(if (isSelected) Colors.purple else Colors.surface)
Row(
modifier = Modifier.background(bgColor).clickable(onClick = onClick).padding(16.dp),
) {
Text(user.name)
}
}
要点:
key让状态跟随数据- 参数全 stable,UserRow 是 skippable,列表滚动时未变化的行不 recompose
- 动画状态用
animateXAsState隔离,避免动画驱动整行 recompose
结语
Compose 的状态系统不是“用了就对了”,而是“理解了才稳”。mutableStateOf + remember + derivedStateOf 是地基,key/stability/副作用是上层规范。把这套心智模型建立起来,你就能写出“recomposition 可预测、动画不掉帧、电量不浪费”的稳定 UI。
记住一句话:任何在 Composable 顶层直接执行的东西都要怀疑一遍——要么是 remember,要么是 LaunchedEffect,要么是 derivedStateOf,绝不应该有“裸”的逻辑。