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

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

背景

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

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

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

  1. 什么是 Compose 状态。
  2. rememberrememberSaveable 的区别。
  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>()) }

如果 taskscheckedMap 都能表达"是否完成",就很容易出现两边不一致。更好的做法是让 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 或状态。

初学阶段先掌握 LaunchedEffectDisposableEffect 就够用了。

一次性事件怎么处理

错误 toast、跳转页面、弹出 snackbar 这类事件,不太适合直接塞进普通 UI 状态里长期保存。否则旋转屏幕或重新收集状态时,事件可能重复触发。

常见做法是用 SharedFlowChannel 发送一次性事件:

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 页面会稳定很多,也更接近真实项目里的写法。

相关推荐
星栈1 小时前
Dioxus 接数据库最容易写歪的 3 个地方:sqlx + SQLite 怎么接才顺
前端·rust·前端框架
晴虹1 小时前
vue3-scroll-more:横向滚动条-元素或页签过多滚动显示处理的组件
前端·vue.js
代码搬运媛1 小时前
Claude 全栈开发专用 Rules 配置
前端
PedroQue992 小时前
uni-router v1.7.0重磅更新:守卫重定向自由掌控
前端·uni-app
逸铭2 小时前
Day 4:登录与 Token——桌面端怎么存密钥
前端·客户端
溯朢2 小时前
TokUI 流式渲染的 SSE 全链路拆解
前端
京东云开发者2 小时前
京东 Oxygen xLLM 大模型推理引擎正式捐赠开放原子开源基金会,共建国产 AI Infra 生态
前端
Csvn2 小时前
LLM 一把梭:从 Swagger 文档到类型安全 API 请求,再也不手写接口
前端
DGT2 小时前
深入理解 JavaScript 闭包
前端