状态提升与单数据流原则
上一章我聊了 Mock 回放的底层思路,说白了就是“录一段状态,再放出来”。但有一个问题你肯定遇到过:录下来的状态放在哪?谁来管理?如果每个组件自己搞一套状态,回放的时候数据满天飞,那还不如不录。
嗯,这就是我今天要讲的核心——状态提升。我在做第一个 Compose 项目时,就被状态管理坑过一次。当时写了个联系人列表,每个列表项自己维护了展开/收起状态。
看起来挺正常,对吧?结果一测试,我点开一项,另一项也莫名其妙展开了。排查了半天,发现是状态没提升,导致多个组件共享了同一个过期引用。从那以后,我彻底理解了“状态上升,事件下沉”这句话的分量。
1. 状态提升:把“主人的位置”交给父级
状态提升(State Hoisting)不是 Compose 独有的,早在 React 时代就有了。但 Compose 把它推到了更极致的位置——一个组件不应该自己持有可修改状态,而是由父组件传入。
你想想看,这样做的好处是什么?
- 单一数据源:状态只在一个地方保存,不会出现多个副本打架。
- 可重复性:父组件可以随时给子组件传不同的值,实现各种回放/测试场景。
- 解耦:子组件变成纯展示组件,不关心数据从哪里来、怎么变。
核心公式:子组件只负责“显示+上报事件”,父组件负责“存储+计算逻辑”。
举个例子,一个简单的登录页面。不用状态提升,组件里直接写 var input by remember { mutableStateOf("") }。用状态提升,就把 input 提升到外部,通过参数传入。
2. 单数据流原则:数据向下流,事件向上飘
状态提升的配套原则就是“单数据流”(Unidirectional Data Flow, UDF)。
UDF 只有两条规则:
- 数据向下传递:父组件通过参数把状态传给子组件。
- 事件向上传递:子组件不能自己改状态,它只能通过回调(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 总结了我刚才讲的所有关系。你把它存下来,以后写组件前看一眼,能少踩一半坑。
7. 避坑指南:状态提升的三大陷阱
光讲理论容易飘,我结合真实教训列几个典型坑:
- 陷阱一:子组件里用 remember 缓存计算值。我曾经在子组件里写了
remember(key) { expensiveCalc(state) },结果回放时 key 失效,缓存也被清了。正确做法:把计算提升到父组件,子组件只接收结果。 - 陷阱二:回调函数传了 lambda 但每次都重建。Compose 里如果
onValueChange每次重组都新建一个 lambda,子组件可能会不必要重组。我习惯用remember包裹稳定的回调。 - 陷阱三:过度提升导致“巨无霸”父组件。现象是一个 Activity 级别的 Composable 里有几百行状态代码。解决方案:用 ViewModel 拆分,或者用
stateIn做细化分发。
我的测试习惯:每个状态提升点我都会写一个单元测试——传入不同初始状态,验证子组件正确显示;然后模拟回调,验证父组件状态正确变更。这样回放系统接入时几乎不用调代码。
8. 小结:回到 Mock 回放的本质
状态提升 + 单数据流,说白了就是让 Compose 组件成为“提线木偶”。父组件握着所有线,回放系统只需要操作父组件那根线,整个 UI 就会听话地重现历史状态。
如果不遵守这个原则,回放会变成一团乱麻。所以我的建议:从第一个 Composable 开始,就养成提升状态的习惯。等后面需要 Mock 回放时,你会发现一切已经水到渠成。