4、状态管理与重组:remember、mutableStateOf、StateFlow与collectAsState、重组作用域与优化
做过几个 Compose 项目之后你会发现,状态管理是整个应用的「神经中枢」。写得舒服,后面怎么改都顺手;写得乱,随便改个按钮都会导致整个界面闪一下。嗯,这一章咱们就来聊聊这件事。
我个人习惯把状态管理分成两层:组件内部的状态和跨组件的共享状态。前者用 remember + mutableStateOf 就行,后者则要上 StateFlow 配合 collectAsState。这两个模式背后都牵扯一个核心概念——重组作用域。不理解作用域,优化就是空中楼阁。
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 库做流测试 |
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 实战避坑:我翻过哪些车
-
忘记加 remember 导致死循环
我曾经写过一个mutableStateOf没有包remember,结果每次重组都重新创建新状态对象,然后触发重组,再创建……直接 OOM。嗯,那个排查花了我一整个下午。记住:状态必须remember住。 -
StateFlow + collectAsState 在非 UI 线程发射
如果你在 IO 线程_state.value = ...,收集端默认会跟随发射线程。后来我用.flowOn(Dispatchers.Main)强制切回主线程,或者用collectAsState的Dispatchers.Main版本。不过 Compose 1.5+ 已经自动帮你在主线程收集了,老项目注意。 -
重组作用域太大
一个常见的坏习惯:在顶层 Composable 里collectAsState,然后把整个状态对象传下去。任何子字段变化,顶层都重组,下面所有读取该对象的函数也重组。我的解决方案是「按需传递」——只传递具体字段或者使用derivedStateOf做局部计算。
4.6 一句话总结
状态管理的本质就是控制「谁在听」和「听什么」。本地状态用 remember + mutableStateOf,跨层状态用 StateFlow + collectAsState,用 derivedStateOf 减少不必要的触发,用 snapshotFlow 桥接协程。记住:重组作用域越小,性能越好;状态上提越少,代码越稳。