Kotlin MVI 与 MVVM:面向表单密集型 Android 应用的 UI 状态管理
用实用方法解释 Kotlin MVI 与 MVVM 在表单密集型 Android 应用中的区别:如何建模校验、乐观 UI、错误状态和离线草稿。

为什么表单密集的 Android 应用容易变得混乱
表单密集的应用会让人觉得慢或脆弱,因为用户不停地在等待你代码做出微小决策:这个字段有效吗?保存成功了吗?是否应该显示错误?网络中断怎么办?
表单也最先暴露出状态错误,因为它们同时混合了多类状态:UI 状态(可见内容)、输入状态(用户输入了什么)、服务器状态(已保存的是什么)以及临时状态(正在进行的操作)。当这些状态不同步时,应用会开始显得“随机”:按钮在错误时刻被禁用,旧的错误残留,或屏幕在旋转后重置。
大多数问题集中在四个方面:校验(尤其是跨字段规则)、乐观 UI(在操作仍在进行时提供快速反馈)、错误处理(清晰且可恢复的失败)和离线草稿(不要丢失未完成的工作)。
良好的表单交互遵循几个简单规则:
- 校验应当有帮助性且尽量接近字段。不要阻止输入。重要时(通常在提交时)再严格校验。
- 乐观 UI 应立即反映用户的操作,但也需要在服务器拒绝时能干净回滚。
- 错误应具体且可操作,且绝不应抹去用户输入。
- 草稿应能在重启、中断和糟糕连接下存活。
这就是为什么围绕表单的架构辩论会变得激烈。你选的模式决定了这些状态在压力下的可预测性。
快速回顾:通俗理解 MVVM 与 MVI
MVVM 与 MVI 的真正区别在于变化如何在屏幕中流动。
MVVM(Model View ViewModel)通常这样:ViewModel 保存屏幕数据,通过 StateFlow 或 LiveData 等暴露给 UI,并提供如 save、validate、load 等方法。UI 在用户交互时调用 ViewModel 的方法。
MVI(Model View Intent)通常这样:UI 发送事件(intent),reducer 处理它们,并从一个表示当前屏幕所需全部内容的状态对象渲染界面。副作用(网络、数据库)以受控方式触发,并以结果事件回报。
一个简单的记忆方式:
- MVVM 会问,“ViewModel 应该暴露哪些数据,应该提供哪些方法?”
- MVI 会问,“可能发生哪些事件,它们如何把一个状态变为另一个状态?”
对简单屏幕,两种模式都能很好地工作。一旦加入跨字段校验、自动保存、重试和离线草稿,你就需要更严格的规则来决定谁能在何时改变状态。MVI 默认能强制这些规则。MVVM 仍然可以很好用,但需要自律:保持一致的更新路径并谨慎处理一次性 UI 事件(如吐司、导航)。
如何让表单状态不可预测地失控
失去控制的最快方式是让表单数据存在太多地方:视图绑定、多个流以及“再加一个”布尔值。表单密集的页面在只有一个真相来源时更可预测。
一个实用的 FormState 形状
目标是一个单一的 FormState,它既包含原始输入也包含可信赖的派生标志。保持它朴素且完整,即便看起来略大。
data class FormState(
val fields: Fields,
val fieldErrors: Map\u003cFieldId, String\u003e = emptyMap(),
val formError: String? = null,
val isDirty: Boolean = false,
val isValid: Boolean = false,
val submitStatus: SubmitStatus = SubmitStatus.Idle,
val draftStatus: DraftStatus = DraftStatus.NotSaved
)
sealed class SubmitStatus { object Idle; object Saving; object Saved; data class Failed(val msg: String) }
sealed class DraftStatus { object NotSaved; object Saving; object Saved }
这将字段级校验(每个输入)与表单级问题(例如“总数必须 > 0”)分离开来。派生标志如 isDirty 和 isValid 应在一个地方计算,而不是在 UI 中重复实现。
一个清晰的心智模型是:fields(用户输入)、validation(有什么问题)、status(应用正在做什么)、dirtiness(自上次保存以来有什么变化),以及 drafts(是否存在离线副本)。
一次性效果应放在哪里
表单也会触发一次性事件:snackbar、导航、“已保存”横幅。不要把这些放到 FormState,否则它们会在旋转或 UI 重新订阅时再次触发。
在 MVVM 中,通过单独的通道(例如 SharedFlow)发出效果。在 MVI 中,把它们建模为 UI 消费一次的 Effects(或 Events)。这种分离防止“幽灵”错误和重复成功消息。
MVVM 与 MVI 中的校验流程对比
校验是表单屏幕开始变脆弱的地方。关键选择是规则放在哪里以及结果如何回到 UI。
简单的同步规则(必填、最小长度、数值范围)应该在 ViewModel 或领域层运行,而不是 UI 中。这样规则可测试且一致。
异步规则(例如“该邮箱是否已被占用?”)更棘手。需要处理加载、陈旧结果以及“用户再次输入”的情况。
在 MVVM 中,校验通常会变成状态与辅助方法的混合体:UI 将变化(文本更新、焦点变化、提交点击)发送到 ViewModel;ViewModel 更新 StateFlow/LiveData 并暴露每字段的错误和派生的“可提交”标志。异步检查通常会启动一个 job,然后在完成时更新 loading 标志与错误。
在 MVI 中,校验往往更明确。一个实用的职责划分:
- reducer 运行同步校验并立即更新字段错误。
- effect 运行异步校验并派发结果 intent。
- reducer 仅在结果仍与最新输入匹配时才应用它。
最后一步很重要。如果用户在“唯一邮箱”检查运行时又输入了新的邮箱,旧结果不应覆盖当前输入。MVI 常常更容易把这类逻辑编码出来,因为你可以在 state 中存储最后一次被校验的值,并忽略陈旧响应。
乐观 UI 与异步保存
乐观 UI 是指屏幕在网络回复到达之前就表现得像保存已成功。在表单中,这通常表现为 Save 按钮变为“Saving...”,完成后显示一个小“已保存”指示,并且输入保持可用(或有意锁定),直到请求完成。
在 MVVM 中,这通常通过切换诸如 isSaving、lastSavedAt 和 saveError 的标志实现。风险在于状态漂移:并发保存可能导致这些标志不一致。在 MVI 中,reducer 更新一个状态对象,因此“Saving”和“Disabled”更不容易互相矛盾。
为避免双重提交和竞态条件,把每次保存视为有标识的事件很重要。如果用户连续点击 Save 或在保存期间编辑,你需要决定哪个响应生效。几项通用防护适用于任一模式:保存时禁用 Save(或对点击做防抖)、为每次保存附加 requestId(或版本号)并忽略陈旧响应、在离开时取消正在进行的工作,并定义保存期间的编辑含义(排队另一次保存或再次标记为脏)。
部分成功也很常见:服务器接受了部分字段但拒绝了其他字段。要明确地把这类情况建模出来。保留字段级错误(以及如有必要的字段级同步状态),以便在总体显示“已保存”时仍然高亮需要关注的字段。
用户可恢复的错误状态
表单失败的方式比“出错了”要多得多。如果每次失败都变成一个通用吐司,用户会重输数据、失去信任并放弃流程。目标始终是:保护输入安全、展示清晰的修复方法,并让重试变得自然。
把错误按归属位置分离会很有帮助。错误的邮箱格式与服务器宕机不是同一类问题。
字段错误应以内联方式并绑定到对应输入。表单级错误应放在提交动作附近并解释阻塞点。网络错误应提供重试并保持表单可编辑。权限或认证错误应引导用户重新认证,同时保留草稿。
一个核心恢复规则:失败时绝不清除用户输入。如果保存失败,保留当前值在内存和磁盘上。重试应重发相同的有效载荷,除非用户编辑了内容。
模式差异在于服务器错误如何映射回 UI 状态。在 MVVM 中,更新多个流或字段很容易意外造成不一致。在 MVI 中,你通常在一个 reducer 步骤中应用服务器响应,同时更新 fieldErrors 和 formError。
还要决定哪些是状态,哪些是一次性效果。内联错误和“提交失败”应属于状态(它们必须在旋转时存活)。像 snackbar、震动或导航这类一次性动作应是效果。
离线草稿与恢复进行中的表单
即便网络良好,表单密集的应用也会感觉“离线”。用户切换应用、操作系统杀掉进程,或在中途丢失信号。草稿能防止用户从头开始。
首先,定义草稿意味着什么。仅保存“干净”的模型通常不够。你通常希望恢复屏幕时它看起来和离开时完全一样,包括半输入的字段。
值得持久化的主要是原始用户输入(按键输入的字符串、所选 ID、附件 URI),以及用于以后安全合并的必要元数据:最后已知的服务器快照和一个版本标记(updatedAt、ETag 或简单的递增数)。恢复时可重新计算校验。
存储选择取决于敏感度和大小。小型草稿可以放在偏好设置中,但多步表单和附件在本地数据库中更安全。如果草稿包含个人数据,请使用加密存储。
最大的架构问题是单一真相在哪里。在 MVVM 中,团队常常在字段变化时从 ViewModel 持久化。在 MVI 中,在每次 reducer 更新后持久化可能更简单,因为你保存的是一个连贯的状态(或派生的 Draft 对象)。
自动保存的时机很重要。对每次按键保存会很嘈杂;采用短时防抖(例如 300 到 800 毫秒)并在步骤切换时保存效果不错。
当用户重新上线时,你需要合并规则。实用的方法是:如果服务器版本没有改变,就应用草稿并提交;如果改变了,展示清晰的选择:保留我的草稿或重新加载服务器数据。
逐步实现:用任一模式打造可靠表单
可靠的表单始于清晰的规则,而不是 UI 代码。每个用户操作应导致一个可预测的状态,每个异步结果应有一个明显的落点。
写下你的页面需要响应的操作:输入、失焦、提交、重试和步骤导航。在 MVVM 中这些成为 ViewModel 方法和状态更新;在 MVI 中它们成为显式的 intents。
然后分小步构建:
- 定义完整生命周期的事件:edit、blur、submit、save success/failure、retry、restore draft。
- 设计一个状态对象:字段值、字段级错误、整体表单状态以及“是否有未保存更改”。
- 添加校验:编辑时做轻量检查,提交时做更严格检查。
- 添加乐观保存规则:哪些变化立即生效,什么情况下触发回滚。
- 添加草稿:带防抖的自动保存、打开时恢复,并显示一个小的“草稿已恢复”提示以建立用户信任。
把错误当作体验的一部分。保留输入,仅高亮需要修复的部分,并给出一个明确的下一步(编辑、重试或保留草稿)。
如果你想在写 Android UI 之前对复杂表单状态做原型,一个像 AppMaster 这样的无代码平台能在早期验证工作流。然后你可以用更少的意外在 MVVM 或 MVI 中实现相同规则。
示例场景:多步骤费用报销表单
想象一个 4 步的费用报销:详情(日期、类别、金额)、收据上传、备注、然后审核并提交。提交后显示审批状态:Draft、Submitted、Rejected、Approved。棘手之处在于校验、可能失败的保存以及手机离线时保留草稿。
在 MVVM 中,通常在 ViewModel 中保存一个 FormUiState(通常是 StateFlow)。每次字段变化都会调用 ViewModel 的函数如 onAmountChanged() 或 onReceiptSelected()。校验在变更、步骤导航或提交时运行。常见结构是原始输入加字段错误,派生标志控制下一步/提交按钮是否可用。
在 MVI 中,相同流程变为显式:UI 发送 AmountChanged、NextClicked、SubmitClicked、RetrySave 等 intents。reducer 返回新状态。副作用(上传收据、调用 API、显示 snackbar)在 reducer 外执行并将结果作为事件反馈回来。
实践中,MVVM 使得快速添加函数和更新流变得容易。MVI 则更难无意间跳过某个状态转移,因为所有变化都必须通过 reducer 汇流。
常见错误与陷阱
大多数表单 bug 来自于不清晰的真相归属、校验何时运行以及异步结果晚到时的处理规则。
最常见的错误是混合真相来源。如果文本字段有时从控件读取,有时从 ViewModel 状态读取,有时又从恢复的草稿读取,就会出现随机重置和“我的输入消失了”的问题。选择一个规范状态来源,并从它派生所有其他内容(领域模型、缓存行、API 负载)。
另一个容易犯的陷阱是把状态和事件混淆。吐司、导航或“已保存!”是一次性事件。必须在用户编辑前一直可见的错误信息是状态。混合二者会导致旋转时重复效果或丢失反馈。
两类常见正确性问题:
- 在每次按键都过度校验,尤其是昂贵的检查。应该防抖、在失焦时校验,或仅校验被触碰过的字段。
- 忽视乱序异步结果。如果用户保存两次或在保存后编辑,旧的响应可能覆盖新的输入,除非使用请求 ID(或“仅最新”逻辑)。
最后,草稿不应只是“保存 JSON”。没有版本控制,应用更新会破坏恢复。为草稿添加简单的 schema 版本和迁移策略,即便策略是对非常老的草稿“舍弃并重来”。
出货前的快速检查表
在争论 MVVM vs MVI 之前,先确保你的表单有一个清晰的单一真相来源。如果一个值会在屏幕上改变,它应属于状态,而不是视图控件或隐藏的标志。
一个实用的出货前检查:
- 状态包含输入、字段错误、保存状态(idle/saving/saved/failed)和草稿/队列状态,让 UI 无需猜测。
- 校验规则是纯函数且可在没有 UI 的情况下测试。
- 乐观 UI 有服务器拒绝时的回滚路径。
- 错误绝不清除用户输入。
- 草稿恢复可预测:要么自动恢复并显示横幅,要么提供显式的“恢复草稿”操作。
一个能抓住真实 bug 的测试:在保存过程中打开飞行模式,再关闭并重试两次。第二次重试不应该产生重复记录。使用请求 ID、幂等键或本地“待保存”标记来保证重试安全。
如果这些答案模糊,先收紧状态模型,然后选择那个最易于强制这些规则的模式。
下一步:选路径并更快构建
先问自己一个问题:如果表单进入奇怪的半更新状态代价有多大?如果代价低,就保持简单。
当界面比较直接、状态大多是“字段 + 错误”,且团队已经用 ViewModel + LiveData/StateFlow 稳定交付时,MVVM 是很好的选择。
当你需要严格可预测的状态转移、大量异步事件(自动保存、重试、同步),或错误代价高(支付、合规、关键工作流)时,MVI 更合适。
无论选哪条路,对表单回报最高的测试通常不触 UI:校验边界情况、状态转移(编辑、提交、成功、失败、重试)、乐观保存回滚以及草稿恢复与冲突行为。
如果你还需要后端、管理面板和 API 与移动端一起,AppMaster (appmaster.io) 可以从一个模型生成可生产的后端、Web 与原生移动应用,有助于在多个界面上保持校验与工作流规则一致。
常见问题
当界面流程比较线性,且团队已经有成熟的 StateFlow/LiveData、一次性事件和取消约定时,选择 MVVM。当你预计会有大量重叠的异步工作(自动保存、重试、上传)并且希望更严格地保证状态转移不会被多个来源打断时,选择 MVI。
从一个单一的屏幕状态对象入手(例如 FormState),其中包含原始字段值、字段级错误、表单级错误,以及明确的状态(如 Saving、Failed)。把派生标志(例如 isValid、canSubmit)集中计算,这样 UI 只负责渲染而不是重新决策逻辑。
在编辑时运行轻量、廉价的检查(必填、范围、基础格式),而在提交时运行严格检查。把校验逻辑放在 UI 之外以便测试,并把错误存入状态以便它们在旋转或进程被杀死后仍能保留。
把异步校验当作“最新输入生效”。保存被校验的值(或请求/版本 id),在结果返回时忽略与当前状态不匹配的旧结果。这样可以防止陈旧响应覆盖用户的新输入,避免“随机”错误信息出现。
立即在 UI 上反映用户的操作(例如显示 Saving… 并保持输入可见),但始终保留一个回滚路径以便服务器拒绝时恢复。使用请求 id/版本、禁用或防抖 Save 按钮,并明确编辑发生在保存期间的含义(锁定字段、排队另一次保存或再次标记为脏)。
失败时绝不清除用户输入。把字段级问题以内联方式显示,表单级阻塞信息放在提交附近,对于网络失败提供可重试的方式,并在重试时重发相同的有效负载(除非用户已经改动)。
把一次性事件从持久状态中剥离出来。在 MVVM 中,通过单独的流(例如 SharedFlow)发送它们;在 MVI 中,把它们建模为 UI 消费一次的 Effects。这样可以避免旋转或重新订阅时重复显示 snackbar 或重复导航。
主要持久化原始用户输入(按键输入的字符串、所选 ID、附件 URI),并额外保存用于安全合并的元数据(比如最后已知的服务器版本标记)。在恢复时重新计算校验而不是持久化校验结果,并为草稿添加简单的 schema 版本以便应对应用升级。
使用短时的防抖(大约几百毫秒),并在步骤切换或后台时强制保存一次。每次按键都保存既嘈杂又容易产生竞争,而仅在退出时保存则可能在进程被杀或中断时丢失工作。
对服务器快照和草稿都保留一个版本标记(例如 updatedAt、ETag 或本地递增号)。如果服务器版本未变,就应用草稿并提交;若服务器发生变化,向用户清晰地展示选择:保留我的草稿或重新加载服务器数据,而不是静默覆盖任一方。


