Compose架构的正确实现:MVI单向状态流 vs MVVM
为什么MVVM在Compose中显得笨拙
MVVM伴随着XML和双向数据绑定成长起来。Compose彻底改变了这一模式:UI是状态的函数。这种不匹配体现在常见的痛点中:
状态分散。多个LiveData/Flow(加载中、项目列表、错误、搜索查询、分页等)独立变化。Compose会在奇怪的时间重组,你开始到处添加remember { mutableStateOf(...) }来"修补"故障。
“SingleLiveEvent"困境。一次性操作(吐司提示、导航、Snackbar)不属于稳定的UI状态,因此团队会引入特殊的event包装器,这些包装器在配置变更或进程死亡时会失效。
隐式写入。使用双向绑定(或主动观察者)时,不清楚是谁改变了什么。你需要通过搜索setter来追踪错误。
脆弱的测试。当状态可以从多个路径和观察者竞争中被修改时,很难重现错误。
MVI带来的优势
Model-View-Intent(MVI)只是单向数据流的严格规范:
- Intent(事件):用户或系统输入(刷新、重试、点击项目)
- Reducer:将(旧状态,事件)转换为新状态的纯函数(并可能发出Effects)
- State:UI应该渲染的单一不可变快照
- Effect:不应在重组中存活的一次性操作(导航、吐司提示、打开表单)
就是这样。你将所有内容都通过可预测的循环处理。给定相同的起始状态和相同的事件序列,你会得到相同的结果——这对调试和测试非常有用。
为什么MVI如此适合Compose
单一数据源。Compose需要一个UiState来驱动你的屏幕。MVI形式化了这个契约。加载和项目之间不会意外漂移。
可预测的重组。不可变状态+值相等意味着Compose可以廉价地决定何时重组。减少"为什么这个重新渲染了?“的时刻。
显式Effects。不再需要SingleLiveEvent。你暴露一个单独的Flow<UiEffect>并在LaunchedEffect中处理它。
可测试性。Reducers是纯粹和确定性的——你可以在没有Android运行时的情况下对它们进行单元测试。可以使用Turbine收集器断言Effects。
可追溯性。记录(事件,旧状态->新状态)将你的错误报告变成可重放的时间线。
实用的迁移方案(MVVM -> MVI风格)
你不需要采用新的框架。保留你的ViewModel,但使用State + Events + Effects和类似reducer的更新来建模你的屏幕。这是一个可以放入现有代码库的紧凑模式。
|
|
|
|
注意发生了什么变化:视图层从一个快照渲染,只通过onEvent进行通信。ViewModel拥有真相并分别发出一次性effects。你有效地从MVVM的"多个可变流"转变为MVI的"单一状态+意图”,而没有抛弃ViewModel、Hilt或你的存储库层。
性能和人体工程学技巧
- 保持状态小而值类型。优先使用数据类和不可变集合。避免在UiState中放置lambda、协程或控制器。
- 按功能拆分。如果一个屏幕很大,使用多个小的UiState或子reducers。Compose在调用站点粒度上重新组合,因此在小函数中构建UI。
- 明智地使用键。在LazyColumn中,提供稳定的键以避免更新期间的变动。
- 推导你能推导的。对计算值使用derivedStateOf(例如,
isEmpty = items.isEmpty()),这样你就不会存储可能过时的冗余标志。 - 去抖动上游。如果你的事件是嘈杂的(搜索输入),在reducer之前在ViewModel中去抖动。
MVI方式的测试
- Reducer测试:给定初始UiState和事件,断言新状态。不需要Android框架。
- Effect测试:使用测试SharedFlow收集器收集effects,并断言一次性操作(NavigateToDetail)恰好发出一次。
- 集成测试:使用带有fake Repo的runTest来验证加载->成功/失败路径。
何时可能坚持使用MVVM
如果你的屏幕很小,或者你的团队已经使用"UDF风格的MVVM”(单一UiState + onEvent() + Effect),全面转向MVI可能不会带来太多好处。收益随复杂性而扩展——分页、离线/在线、重试、权限和多源合并是MVI发挥作用的领域。
库(如果你需要的话)
你可以自己实现(如上所述)或采用轻量级的MVI辅助库。流行的选项提供reducers、意图分发器和时间旅行调试。关键是保持契约(State/Event/Effect)稳定,以便以后可以交换内部实现。
总结
当你的UI是单一状态的纯函数且所有更新都通过相同的高速公路时,Compose处于最佳状态。MVI为你提供了那条高速公路:更少的意外、更清晰的测试和团队间的共享语言。你不需要大规模重写——首先用一个UiState建模一个屏幕,通过onEvent路由用户操作,并将一次性操作分离为UiEffect。在一两个屏幕之后,你会想知道以前是如何管理旧方式的。