4、状态管理与重组:remember、mutableStateOf、StateFlow与collectAsState、重组作用域与优化

做过几个 Compose 项目之后你会发现,状态管理是整个应用的「神经中枢」。写得舒服,后面怎么改都顺手;写得乱,随便改个按钮都会导致整个界面闪一下。嗯,这一章咱们就来聊聊这件事。

我个人习惯把状态管理分成两层:组件内部的状态和跨组件的共享状态。前者用 remember + mutableStateOf 就行,后者则要上 StateFlow 配合 collectAsState。这两个模式背后都牵扯一个核心概念——重组作用域。不理解作用域,优化就是空中楼阁。

Compose 状态管理核心路径 Compose 状态管理 本地状态·remember + mutableStateOf 单个 Composable 内部状态 状态变化 → 仅重组本组件 跨层状态·StateFlow + collectAsState ViewModel / Repository 层 StateFlow 发出新值 → collectAsState 所有 collect 的 Composable 重组 重组优化·作用域与 derivedStateOf 重组范围 = 读取状态的最近父级 derivedStateOf 减少不必要重组 snapshotFlow 响应式副作用 核心原则:状态放在最靠近使用处的层级, 避免「状态上提」过多导致大范围无效重组。

4.1 remember + mutableStateOf —— 组件里的「小本本」

如果你只想在一个 Composable 函数内部记一个值,比如输入框的文本、开关的状态,那我建议直接用 remember 包装一个 mutableStateOf。为什么?因为 Compose 的运行时只认得这个组合:当 mutableStateOf 的值变化时,它会让读取这个状态的 Composable 进入重组流程。

@Composable
fun MyTextField() {
    var text by remember { mutableStateOf("") }
    TextField(
        value = text,
        onValueChange = { text = it }
    )
}

核心要点:

  • remember 保证了重组时不会重置状态(除非离开组合)。
  • mutableStateOf 返回一个 MutableState 对象,读取它时会自动注册重组依赖。
  • 用 by 委托语法更简洁,但别忘了 import androidx.compose.runtime.getValue 和 setValue。
⚠️ 我曾经踩过的坑: 把remember写成了rememberSaveable,结果配置变更(比如屏幕旋转)时状态倒是保留了,但单元测试时发现序列化爆炸。后来我总结:只有必须跨配置变更保留的状态才用 rememberSaveable,否则一律 remember。不然你以为节省了工作量,实际多了一堆序列化代码。

4.2 StateFlow + collectAsState —— 跨组件的「广播」

当状态需要被多个 Composable 共享,或者从 ViewModel / Repository 里出来,StateFlow 就是首选。为什么不是 MutableLiveData?因为 StateFlow 天然支持协程,配合 Compose 的 collectAsState 可以无缝转为 Compose 状态。

class MyViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(UiState())
    val uiState: StateFlow<UiState> = _uiState.asStateFlow()

    fun doSomething() {
        _uiState.update { it.copy(data = "新数据") }
    }
}

@Composable
fun MyScreen(viewModel: MyViewModel) {
    val state by viewModel.uiState.collectAsState()
    // 使用 state
}

对比表格:

特点 remember + mutableStateOf StateFlow + collectAsState
生命周期 随 Composable 组合存在 随 ViewModel / 协程作用域存在
共享范围 单个 Composable 内部 任意多个 Composable 订阅
背压处理 无(单值) 自带(丢弃中间值)
测试方便度 直接用 runComposeTestRule 需配合 Turbine 库做流测试
💡 小技巧: 如果你需要从多个 StateFlow 组合出 UI 状态,不要嵌套 collectAsState。用 combine 操作符把它们合并成一个 Flow,然后在顶层收集一次,这样性能更好,也不会造成多余重组。

4.3 重组作用域 —— Compose 的「最小更新单元」

说白了,Compose 并不知道你要「刷新哪个按钮」,它只知道哪个 Composable 读取了变化的状态。读取的位置就是重组的起点。这个位置叫重组作用域(Recompose Scope)。默认情况下,整个 Composable 函数体就是一个作用域。

重要规则: 只有读取 State 对象的 Composable 及其子项才会被重组。父级如果没读这个状态,不会重组。这跟 React 的「状态提升」类似,但 Compose 的更细粒度。

举个例子:

@Composable
fun Parent() {
    var count by remember { mutableStateOf(0) }
    Child(count)       // Child 读取 count,所以 count 变时 Child 重组
    UnrelatedText()    // 没读 count,所以不会重组
}
  • Child(count) 因为参数中包含状态值,所以每次重组时 Child 会重新执行。
  • UnrelatedText() 根本没碰 count,即使它在 Parent 里,也不会被牵涉。

我在项目中遇到过这样一个性能问题:一个复杂的列表项里,每个项都读取了 ViewModel 中的某个 StateFlow,结果任何一个状态变化,所有列表项都跟着重组。后来我把状态拆细,让每个列表项只订阅自己需要的数据字段,重组量直接降了 80%。

4.4 优化重组:derivedStateOf 与 snapshotFlow

你可能会遇到这种情况:多个状态变化只关心它们组合后的结果。比如根据 isLoggedIn 和 userPermissions 计算 canEdit。如果分别监听这两个状态,任何一个变化都会导致重组。这时候 derivedStateOf 就派上用场了。

val canEdit by remember {
    derivedStateOf { isLoggedIn && userPermissions.contains("edit") }
}

derivedStateOf 内部缓存了计算结果,只有当依赖的状态实际改变时才会重新计算并触发重组。说白了,它就是 Compose 版本的「计算属性」。

反过来,如果你需要把 Compose 状态「反向」变成 Flow 用在其他协程里,可以用 snapshotFlow。比如你想在状态变化时发一个网络请求:

LaunchedEffect(Unit) {
    snapshotFlow { someState }
        .filter { it.isNotEmpty() }
        .debounce(300)
        .collect { query -> viewModel.search(query) }
}
⚠️ 注意: snapshotFlow 默认在读取时拍下快照,不要在内部写副作用(如直接改状态),否则容易产生无限循环。用 collect 做副作用才是正道。

4.5 实战避坑:我翻过哪些车

  1. 忘记加 remember 导致死循环
    我曾经写过一个 mutableStateOf 没有包 remember,结果每次重组都重新创建新状态对象,然后触发重组,再创建……直接 OOM。嗯,那个排查花了我一整个下午。记住:状态必须 remember 住。
  2. StateFlow + collectAsState 在非 UI 线程发射
    如果你在 IO 线程 _state.value = ...,收集端默认会跟随发射线程。后来我用 .flowOn(Dispatchers.Main) 强制切回主线程,或者用 collectAsState 的 Dispatchers.Main 版本。不过 Compose 1.5+ 已经自动帮你在主线程收集了,老项目注意。
  3. 重组作用域太大
    一个常见的坏习惯:在顶层 Composable 里 collectAsState,然后把整个状态对象传下去。任何子字段变化,顶层都重组,下面所有读取该对象的函数也重组。我的解决方案是「按需传递」——只传递具体字段或者使用 derivedStateOf 做局部计算。

4.6 一句话总结

状态管理的本质就是控制「谁在听」和「听什么」。本地状态用 remember + mutableStateOf,跨层状态用 StateFlow + collectAsState,用 derivedStateOf 减少不必要的触发,用 snapshotFlow 桥接协程。记住:重组作用域越小,性能越好;状态上提越少,代码越稳。