Logo SagesTool

彻底搞懂 Compose 重组与状态管理

彻底搞懂 Compose 重组与状态管理

刚从传统 View 系统切到 Jetpack Compose 的同学,通常都会遇到两类非常典型的问题:

  1. 点了按钮,变量明明变了,界面却不刷新。
  2. 页面终于能用了,一旋转屏幕,刚输入的数据又全没了。

这两个问题看起来不同,但核心都指向同一个主题:状态放在哪里,以及 Compose 什么时候会重新执行 UI。


一、Compose 为什么和传统 View 不一样

在传统 View 系统中,控件自己持有状态:

  • TextView 持有文本
  • CheckBox 持有勾选状态
  • EditText 持有输入内容

而 Compose 是声明式 UI。你不再“修改控件本身”,而是通过修改数据,让 UI 根据最新状态重新计算:

UI = f(State)

也就是说,UI 只是状态的函数结果。


二、为什么“点了按钮没反应”

最常见的错误写法大概是这样:

@Composable
fun BadCounter() {
    var count = 0

    Column {
        Text(text = "Count: $count")
        Button(onClick = { count++ }) {
            Text("Add")
        }
    }
}

点击按钮后,count 的值确实变了,但界面不刷新。

原因是:count 只是一个普通 Kotlin 局部变量,Compose 根本不知道它发生了变化。

正确做法:使用 mutableStateOf

@Composable
fun BetterCounter() {
    var count by mutableStateOf(0)

    Column {
        Text(text = "Count: $count")
        Button(onClick = { count++ }) {
            Text("Add")
        }
    }
}

但这段代码其实还是有问题。因为每次重组时,这个函数都会重新执行,count 又会被初始化成 0


三、remember 到底解决了什么

要让状态在重组之间保留下来,必须使用 remember

@Composable
fun GoodCounter() {
    var count by remember { mutableStateOf(0) }

    Column {
        Text(text = "Count: $count")
        Button(onClick = { count++ }) {
            Text("Add")
        }
    }
}

remember 的作用可以理解为:

  • 第一次执行时创建状态
  • 后续重组时直接复用之前那份状态

这样 count 就不会因为函数重跑而被重置。

什么时候只用 remember 就够了

适合这类纯页面内的临时状态:

  • 展开 / 收起
  • Tab 高亮
  • 局部选中态
  • 短生命周期动画状态

只要页面没被销毁,它就能工作得很好。


四、为什么旋转屏幕后状态又没了

即使你用了 remember,设备一旋转,状态还是会丢。

原因是:旋转屏幕会触发配置变更,默认情况下 Activity 会被销毁再重建。remember 只保证同一轮 Composition 中的重组不丢状态,无法跨 Activity 重建保留数据。

解决方案:rememberSaveable

@Composable
fun ResilientCounter() {
    var count by rememberSaveable { mutableStateOf(0) }

    Column {
        Text(text = "Count: $count")
        Button(onClick = { count++ }) {
            Text("Add")
        }
    }
}

rememberSaveable 会借助 Android 的状态保存机制,把可保存的数据放进 Bundle,页面重建后再恢复出来。

它适合什么场景

  • 输入框内容
  • 页面内选项切换
  • 当前分页索引
  • 简单表单状态

它不适合什么

如果状态本身是业务核心数据,比如:

  • 列表接口返回结果
  • 当前登录用户
  • 详情页业务对象

那就不应该继续堆在 Composable 内部了。


五、真正可维护的方案:状态上提 + ViewModel

一旦状态不再只是“局部 UI 小开关”,而是业务状态,就应该把它上提到 ViewModel

class CounterViewModel : ViewModel() {
    private val _count = MutableStateFlow(0)
    val count = _count.asStateFlow()

    fun increment() {
        _count.value++
    }
}

@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
    val count by viewModel.count.collectAsStateWithLifecycle()

    CounterContent(
        count = count,
        onIncrement = viewModel::increment
    )
}

@Composable
fun CounterContent(
    count: Int,
    onIncrement: () -> Unit
) {
    Column {
        Text(text = "Count: $count")
        Button(onClick = onIncrement) {
            Text("Add")
        }
    }
}

这种写法的好处非常明显:

  • 状态不怕重组
  • 状态也不怕旋转屏幕
  • UI 组件变成无状态,复用和测试都更轻松

这就是 Compose 里非常重要的模式:State Hoisting(状态上提)


六、如何判断状态该放哪

你可以按下面这个思路快速判断:

放在 remember

适合只在当前页面短暂存在、页面销毁就无所谓的数据。

放在 rememberSaveable

适合希望在旋转、重建后仍保留的轻量 UI 状态。

放在 ViewModel

适合需要跨配置变化、跨页面流程、甚至和业务逻辑强耦合的状态。


七、排查 Compose 状态问题的实用顺序

当你发现 Compose 页面“不对劲”时,建议按这个顺序查:

  1. 这个值是不是普通变量,而不是 mutableStateOf
  2. 这个状态有没有被 remember 包起来。
  3. 页面是不是经历了 Activity 重建,需要 rememberSaveable
  4. 这个状态是不是其实已经超出了 UI 层,应该上提到 ViewModel

八、结论

Compose 本身并不神秘,问题通常出在我们还沿用了传统 View 的思维方式。

只要记住下面这几条,很多“界面不刷新”和“状态莫名消失”的问题都会自然消失:

  • UI 只是状态的映射,不要直接试图“改控件”
  • 普通变量不会触发重组,要用 mutableStateOf
  • remember 解决重组问题,不解决重建问题
  • rememberSaveable 解决轻量状态恢复
  • 业务状态尽早上提到 ViewModel

当状态边界清晰以后,Compose 的开发体验会顺很多,代码结构也会比传统 UI 更稳定、更好维护。

相关工具