卷 II · 记住CH 07深度 7/24

状态提升:事件向上,状态向下

「把状态提上去」是每份教程的第二课。它们没说的是下半句:提得太高一样出问题——而且出的是更难查的问题。这一章给一个能用的停止条件。

状态提升单向数据流受控组件输入框丢字

先把这个模式说准确

状态提升(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 就对不上了。

三个层次的解法,按推荐程度排:

  1. 别提那么高。输入框的草稿放在 UI 层的 rememberSaveable 里,只在「提交」的时候交给 ViewModel。大多数场景这是最简单也最对的答案。
  2. TextFieldStateCompose 的新 BasicTextFieldandroidx.compose.foundation.text.input,Foundation 1.7 起稳定)不再是「value + onValueChange」的受控模式,而是给你一个可以直接持有的 TextFieldState——它把编辑状态和值放在一起,从根上避免了这个回环。新写的输入框优先用它。
  3. 如果必须走 ViewModel:保证 onValueChange 到状态更新之间没有任何异步(不要 viewModelScope.launch、不要 debounce、不要经过 Repository)。同步更新一般不会丢字。
◆ 这一章的核心

状态提升的两条方向要一起记:

  • 状态向下——组件从参数拿到值,自己不存
  • 事件向上——组件不改数据,只报告「发生了什么」

但这两条不是「越彻底越好」。判据是:提到所有相关方的最近共同祖先,就停。再往上,你换来的每一分「可复用」,都要用一分「多绕一圈异步」来付。

「事件向上」应该向上传什么

这里有一个容易做错的细节:回调该传「发生了什么」,而不是「你该改成什么」

// ❌ 组件在替上层做决定
onCountChange: (Int) -> Unit
// 调用处:onCountChange(count + 1)
// 问题:加多少、有没有上限、要不要记埋点,全被组件决定了

// ✅ 组件只报告事实
onIncrement: () -> Unit
// 上层自己决定 count + 1 还是 count + step,要不要封顶

这条规则对 TextField 这类「就是要传新值」的组件不适用(那本来就是它的语义),但对业务组件几乎总是对的。判断方法:把这个回调的名字念出来,如果它像一个命令(setXxxupdateXxx),多半反了;如果它像一个事实(onXxxClickedonXxxDismissed),就对了。

✎ 那种「两边都能持有」的写法

一个很好用的模式是提供两个重载:

// 有状态版本:调用方什么都不用管
@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 的时候你只需要问「这个值是谁写的」,而答案永远只有一个。

反面是「双向绑定」:界面能改数据,数据也能改界面,出问题时你面对的是一个环,没有起点。

这一章的一句话

状态向下,事件向上;提到所有相关方的最近共同祖先就停。每往上提一层,你都在用一圈额外的异步,去换一分可复用——这笔交易不总是划算。

下一章:状态放在哪一层,还决定了另一件事——它能活多久。旋转屏幕、返回上一页、被系统杀掉再回来,这四种事件下不同的存放位置有完全不同的结局。而大多数 bug 出在「它活得比你以为的久」这一边,不是另一边。