状态提升与单数据流原则

上一章我聊了 Mock 回放的底层思路,说白了就是“录一段状态,再放出来”。但有一个问题你肯定遇到过:录下来的状态放在哪?谁来管理?如果每个组件自己搞一套状态,回放的时候数据满天飞,那还不如不录。

嗯,这就是我今天要讲的核心——状态提升。我在做第一个 Compose 项目时,就被状态管理坑过一次。当时写了个联系人列表,每个列表项自己维护了展开/收起状态。

看起来挺正常,对吧?结果一测试,我点开一项,另一项也莫名其妙展开了。排查了半天,发现是状态没提升,导致多个组件共享了同一个过期引用。从那以后,我彻底理解了“状态上升,事件下沉”这句话的分量。

1. 状态提升:把“主人的位置”交给父级

状态提升(State Hoisting)不是 Compose 独有的,早在 React 时代就有了。但 Compose 把它推到了更极致的位置——一个组件不应该自己持有可修改状态,而是由父组件传入。

你想想看,这样做的好处是什么?

  • 单一数据源:状态只在一个地方保存,不会出现多个副本打架。
  • 可重复性:父组件可以随时给子组件传不同的值,实现各种回放/测试场景。
  • 解耦:子组件变成纯展示组件,不关心数据从哪里来、怎么变。

核心公式:子组件只负责“显示+上报事件”,父组件负责“存储+计算逻辑”。

举个例子,一个简单的登录页面。不用状态提升,组件里直接写 var input by remember { mutableStateOf("") }。用状态提升,就把 input 提升到外部,通过参数传入。

2. 单数据流原则:数据向下流,事件向上飘

状态提升的配套原则就是“单数据流”(Unidirectional Data Flow, UDF)。

UDF 只有两条规则:

  1. 数据向下传递:父组件通过参数把状态传给子组件。
  2. 事件向上传递:子组件不能自己改状态,它只能通过回调(lambda)通知父组件“我想改”。

为什么要硬性规定“不能自己改”?我在项目中见过一个“聪明”的写法——子组件直接调用父组件的状态 setter。当时看起来方便,但回放机制一上,整个状态就乱套了。因为回放需要“从外部注入历史数据”,子组件突然改了一下,回放序列就断了。

曾经踩过的坑:子组件内用 derivedStateOf 直接依赖了可变的 MutableState,导致回放时状态漂移。一定要坚持让父组件控制唯一状态源。

3. 如何在 Compose 中实现状态提升?

其实很简单,三步走:

步骤 做什么
① 确定需要提升的状态 检查子组件内部是否有 remember 的 mutableStateOf
② 把状态参数移到父组件 父组件创建状态,通过构造参数传给子组件
③ 定义事件回调 子组件接口增加 lambda 参数,例如 onValueChange: (String) -> Unit

来看一段代码对比。先看“反例”:

@Composable
fun BadTodoInput() {
    var text by remember { mutableStateOf("") }
    TextField(
        value = text,
        onValueChange = { text = it }
    )
    // 这个组件自己管状态,无法被外部回放
}

再改成提升后的“正例”:

@Composable
fun GoodTodoInput(
    text: String,                       // 数据由父组件传入
    onValueChange: (String) -> Unit      // 事件向上汇报
) {
    TextField(
        value = text,
        onValueChange = onValueChange
    )
}

// 父组件里使用
@Composable
fun TodoScreen() {
    var text by remember { mutableStateOf("") }
    GoodTodoInput(
        text = text,
        onValueChange = { text = it }
    )
}

注意看区别:BadTodoInput 把状态锁在自己肚子里,回放时外部根本不知道它是什么状态。而 GoodTodoInput 完全透明,回放系统只要修改父组件的 text,UI 就会自动响应。

小技巧:我习惯在子组件的 KDoc 里写明“@param onValueChange 回调:用户输入时触发,由父组件消费”。这样后续开发者一看就知道这个组件是“被动的”,不会走弯路。

4. 状态提升的最小单元:到底该提多高?

很多人问:“是不是所有状态都要提到最顶层?” 当然不是。我个人习惯按作用域决定:

  • 局部 UI 状态(比如 animateFloatAsState 的进度)—— 可以不提,除非需要回放动画本身。
  • 用户交互状态(输入内容、选择项)—— 必须提升到最近的公共父级。
  • 业务状态(从网络/数据库来的数据)—— 提升到 ViewModel 或回放容器。

这里有个容易犯的错:把状态提到太高的地方,导致父组件刷新一大堆子组件。我建议用 derivedStateOf 或 remember(state) 做剪枝,只让受影响的部分重组。

5. 单数据流与 Mock 回放的关系

你可能会问:“这跟 Mock 回放有什么关系?” 关系太大了。回放的本质就是“把历史状态注入到组件树”的过程。如果组件树不遵守单数据流,状态可以随意在内部修改,那么注入的状态马上会被覆盖,回放就失败。

举个例子,回放系统需要把一组登录状态的快照按时间重放:

// 回放时直接传入历史状态
var snapshotIndex by remember { mutableIntStateOf(0) }
val currentState = history[snapshotIndex]

GoodTodoInput(
    text = currentState.text,
    onValueChange = { snapshotIndex++ }  // 推进回放序列
)

看到没?只要子组件是“纯展示 + 上报事件”,回放系统就能自由控制状态流动,甚至可以实现“快进、后退、暂停”等高级玩法。

这也是为什么我在设计 Mock 回放框架时,第一件事就是检查所有组件是否完全遵守状态提升。不遵守的组件,我直接写一个 Wrapper 把它包起来,强制提升。

6. 一张图看懂状态提升与单数据流

下面这张 SVG 总结了我刚才讲的所有关系。你把它存下来,以后写组件前看一眼,能少踩一半坑。

状态提升与单数据流核心逻辑 父组件 / ViewModel 持有唯一状态:history / currentState 子组件 A 纯展示 + 上报事件 子组件 B 纯展示 + 上报事件 text, onValueChange... selected, onClick... onValueChange(...) onClick(...) Mock 回放系统 注入历史状态 状态注入 原则:数据沿实线箭头向下传播,事件沿虚线箭头向上汇报 子组件绝不能私自修改状态,只能通过回调通知父组件

7. 避坑指南:状态提升的三大陷阱

光讲理论容易飘,我结合真实教训列几个典型坑:

  • 陷阱一:子组件里用 remember 缓存计算值。我曾经在子组件里写了 remember(key) { expensiveCalc(state) },结果回放时 key 失效,缓存也被清了。正确做法:把计算提升到父组件,子组件只接收结果。
  • 陷阱二:回调函数传了 lambda 但每次都重建。Compose 里如果 onValueChange 每次重组都新建一个 lambda,子组件可能会不必要重组。我习惯用 remember 包裹稳定的回调。
  • 陷阱三:过度提升导致“巨无霸”父组件。现象是一个 Activity 级别的 Composable 里有几百行状态代码。解决方案:用 ViewModel 拆分,或者用 stateIn 做细化分发。

我的测试习惯:每个状态提升点我都会写一个单元测试——传入不同初始状态,验证子组件正确显示;然后模拟回调,验证父组件状态正确变更。这样回放系统接入时几乎不用调代码。

8. 小结:回到 Mock 回放的本质

状态提升 + 单数据流,说白了就是让 Compose 组件成为“提线木偶”。父组件握着所有线,回放系统只需要操作父组件那根线,整个 UI 就会听话地重现历史状态。

如果不遵守这个原则,回放会变成一团乱麻。所以我的建议:从第一个 Composable 开始,就养成提升状态的习惯。等后面需要 Mock 回放时,你会发现一切已经水到渠成。