状态提升:事件向上,状态向下
「把状态提上去」是每份教程的第二课。它们没说的是下半句:提得太高一样出问题——而且出的是更难查的问题。这一章给一个能用的停止条件。
先把这个模式说准确
状态提升(state hoisting)的标准形状是:
// 提升前:状态在自己肚子里,别人管不着,也测不了
@Composable
fun SearchBar() {
var text by remember { mutableStateOf("") }
TextField(value = text, onValueChange = { text = it })
}
// 提升后:无状态(stateless),两个参数一进一出
@Composable
fun SearchBar(
text: String, // ← 状态向下
onTextChange: (String) -> Unit // ← 事件向上
) {
TextField(value = text, onValueChange = onTextChange)
}
换来三样东西:
- 可复用——同一个组件可以被不同的调用方喂不同的数据
- 可预览、可测试——
SearchBar("hello") { }一行就能跑 - 单一数据源——这个值只有一个地方能改,不会出现两份不同步
这三样都是真的好处。问题在于「提到哪儿为止」没人说。
提到哪儿为止
判据一句话:提到「所有需要读它、改它的人」的最近共同祖先,一层都不要再高。
再具体一点,三个层级各自的适用范围:
| 放在哪 | 什么样的状态 | 提高一层的代价 |
|---|---|---|
组件自己肚子里remember { } | 只有它自己关心:展开/收起、hover、内部动画进度、Ripple | — |
| 提到父 composable | 兄弟组件之间要协调:选中项、当前 tab、哪个卡片展开 | 父级的 scope 现在会因为它而脏(第 6 章那笔账) |
| 提到 ViewModel | 要跨配置变更存活、要和数据层交互、要被业务逻辑改 | 每次变化都要走一遍「事件上去 → 状态下来」的循环;而这个循环是异步的 |
最后那一格的代价是很多人栽跟头的地方,值得单独讲。
输入框丢字:一个真实的、经典的坑
把 TextField 的内容提到 ViewModel,写法看起来无懈可击:
// ViewModel
private val _uiState = MutableStateFlow(UiState())
val uiState = _uiState.asStateFlow()
fun onTextChange(t: String) { _uiState.update { it.copy(text = t) } }
// UI
val state by vm.uiState.collectAsStateWithLifecycle()
TextField(value = state.text, onValueChange = vm::onTextChange)
用户打字快的时候,会出现丢字或者光标乱跳。原因是这条链子:
用户按下 'a' → onValueChange("a")
→ ViewModel.update
→ StateFlow 发出新值
→ collectAsState 收到
→ 重组
→ TextField 的 value 变成 "a"
这一整圈是异步的。如果这期间用户又按了 'b',
TextField 内部的编辑状态和 value 就对不上了。
三个层次的解法,按推荐程度排:
- 别提那么高。输入框的草稿放在 UI 层的
rememberSaveable里,只在「提交」的时候交给 ViewModel。大多数场景这是最简单也最对的答案。 - 用
TextFieldState。Compose 的新BasicTextField(androidx.compose.foundation.text.input,Foundation 1.7 起稳定)不再是「value + onValueChange」的受控模式,而是给你一个可以直接持有的TextFieldState——它把编辑状态和值放在一起,从根上避免了这个回环。新写的输入框优先用它。 - 如果必须走 ViewModel:保证
onValueChange到状态更新之间没有任何异步(不要viewModelScope.launch、不要debounce、不要经过 Repository)。同步更新一般不会丢字。
状态提升的两条方向要一起记:
- 状态向下——组件从参数拿到值,自己不存
- 事件向上——组件不改数据,只报告「发生了什么」
但这两条不是「越彻底越好」。判据是:提到所有相关方的最近共同祖先,就停。再往上,你换来的每一分「可复用」,都要用一分「多绕一圈异步」来付。
「事件向上」应该向上传什么
这里有一个容易做错的细节:回调该传「发生了什么」,而不是「你该改成什么」。
// ❌ 组件在替上层做决定 onCountChange: (Int) -> Unit // 调用处:onCountChange(count + 1) // 问题:加多少、有没有上限、要不要记埋点,全被组件决定了 // ✅ 组件只报告事实 onIncrement: () -> Unit // 上层自己决定 count + 1 还是 count + step,要不要封顶
这条规则对 TextField 这类「就是要传新值」的组件不适用(那本来就是它的语义),但对业务组件几乎总是对的。判断方法:把这个回调的名字念出来,如果它像一个命令(setXxx、updateXxx),多半反了;如果它像一个事实(onXxxClicked、onXxxDismissed),就对了。
一个很好用的模式是提供两个重载:
// 有状态版本:调用方什么都不用管
@Composable
fun SearchBar(modifier: Modifier = Modifier) {
var text by rememberSaveable { mutableStateOf("") }
SearchBar(text, { text = it }, modifier)
}
// 无状态版本:调用方要接管的时候用这个
@Composable
fun SearchBar(
text: String,
onTextChange: (String) -> Unit,
modifier: Modifier = Modifier
) { … }
Compose 自己的组件库大量用这个手法(rememberScrollState()、rememberLazyListState() 都是「不传就给你造一个」)。默认给最省事的那个,需要控制的人自己往上提。
单向数据流:这个词到底在说什么
把上面的模式往上推一层,就是 UDF(unidirectional data flow):
┌──────────────┐
状态 ↓ │ UI 层 │ ↑ 事件
└──────────────┘
│
┌──────────────┐
│ ViewModel │
└──────────────┘
│
┌──────────────┐
│ 数据层 │
└──────────────┘
它的价值不在于「架构好看」,在于一个很实际的性质:任何一个界面状态,都能被追溯到唯一一个来源。出 bug 的时候你只需要问「这个值是谁写的」,而答案永远只有一个。
反面是「双向绑定」:界面能改数据,数据也能改界面,出问题时你面对的是一个环,没有起点。
分层怎么划、ViewModel 和 Presenter 怎么选、状态该由谁持有、Repository 那一层怎么组织——《承重》第 1 章和第 4 章是专门讲这个的,而且是按 2026 年的实际生态写的。
这本书只管一件事:你把状态放在哪一层,会让什么东西在什么时候重跑。
这一章的一句话
状态向下,事件向上;提到所有相关方的最近共同祖先就停。每往上提一层,你都在用一圈额外的异步,去换一分可复用——这笔交易不总是划算。
下一章:状态放在哪一层,还决定了另一件事——它能活多久。旋转屏幕、返回上一页、被系统杀掉再回来,这四种事件下不同的存放位置有完全不同的结局。而大多数 bug 出在「它活得比你以为的久」这一边,不是另一边。