资讯动态

从 MVVM 到 MVI:让非法状态无法被表示

发布时间:2026/10/16 11:35:46 来源:尧图企业网站定制
引子一个不可能的 bug先说一个真实场景。页面在请求数据用户等不及切走了。两秒后网络回调姗姗来迟超时了。此时页面上出现了一个很诡异的画面转圈的 loading 还在转但底下已经弹出了请求超时的提示。你去看代码逻辑好像都对valisLoadingMutableLiveDataBoolean()valerrorMsgMutableLiveDataString?()valdataMutableLiveDataListItem?()isLoading设了 trueerrorMsg设了超时。两个赋值都是对的但它们的组合是错的。这就是 MVVM 在实践中那个最隐蔽的坑——不是架构错了是状态太分散了。一、多个 LiveData 能组合出多少种鬼状态上面那三个变量理论上能组合出 8 种情况。但真正合法的只有 4 种loadingerrordata状态是否合法truenullnull加载中✅false“超时”null失败✅falsenull[…]成功✅falsenullnull初始 / 空✅true“超时”null既加载又失败❌false“超时”[…]既失败又成功❌truenull[…]既加载又有数据❌true“超时”[…]三种同时成立❌问题就在这下面那 4 种鬼状态你的代码是允许出现的。语法上完全合法编译器不会拦你。它们怎么产生的就是引子里说的——回调时序错位isLoading刚设成 falseerror又紧接着被设上中间那一瞬间就是矛盾态。你能做的只有靠纪律约定“记得在设 error 的时候把 loading 清掉”。而纪律是一定会破的。二、让非法状态无法被表示既然多个独立变量是问题的根源那就把状态从多个变量变成一个密封类型sealedclassUiState{objectLoading:UiState()dataclassSuccess(vallist:ListItem):UiState()dataclassError(valmsg:String):UiState()}现在UiState只能是这三种之一。你想写一个既是 Loading 又是 Error的对象——写不出来编译器不允许。这就是那句话的含义让非法状态无法被表示make illegal states unrepresentable关键差别在禁止的层次不同多个 LiveData 是靠人记规则去避免矛盾 —— 一定会失效sealed class 是靠类型系统去禁止矛盾 —— 想犯都犯不了打个比方多个 LiveData 像电箱上三个独立开关谁都能随便拨能拨出通电 断电同时成立sealed class 是一个三档旋钮物理上只能停在一档。顺带一个白拿的好处sealed class 配合when时编译器会强制你处理所有分支。漏了Error分支直接编译报错——而不是等到线上才炸。三、一个StateFlowUiState替换多个 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 决定——走缓存还是走网络、要不要先显示 loadingView 一概不管也不该管。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换成一个StateFlowUiState前面再加一层 Intent。它不是新框架是状态管理的规范。六、代价也得说不谈代价的方案介绍都是推销。MVI 的代价很实在状态要整体复制页面状态对象大时有内存开销简单页面用 MVI 是过度设计——就一个列表的页面搞整套 Intent/Reducer纯属自找麻烦样板代码多Intent 和 State 都要写一堆类连续快速的状态更新要做去重 / 合并否则频繁触发重组七、一张表看懂差别传统 MVVMMVI状态多个 LiveData 各自飞一个StateFlowUiState非法组合靠约定避免会破类型上禁止写不出来View 职责观察 组合 判断只渲染交互入口N 个方法1 个onIntent可追溯散落各处集中在 Reducer两件事各管一头sealed class StateFlow管“状态不能自相矛盾”Intent管“状态变化必须只有一条路”合起来就是 MVI。结语回头看引子里那个 bug——“loading 还在转超时提示已经弹出来了”。它在 MVVM 里几乎无法根除因为那个矛盾状态是合法的你只能靠纪律去绕。而 MVI 做的事情很朴素不是让代码写得更漂亮而是让那种错误的状态从类型上就写不出来。这大概是我觉得 MVI 最值得学的一个点——好的设计不是教人别犯错而是让人犯不了错。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑