Compose 状态管理:remember、rememberSaveable 与状态提升

Compose 状态管理:remember、rememberSaveable 与状态提升

背景

上一篇我们用 Jetpack Compose 写出了第一个声明式 UI 页面。真正开始做业务页面后,最先遇到的问题通常不是布局,而是状态:输入框里的文字放在哪里?按钮点击后的 loading 怎么控制?列表刷新后为什么某些 item 的状态错位?页面旋转后数据为什么没了?

Compose 的思路和传统 View 不一样。传统 View 更像是"拿到控件,然后修改控件";Compose 更像是"给定当前状态,界面自然长成对应样子"。所以写好 Compose 的关键,就是先把状态设计清楚。

本文会按从浅到深的顺序讲清楚:

  1. 什么是 Compose 状态。
  2. remember 和 rememberSaveable 的区别。
  3. 为什么要做状态提升。
  4. ViewModel 如何和 Compose 配合。
  5. 常见副作用和踩坑点。

状态是什么

状态就是会影响 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 状态管理可以先记住这几条:

  1. UI 由状态驱动,状态变化后 Compose 自动重组。
  2. remember 保存组合生命周期内的局部状态。
  3. rememberSaveable 适合保存简单、可恢复的 UI 状态。
  4. 可复用组件优先做状态提升:状态往下传,事件往上传。
  5. 页面级业务状态放 ViewModel,Compose 只订阅并展示。
  6. 动态列表提供稳定 key,避免 item 状态错位。
  7. 副作用不要直接写在 Composable 函数体里。

掌握这些后,再去学 Navigation、动画、分页和复杂交互,Compose 页面会稳定很多,也更接近真实项目里的写法。

相关推荐
Csvn8 小时前
WASM
前端
修己xj10 小时前
我用 AI 给自己写了一个 Markdown 编辑器,然后把它开源了
前端
FRing0310 小时前
vue2干货总结2(v-bind、v-for、v-model、指令修饰符、v-bind操作class和style)
开发语言·前端·vue.js·vue2
幸运小猿10 小时前
模拟慢上传-处理超时重试image 丢失线上问题
前端·javascript·html
moshuying12 小时前
网购能买Token了!
前端
h_654321012 小时前
echarts实现柱状图加一条横线
前端·echarts
风骏时光牛马12 小时前
生成一个后端标题,只需要一个标题,不需要其他的任何文本。
前端
子非鱼@Itfuture12 小时前
关于前后端联调中登录状态/登录信息失效?
java·前端
郭小李12 小时前
openFeign入门
java·开发语言·前端
馒头不想说话12 小时前
AI当运营,帮你省人力
前端