引子:一个"不可能"的 bug
先说一个真实场景。
页面在请求数据,用户等不及,切走了。两秒后网络回调姗姗来迟,超时了。
此时页面上出现了一个很诡异的画面:转圈的 loading 还在转,但底下已经弹出了"请求超时"的提示。
你去看代码,逻辑好像都对:
valisLoading=MutableLiveData<Boolean>()valerrorMsg=MutableLiveData<String?>()valdata=MutableLiveData<List<Item>?>()isLoading设了 true,errorMsg设了"超时"。两个赋值都是对的,但它们的组合是错的。
这就是 MVVM 在实践中那个最隐蔽的坑——不是架构错了,是状态太分散了。
一、多个 LiveData 能组合出多少种"鬼状态"
上面那三个变量,理论上能组合出 8 种情况。但真正合法的只有 4 种:
| loading | error | data | 状态 | 是否合法 |
|---|---|---|---|---|
| true | null | null | 加载中 | ✅ |
| false | “超时” | null | 失败 | ✅ |
| false | null | […] | 成功 | ✅ |
| false | null | null | 初始 / 空 | ✅ |
| true | “超时” | null | 既加载又失败 | ❌ |
| false | “超时” | […] | 既失败又成功 | ❌ |
| true | null | […] | 既加载又有数据 | ❌ |
| true | “超时” | […] | 三种同时成立 | ❌ |
问题就在这:下面那 4 种"鬼状态",你的代码是允许出现的。
语法上完全合法,编译器不会拦你。它们怎么产生的?就是引子里说的——回调时序错位,isLoading刚设成 false,error又紧接着被设上,中间那一瞬间就是矛盾态。
你能做的只有靠纪律约定:“记得在设 error 的时候把 loading 清掉”。
而纪律,是一定会破的。
二、让非法状态无法被表示
既然"多个独立变量"是问题的根源,那就把状态从"多个变量"变成"一个密封类型":
sealedclassUiState{objectLoading:UiState()dataclassSuccess(vallist:List<Item>):UiState()dataclassError(valmsg:String):UiState()}现在UiState只能"是"这三种之一。
你想写一个"既是 Loading 又是 Error"的对象——写不出来,编译器不允许。这就是那句话的含义:
让非法状态无法被表示(make illegal states unrepresentable)
关键差别在"禁止的层次"不同:
- 多个 LiveData 是靠人记规则去避免矛盾 —— 一定会失效
- sealed class 是靠类型系统去禁止矛盾 —— 想犯都犯不了
打个比方:
多个 LiveData 像电箱上三个独立开关,谁都能随便拨,能拨出"通电 + 断电"同时成立;sealed class 是一个三档旋钮,物理上只能停在一档。
顺带一个白拿的好处:sealed class 配合when时,编译器会强制你处理所有分支。漏了Error分支,直接编译报错——而不是等到线上才炸。
三、一个StateFlow<UiState>替换多个 LiveData
第一层:观察方式变了。
// 原来:View 观察 3 个源,自己组合isLoading.observe{...}errorMsg.observe{...}data.observe{...}// 现在:只观察 1 个源viewModel.state.collect{state->when(state){isLoading->showLoading()isSuccess->showList(state.list)isError->showError(state.msg)}}核心差别是"状态由谁组合"。
- 原来,组合逻辑在 View 里——它得自己判断"loading 为真时还要不要显示旧数据"“error 和 data 同时有该显示谁”。判断散在三个回调里,还容易撞车。
- 现在,组合逻辑在 ViewModel 里。View 收到的是一个完整的、自洽的画面,直接画就完了。
这正是那句话的意思:UI = f(state)。View 变成一个纯粹的函数——给它状态,它出画面,中间没有任何"判断"。
额外两个好处:
- StateFlow永远有当前值(
.value),新订阅者立刻能拿到最新状态。 - 它天然是"状态容器",语义上就表达了"我这里存的是一个状态"。
四、前面加一层 Intent
先说原来什么样。
// View 直接调 ViewModel 的各种方法refreshBtn.setOnClickListener{viewModel.refresh()}searchInput.addTextChangedListener{viewModel.search(it)}loadMore.setOnClickListener{viewModel.loadMore()}ViewModel 对外暴露 N 个方法,谁想调哪个就调哪个。
时间一长,ViewModel 变成一个大杂烩。而你很难回答一个问题:“这个状态,到底是被谁改的?”
加一层 Intent 之后:
sealedclassUiIntent{objectRefresh:UiIntent()dataclassSearch(valkeyword:String):UiIntent()}// View 只做一件事:发出意图refreshBtn.setOnClickListener{viewModel.onIntent(UiIntent.Refresh)}// ViewModel 只有一个入口funonIntent(intent:UiIntent){when(intent){isRefresh->{/* 算出新 State */}isSearch->{/* 算出新 State */}}}带来三个变化:
1. 入口从 N 个收敛成 1 个。
所有状态变化的来源都从onIntent这一个口进。排查时你只需要看这一个方法,就知道"什么操作会导致什么状态"。这就是"可追溯"落地后的样子。
2. View 不再指挥,只报告。
它说"用户点了刷新",但"刷新该怎么做"完全归 ViewModel 决定——走缓存还是走网络、要不要先显示 loading,View 一概不管,也不该管。
3. 好测试。
直接构造一个UiIntent.Refresh喂给 ViewModel,断言输出的 State 对不对——完全不需要启动 UI。
顺带提醒:MVI 的 Intent 不是 Android 那个
Intent(跳 Activity 用的)。名字撞车,是这里最容易把人绕晕的地方,聊的时候最好主动点一句。
五、串起来:它其实是一台状态机
Intent(用户意图) → Reducer(算出新状态) → State(唯一真相) ↑ ↓ └────────────── View ←────────────────┘ 只渲染,不改状态整个模型可以用一行公式概括:
新 State = f(旧 State, Intent)
ViewModel 里那个"根据 Intent 和当前 State 算出新 State"的东西,就是Reducer。
因为整个页面只有这一个 State 对象、所有 UI 都由它派生,所以叫单一状态源(SSOT, Single Source of Truth)。
注意一件事:MVI 不是来替代 MVVM 的。
它是给 MVVM 加两条纪律:
- 状态收敛成一个(单一状态源)
- 数据单向流动
落到 Android 上,最典型的实现就是:把"多个 LiveData"换成"一个StateFlow<UiState>",前面再加一层 Intent。
它不是新框架,是状态管理的规范。
六、代价也得说
不谈代价的方案介绍都是推销。MVI 的代价很实在:
- 状态要整体复制,页面状态对象大时有内存开销
- 简单页面用 MVI 是过度设计——就一个列表的页面搞整套 Intent/Reducer,纯属自找麻烦
- 样板代码多,Intent 和 State 都要写一堆类
- 连续快速的状态更新要做去重 / 合并,否则频繁触发重组
七、一张表看懂差别
| 传统 MVVM | MVI | |
|---|---|---|
| 状态 | 多个 LiveData 各自飞 | 一个StateFlow<UiState> |
| 非法组合 | 靠约定避免,会破 | 类型上禁止,写不出来 |
| View 职责 | 观察 + 组合 + 判断 | 只渲染 |
| 交互入口 | N 个方法 | 1 个onIntent |
| 可追溯 | 散落各处 | 集中在 Reducer |
两件事各管一头:
sealed class + StateFlow管“状态不能自相矛盾”Intent管“状态变化必须只有一条路”
合起来,就是 MVI。
结语
回头看引子里那个 bug——“loading 还在转,超时提示已经弹出来了”。
它在 MVVM 里几乎无法根除,因为那个矛盾状态是"合法"的,你只能靠纪律去绕。
而 MVI 做的事情很朴素:不是让代码写得更漂亮,而是让那种错误的状态,从类型上就写不出来。
这大概是我觉得 MVI 最值得学的一个点——
好的设计不是教人别犯错,而是让人犯不了错。