☰
从 MVVM 到 MVI:让非法状态无法被表示
2026/10/15 6:37:09 网站建设 项目流程

引子:一个"不可能"的 bug

先说一个真实场景。

页面在请求数据,用户等不及,切走了。两秒后网络回调姗姗来迟,超时了。

此时页面上出现了一个很诡异的画面:转圈的 loading 还在转,但底下已经弹出了"请求超时"的提示。

你去看代码,逻辑好像都对:

valisLoading=MutableLiveData<Boolean>()valerrorMsg=MutableLiveData<String?>()valdata=MutableLiveData<List<Item>?>()

isLoading设了 true,errorMsg设了"超时"。两个赋值都是对的,但它们的组合是错的。

这就是 MVVM 在实践中那个最隐蔽的坑——不是架构错了,是状态太分散了。


一、多个 LiveData 能组合出多少种"鬼状态"

上面那三个变量,理论上能组合出 8 种情况。但真正合法的只有 4 种:

loadingerrordata状态是否合法
truenullnull加载中✅
false“超时”null失败✅
falsenull[…]成功✅
falsenullnull初始 / 空✅
true“超时”null既加载又失败❌
false“超时”[…]既失败又成功❌
truenull[…]既加载又有数据❌
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 变成一个纯粹的函数——给它状态,它出画面,中间没有任何"判断"。

额外两个好处:

  1. StateFlow永远有当前值(.value),新订阅者立刻能拿到最新状态。
  2. 它天然是"状态容器",语义上就表达了"我这里存的是一个状态"。

四、前面加一层 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 加两条纪律:

  1. 状态收敛成一个(单一状态源)
  2. 数据单向流动

落到 Android 上,最典型的实现就是:把"多个 LiveData"换成"一个StateFlow<UiState>",前面再加一层 Intent。

它不是新框架,是状态管理的规范。


六、代价也得说

不谈代价的方案介绍都是推销。MVI 的代价很实在:

  1. 状态要整体复制,页面状态对象大时有内存开销
  2. 简单页面用 MVI 是过度设计——就一个列表的页面搞整套 Intent/Reducer,纯属自找麻烦
  3. 样板代码多,Intent 和 State 都要写一堆类
  4. 连续快速的状态更新要做去重 / 合并,否则频繁触发重组

七、一张表看懂差别

传统 MVVMMVI
状态多个 LiveData 各自飞一个StateFlow<UiState>
非法组合靠约定避免,会破类型上禁止,写不出来
View 职责观察 + 组合 + 判断只渲染
交互入口N 个方法1 个onIntent
可追溯散落各处集中在 Reducer

两件事各管一头:

  • sealed class + StateFlow管“状态不能自相矛盾”
  • Intent管“状态变化必须只有一条路”

合起来,就是 MVI。


结语

回头看引子里那个 bug——“loading 还在转,超时提示已经弹出来了”。

它在 MVVM 里几乎无法根除,因为那个矛盾状态是"合法"的,你只能靠纪律去绕。

而 MVI 做的事情很朴素:不是让代码写得更漂亮,而是让那种错误的状态,从类型上就写不出来。

这大概是我觉得 MVI 最值得学的一个点——

好的设计不是教人别犯错,而是让人犯不了错。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询