Compose 状态管理:remember、rememberSaveable 与状态提升
背景
上一篇我们用 Jetpack Compose 写出了第一个声明式 UI 页面。真正开始做业务页面后,最先遇到的问题通常不是布局,而是状态:输入框里的文字放在哪里?按钮点击后的 loading 怎么控制?列表刷新后为什么某些 item 的状态错位?页面旋转后数据为什么没了?
Compose 的思路和传统 View 不一样。传统 View 更像是"拿到控件,然后修改控件";Compose 更像是"给定当前状态,界面自然长成对应样子"。所以写好 Compose 的关键,就是先把状态设计清楚。
本文会按从浅到深的顺序讲清楚:
- 什么是 Compose 状态。
remember和rememberSaveable的区别。- 为什么要做状态提升。
- ViewModel 如何和 Compose 配合。
- 常见副作用和踩坑点。
状态是什么
状态就是会影响 UI 展示的数据。比如:
- 输入框当前输入的文本。
- 按钮是否可点击。
- 接口是否正在加载。
- 列表当前数据。
- 弹窗是否显示。
- 当前选中的 Tab。
在 Compose 中,只要一个 Composable 读取了某个可观察状态,当状态变化后,Compose 就会重新执行相关 Composable,这个过程叫重组(Recomposition)。
最简单的例子:
kotlin
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) {
Text(text = "点击了 $count 次")
}
}
这里 count 就是状态。点击按钮后 count 变化,Text 自动显示新数字。我们没有手动调用 textView.setText(),UI 由状态驱动。
remember:在重组之间保存状态
先看一个错误写法:
kotlin
@Composable
fun BadCounter() {
var count = 0
Button(onClick = { count++ }) {
Text(text = "点击了 $count 次")
}
}
这段代码看起来像能工作,但实际不可靠。因为 Composable 函数可能因为重组被重新执行,普通局部变量会重新初始化成 0。你修改了变量,Compose 也不知道它需要刷新 UI。
正确写法是使用 remember 配合 mutableStateOf:
kotlin
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) {
Text(text = "点击了 $count 次")
}
}
remember 的作用是:在当前组合生命周期内记住这个值。只要这个 Composable 还在组合树里,重组时就不会重新创建这份状态。
常见输入框也类似:
kotlin
@Composable
fun SearchBox() {
var keyword by remember { mutableStateOf("") }
OutlinedTextField(
value = keyword,
onValueChange = { keyword = it },
label = { Text("搜索关键词") }
)
}
这里 value 由状态决定,onValueChange 负责更新状态,这就是 Compose 中典型的双向交互写法。
rememberSaveable:配置变化后也尽量保存
remember 能跨过重组,但不一定能跨过 Activity 重建。例如横竖屏切换、系统回收后恢复,普通 remember 状态可能会丢失。
如果状态比较简单,并且希望配置变化后仍然保留,可以使用 rememberSaveable:
kotlin
@Composable
fun SearchBox() {
var keyword by rememberSaveable { mutableStateOf("") }
OutlinedTextField(
value = keyword,
onValueChange = { keyword = it },
label = { Text("搜索关键词") }
)
}
rememberSaveable 底层会借助 Android 的保存状态机制,适合保存字符串、数字、布尔值,或者能放进 Bundle 的简单数据。
但它不是万能的。下面这些不适合直接塞进 rememberSaveable:
- 大列表数据。
- 网络请求结果的完整对象图。
- 数据库连接、Repository、Context。
- 复杂业务状态。
复杂业务状态更适合放到 ViewModel 中。
状态提升:让组件更可复用
刚开始写 Compose 时,很容易把状态直接写在组件内部:
kotlin
@Composable
fun FavoriteButton() {
var checked by remember { mutableStateOf(false) }
IconButton(onClick = { checked = !checked }) {
Icon(
imageVector = if (checked) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
contentDescription = null
)
}
}
这段代码能用,但复用性差:外部不知道当前是否收藏,也无法从接口数据里控制它。
更推荐做状态提升,把状态和事件交给外部:
kotlin
@Composable
fun FavoriteButton(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier
) {
IconButton(
onClick = { onCheckedChange(!checked) },
modifier = modifier
) {
Icon(
imageVector = if (checked) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
contentDescription = if (checked) "取消收藏" else "收藏"
)
}
}
调用方再决定状态放在哪里:
kotlin
@Composable
fun ArticleItem() {
var favorite by rememberSaveable { mutableStateOf(false) }
FavoriteButton(
checked = favorite,
onCheckedChange = { favorite = it }
)
}
状态提升后,组件变成了"受控组件":它只负责展示和触发事件,不偷偷保存业务状态。这种写法更容易测试,也更容易接入真实数据。
单一数据源:不要复制状态
状态管理里最重要的原则之一是单一数据源。同一份信息最好只有一个权威来源。
比如页面上有一个任务列表,每个任务都有完成状态。不要同时维护:
kotlin
var tasks by remember { mutableStateOf(listOf<Task>()) }
var checkedMap by remember { mutableStateOf(mapOf<String, Boolean>()) }
如果 tasks 和 checkedMap 都能表达"是否完成",就很容易出现两边不一致。更好的做法是让 Task 本身包含完成状态:
kotlin
data class Task(
val id: String,
val title: String,
val done: Boolean
)
更新时只更新列表中的对应项:
kotlin
fun toggleTask(id: String) {
tasks = tasks.map { task ->
if (task.id == id) task.copy(done = !task.done) else task
}
}
Compose 会根据新的列表状态刷新 UI。
列表状态:稳定 key 很重要
Compose 的 LazyColumn 展示动态列表时,建议给每个 item 提供稳定 key:
kotlin
LazyColumn {
items(
items = tasks,
key = { it.id }
) { task ->
TaskRow(task = task)
}
}
如果不提供 key,当列表插入、删除、排序时,Compose 可能按位置复用 item,导致某些内部状态看起来"串了"。比如第 2 项展开后,删除第 1 项,展开状态可能跑到新的第 2 项上。
稳定 key 的原则:
- 优先使用后端返回的唯一 id。
- 没有 id 时,本地生成稳定 id。
- 不要用列表下标当 key,除非列表永远不插入、不删除、不排序。
ViewModel:业务状态放到更稳定的位置
页面级业务状态通常不建议全部放在 Composable 内部。比如:
- 网络加载状态。
- 分页列表数据。
- 登录信息。
- 表单提交结果。
- 错误提示。
这些更适合放到 ViewModel 中,由 Compose 订阅状态。
一个常见结构如下:
kotlin
data class ArticleUiState(
val loading: Boolean = false,
val articles: List<Article> = emptyList(),
val errorMessage: String? = null
)
class ArticleViewModel : ViewModel() {
private val _uiState = MutableStateFlow(ArticleUiState())
val uiState: StateFlow<ArticleUiState> = _uiState.asStateFlow()
fun refresh() {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(loading = true, errorMessage = null)
runCatching {
repository.loadArticles()
}.onSuccess { list ->
_uiState.value = ArticleUiState(articles = list)
}.onFailure { e ->
_uiState.value = ArticleUiState(errorMessage = e.message ?: "加载失败")
}
}
}
}
在 Compose 页面中收集状态:
kotlin
@Composable
fun ArticleScreen(
viewModel: ArticleViewModel = viewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
ArticleContent(
uiState = uiState,
onRefresh = viewModel::refresh
)
}
collectAsStateWithLifecycle() 来自 androidx.lifecycle:lifecycle-runtime-compose,它会结合生命周期收集 Flow,避免页面不可见时还持续收集。
页面内容组件继续保持无状态:
kotlin
@Composable
fun ArticleContent(
uiState: ArticleUiState,
onRefresh: () -> Unit
) {
when {
uiState.loading -> CircularProgressIndicator()
uiState.errorMessage != null -> ErrorView(
message = uiState.errorMessage,
onRetry = onRefresh
)
else -> ArticleList(articles = uiState.articles)
}
}
这样分层后,ViewModel 负责业务状态,Composable 负责展示状态。
派生状态:derivedStateOf
有些状态不是独立来源,而是可以从现有状态计算出来。比如列表是否为空、按钮是否可提交:
kotlin
var username by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
val canSubmit by remember {
derivedStateOf {
username.isNotBlank() && password.length >= 6
}
}
derivedStateOf 适合用于从其他状态派生结果,减少不必要的重复计算。注意不要滥用,普通简单表达式直接写也可以。
kotlin
val canSubmit = username.isNotBlank() && password.length >= 6
如果计算很轻量,直接写更清晰;如果计算依赖状态且比较频繁、比较重,再考虑 derivedStateOf。
副作用:不要在 Composable 函数体里直接做事
Composable 函数可能会因为重组反复执行,所以不要在函数体里直接写网络请求、数据库写入、埋点上报这类副作用:
kotlin
@Composable
fun BadScreen(viewModel: ArticleViewModel) {
viewModel.refresh() // 错误:重组时可能反复调用
}
如果需要进入页面时执行一次,可以使用 LaunchedEffect:
kotlin
@Composable
fun ArticleScreen(viewModel: ArticleViewModel = viewModel()) {
LaunchedEffect(Unit) {
viewModel.refresh()
}
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
ArticleContent(uiState = uiState, onRefresh = viewModel::refresh)
}
LaunchedEffect(Unit) 会在当前 Composable 进入组合时启动协程,并在离开组合时取消。key 变化时会重新执行。
常见副作用工具:
LaunchedEffect:启动协程,适合请求数据、收集一次性事件。DisposableEffect:注册和释放监听器,比如广播、生命周期观察者。SideEffect:每次成功重组后执行,适合同步 Compose 状态到外部对象。rememberUpdatedState:在长生命周期副作用中拿到最新 lambda 或状态。
初学阶段先掌握 LaunchedEffect 和 DisposableEffect 就够用了。
一次性事件怎么处理
错误 toast、跳转页面、弹出 snackbar 这类事件,不太适合直接塞进普通 UI 状态里长期保存。否则旋转屏幕或重新收集状态时,事件可能重复触发。
常见做法是用 SharedFlow 或 Channel 发送一次性事件:
kotlin
sealed interface ArticleEvent {
data class Toast(val message: String) : ArticleEvent
data object NavigateBack : ArticleEvent
}
class ArticleViewModel : ViewModel() {
private val _events = MutableSharedFlow<ArticleEvent>()
val events = _events.asSharedFlow()
fun submit() {
viewModelScope.launch {
_events.emit(ArticleEvent.Toast("提交成功"))
}
}
}
Compose 中用 LaunchedEffect 收集:
kotlin
@Composable
fun ArticleScreen(viewModel: ArticleViewModel = viewModel()) {
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
when (event) {
is ArticleEvent.Toast -> snackbarHostState.showSnackbar(event.message)
ArticleEvent.NavigateBack -> {
// navController.popBackStack()
}
}
}
}
}
这样 UI 状态和一次性事件就分开了。
常见坑
把所有状态都塞进 remember。 remember 适合组件局部状态,不适合复杂业务状态。页面级数据优先考虑 ViewModel。
在子组件内部偷偷改业务状态。 子组件越"自作主张",越难复用。业务状态尽量从上往下传,事件从下往上传。
没有稳定 key。 动态列表没有 key 时,插入删除后可能出现状态错位,尤其是 item 内部有展开、输入框、动画状态时。
把副作用写在函数体里。 Composable 不是 onCreate(),函数体可能多次执行。请求数据、注册监听、跳转页面都要放到合适的 Effect 或 ViewModel 中。
状态对象可变但引用不变。 Compose 更容易感知不可变数据的替换。如果你修改 mutableList 内部元素但没有替换列表引用,UI 可能不会刷新。推荐使用不可变列表和 copy() 更新。
总结
Compose 状态管理可以先记住这几条:
- UI 由状态驱动,状态变化后 Compose 自动重组。
remember保存组合生命周期内的局部状态。rememberSaveable适合保存简单、可恢复的 UI 状态。- 可复用组件优先做状态提升:状态往下传,事件往上传。
- 页面级业务状态放 ViewModel,Compose 只订阅并展示。
- 动态列表提供稳定 key,避免 item 状态错位。
- 副作用不要直接写在 Composable 函数体里。
掌握这些后,再去学 Navigation、动画、分页和复杂交互,Compose 页面会稳定很多,也更接近真实项目里的写法。