姆姆极客分享MU·GEEK·SHARE
// Jetpack Compose · Snapshot State mutableStateOf snapshot #3 read read read recompose recompose skip write → invalidate → recompose (only if read)
移动客户端·

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:可被独立 recompose
  • skippable:参数未变可跳过
  • 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,绝不应该有“裸”的逻辑。