
01
为什么复杂页面需要重新设计状态流
Android 架构并不是一个新话题。很多团队已经从 Activity 直接写业务逻辑,走到 MVP,再走到 MVVM;也已经习惯了 ViewModel、Repository、依赖注入、协程和 Flow。对于简单页面来说,传统 MVVM 仍然很好用:ViewModel 暴露几个状态,View 观察状态并刷新 UI,点击事件调用对应方法,代码直观,成本也低。
问题通常出现在页面变复杂之后。一个 ViewModel 里可能同时存在列表数据、首次加载、下拉刷新、局部按钮 loading、错误提示、弹窗、导航事件、权限请求、外部页面返回结果等状态。View 层观察多个 Flow 或 LiveData,某些状态互相依赖,却分散在不同 collector 里。一次接口失败到底保留旧数据还是清空列表?一个弹窗关闭后是否要重置事件?外部页面返回后是否每次都刷新?这些问题如果没有统一约束,很容易靠"约定"和"经验"维持。
MVVM 的优势是分层,它让 View 不再直接持有业务逻辑。但 MVVM 没有强制规定事件入口必须统一,也没有强制规定页面状态必须聚合,更没有天然解决一次性副作用。小页面里,这种自由度很舒服;复杂页面里,自由度会变成不确定性。
Compose 让这个问题更明显。Compose 是声明式 UI,UI 理想上应该是某个状态快照的函数:
@Composable
fun Screen(
state: UiState,
onAction: (Action) -> Unit,
)
状态变化,UI 重新执行;用户操作,事件向上抛出。也就是说,Compose 的心智模型天然接近"状态向下,事件向上"的单向数据流。如果继续沿用多个可变字段、多个临时事件、多个公开方法,重组、生命周期恢复和页面返回就会放大旧问题。
MVI 的价值就在这里。它不是让所有页面都写得更复杂,而是给复杂页面加上几条纪律:UI 不直接修改状态,只发送 Action;ViewModel 是状态变化的唯一入口;页面通过一个完整 UiState 渲染;导航、Toast、权限、系统页面这类一次性动作进入 Effect;所有行为都可以被追踪、测试和复盘。
这种纪律在团队协作中尤其重要。一个人写页面时,很多隐含规则可以存在脑子里;多人长期维护时,隐含规则就会变成风险。今天有人在点击事件里直接导航,明天有人在 State 里加一个 shouldShowToast,后天又有人在 onResume 里无条件刷新,页面行为很快就变成"能跑但不可推理"。MVI 的价值不是减少代码行数,而是让页面行为从隐式约定变成显式协议。
当然,架构选择不能脱离成本。MVI 会引入更多类型和样板,也要求开发者在写代码前先想清楚事件、状态和副作用的边界。对于简单页面,它可能显得啰嗦;对于复杂页面,它能把复杂度摆到台面上。架构不是为了让代码看起来高级,而是为了让需求变更、问题排查、测试补充和新人接手都更可控。
02
从 MVC 到 MVI:架构演进的真正主线
Android 架构演进表面上是模式变化,底层其实是三个问题的变化:谁持有状态,谁处理事件,谁决定 UI。

Android 架构演进
早期 MVC 写法里,Activity 往往同时承担 View 和 Controller。它初始化控件,监听点击,调用接口,回调里再更新 UI。开发很快,但 Activity 容易膨胀,生命周期和异步回调也难测试。
MVP 把 Presenter 放到中间,View 只暴露接口,Presenter 调接口并命令 View 更新。它确实把逻辑从 Activity 中挪走了,但接口会随着页面复杂度成倍增长。一个 Presenter 连续调用 showLoading()、showContent()、hideDialog()、navigate() 时,页面最终状态需要读一串命令才能推断出来。
MVVM 的关键改变是"状态可观察"。ViewModel 不再直接命令 View,而是暴露状态,View 根据状态渲染。这一步非常重要,它让页面逐渐从命令式更新走向状态驱动。但在很多项目里,MVVM 仍然保留了较高自由度:多个方法入口,多个状态出口,副作用和状态混杂,一次性事件靠 consumed 包装或手动清空。
MVI 则进一步收敛:用户或系统输入先变成 Action,ViewModel 处理 Action 后输出 State 或 Effect。它牺牲了一点写法上的自由,换来更强的可预测性。维护者可以先看 Action 知道页面支持哪些行为,看 State 知道页面有哪些可渲染状态,看 Effect 知道页面会触发哪些一次性动作。
从工程实践看,架构演进还有一个隐含趋势:UI 层承担的"决策"越来越少。MVC 时代,Activity 既决定何时请求数据,也决定何时展示 loading,还决定失败后如何恢复。MVP 把一部分决策交给 Presenter,但 View 接口仍然是命令式的。MVVM 让 View 根据状态被动渲染,但状态可以很分散。MVI 则把"输入是什么、状态如何变、一次性动作何时发生"统一收敛到 ViewModel 或状态生产层。UI 的职责越单纯,页面越容易测试和替换。
这种趋势与现代 Android 技术栈是同向的。Compose 鼓励声明式 UI,Flow 鼓励数据流建模,Hilt 鼓励依赖显式装配,Repository 鼓励数据来源隔离。MVI 并不是孤立的模式,它更像是把这些能力组织起来的一套页面级协议。
这也是为什么 MVI 更适合复杂页面,而不是所有页面。对于静态页、简单详情页、一次性活动页,普通 MVVM 完全足够;对于多状态、多入口、多副作用、多人长期维护的页面,MVI 的收益会明显放大。
03
MVVM 与 MVI:不是替代关系,而是约束升级
很多 Android 项目里的 MVI 仍然使用 ViewModel、Repository、Flow 和 Hilt。因此 MVI 并不是完全推翻 MVVM,而是在 MVVM 分层基础上,对事件入口、状态出口和副作用边界做约束升级。

MVVM 与 MVI 的数据流差异
典型 MVVM 页面中,View 可能调用 refresh()、retry()、submit()、clickItem(id) 等多个方法;ViewModel 暴露 tasks、loading、error、dialog、navEvent 等多个流。多个流并不天然错误,问题在于复杂页面里状态之间经常存在强关联:loading 和 empty 谁优先?刷新失败是否保留旧数据?弹窗关闭是否要重置输入?如果这些组合逻辑散在 UI 层,页面会越来越难维护。
MVI 的数据流更像一个闭环:UI 根据 State 渲染,用户操作变成 Action,ViewModel 处理 Action,产出新的 State 或 Effect。复杂字段依然可以很多,但它们聚合在一个 UiState 快照里。UI 每次拿到的都是完整画面,而不是若干片段。

MVVM vs MVI 架构对比
MVVM 的优点是简单、灵活、学习成本低;缺点是复杂页面里风格容易分叉,副作用容易混入状态。MVI 的优点是入口统一、状态完整、副作用显式、测试自然;缺点是初期样板更多,需要团队遵守统一规范。选择时不要问"哪个模式更高级",而要问"当前页面的复杂度是否值得用更强约束来换可维护性"。
MVVM 与 MVI 最大的差别不是"有没有 ViewModel",而是 ViewModel 对外暴露的形态。MVVM 中,ViewModel 往往像一组能力集合:refresh()、submit()、openDetail()、loadMore()。这些方法本身没有错,但方法越多,页面行为越像散落的入口。MVI 中,ViewModel 更像一个状态机:所有外部输入先变成 Action,再由 dispatch 分发。这样做的一个好处是,页面行为天然形成清单,代码评审时也更容易发现遗漏:新增了一个用户路径,就应该有对应 Action、状态变化和测试。
另一个差别是副作用的定位。MVVM 项目里常见 SingleLiveEvent、EventWrapper 或 consumed 标记,本质上都是在修补"状态和事件混在一起"的问题。MVI 直接把副作用单独建模为 Effect,语义更清楚。State 是可以反复渲染的画面事实,Effect 是只能发生一次的动作。这个边界一旦建立,很多重复 Toast、重复跳转、返回后误刷新之类的问题会自然减少。
还有一个经常被忽略的差异是"调试视角"。MVVM 页面出问题时,排查者往往要同时看多个观察者、多个公开方法和多个临时事件。某个按钮变灰,可能来自 loading,也可能来自 formValid,还可能来自某个接口失败后没有重置字段。MVI 页面更适合快照式调试:当前 State 是什么,刚刚收到哪个 Action,之后发了哪个 Effect。只要日志或调试工具能记录这三者,很多状态 bug 可以按时间线还原。
从团队协作看,MVI 还改变了需求拆分方式。UI 同学可以先基于 UiState 写 Screen;业务同学可以在 ViewModel 里实现 Action 到 State 的转换;测试同学可以直接基于 Action 构造用例。页面不再是"谁熟谁改"的大块代码,而是拆成协议、状态生产和渲染几个相对清晰的部分。当然,这种协作收益依赖规范一致,如果每个页面都自创 Action 命名和 Effect 规则,MVI 仍然会退化成另一种混乱。
如果从优缺点做一个更落地的判断:MVVM 胜在轻量、直观、接近 Android 官方长期推荐的分层方式,适合需求稳定、状态较少、交互路径短的页面;MVI 胜在可追踪、可测试、可协作,适合状态和事件都在增长的页面。MVVM 的风险是"每个人都能按自己的习惯扩展",MVI 的风险是"团队没有统一规范时样板会变成负担"。所以架构不是一次性选择,而是按页面复杂度持续调整的工程策略。
一个比较实用的判断方式是看"页面行为是否可以被一屏说清"。如果一个页面的所有操作只有加载、返回、提交三个动作,MVVM 已经足够清楚;如果一个页面同时存在刷新、筛选、弹窗确认、权限、深链、外部页面返回、局部操作和失败恢复,那么继续使用一组松散方法就会越来越吃力。MVI 不是为了让代码变花,而是让页面从"散点行为"变成"状态机行为"。当需求评审时能把用户路径直接映射成 Action,当测试用例能围绕 State/Effect 编写,架构就开始真正产生收益。
04
MVI 的三件套:Action、State、Effect
MVI 最容易被误解为"多写几个 sealed class"。真正重要的不是类名,而是边界。
Action 表示页面允许发生什么。它不是控件点击事件的机械翻译,而是用户意图或系统输入。例如列表页可以有:
sealed interface TaskListAction : MviAction {
data object RefreshRequested : TaskListAction
data object LandingReturned : TaskListAction
data class TaskSelected(val taskId: String) : TaskListAction
}
这里表达的是"刷新""外部页面返回""选择某个任务",而不是"某个按钮被点了"。好的 Action 应该描述意图,携带最少必要参数,不包含 View、Context、NavController,也不暴露 UI 细节。BlueButtonClicked 这类命名会把视觉实现泄漏到 ViewModel;SubmitRequested、ItemSelected(id)、PermissionResultReceived(granted) 更稳定。
Action 的粒度需要克制。太粗会让 ViewModel 内部继续出现隐式分支,例如一个 Submit 里根据各种 UI 状态猜测用户意图;太细又会让协议膨胀,例如把每个动画中间态都发给 ViewModel。判断标准可以很务实:这个事件是否会影响业务状态,是否会触发数据请求或副作用,是否需要被测试覆盖,是否可能由多个 UI 入口触发。如果答案是肯定的,它适合成为 Action;如果只是一个组件内部的临时视觉状态,就留在 Composable。
State 表示页面此刻长什么样。它必须可重复渲染,旋转屏幕、生命周期恢复或 Compose 重组后,重复消费 State 不应该触发副作用。一个列表页 State 可以这样写:
data class ListUiState(
val items: List<ItemUi> = emptyList(),
val isInitialLoading: Boolean = false,
val isRefreshing: Boolean = false,
val inlineError: String? = null,
) : MviState {
val showEmpty: Boolean
get() = !isInitialLoading && items.isEmpty()
}
State 可以用 data class,也可以用 sealed interface。字段能组合时,用 data class 更自然,例如列表、表单、多 Tab 页面。主体状态强互斥时,用 sealed interface 更安全,例如加载中、内容、失败三态。复杂页面经常两者结合:顶层 UiState 是 data class,内部某个 contentState 用 sealed interface。
State 的设计要避免两个极端。一个极端是状态太碎,页面暴露五六个 StateFlow,UI 层负责组合;另一个极端是状态太大且没有结构,所有字段都平铺在一个 data class 里,维护者看不出哪些状态互斥、哪些状态可以并存。更好的方式是把顶层 UiState 当作页面快照,再用内部类型表达局部结构。例如主体内容可以是 Loading / Content / Failed,表单字段、按钮提交态、弹窗状态则作为可组合字段存在。
Effect 表示只能消费一次的副作用。打开页面、弹 Snackbar、请求权限、启动系统安装器、关闭页面并设置 result,这些动作如果因为重组重复执行,就会出问题。因此它们不应该放进 State。

Effect 边界
Effect 的设计要轻量。Effect 不应该携带 View、Context、NavController 这类平台对象,它只表达"应该发生什么"。例如 OpenDetail(id)、ShowSnackbar(message)、RequestInstallPermission(file)。真正调用系统 API 的地方在 Activity 或 Route 层。这样 ViewModel 保持可测试,平台能力也集中在离系统最近的位置。
一个简单 MVI ViewModel 基类可以只做三件事:暴露 StateFlow<State>、暴露 SharedFlow<Effect>、提供 dispatch(Action)。它的目的不是炫技,而是固定协议:
abstract class MviViewModel<A : MviAction, S : MviState, E : MviEffect>(
initialState: S,
) : ViewModel() {
private val _state = MutableStateFlow(initialState)
val state: StateFlow<S> = _state.asStateFlow()
private val _effects = MutableSharedFlow<E>(extraBufferCapacity = 1)
val effects: SharedFlow<E> = _effects.asSharedFlow()
abstract fun dispatch(action: A)
protected fun setState(reducer: S.() -> S) {
_state.update { it.reducer() }
}
}
真正落地时还要注意两点。第一,State 更新尽量输出自洽快照。接口成功后不要分三次更新数据、loading、error,而是在一次 copy 中完成。第二,派生字段要放在合适位置。多个组件都需要的 canSubmit、showEmpty、completedCount 更适合放在 State 或 ViewModel 中统一计算;纯视觉细节可以留在 Composable。
是否必须引入纯 Reducer,要看复杂度。严格 Redux 风格要求 reducer 是纯函数,这对复杂状态机很有价值;但 Android 页面常常要处理协程、Repository、权限和 Activity Result,完全纯化会让结构变厚。多数项目可以采用折中方案:ViewModel 中保留 setState { copy(...) },把复杂转换抽成私有 reducer 方法;只有下载、播放、编辑器、长连接这类状态机非常复杂的页面,再引入独立纯 reducer。
在具体编码时,可以给 Action、State、Effect 设定几条简单约束。Action 命名尽量使用业务动词,例如 RefreshRequested、ItemSelected、SubmitConfirmed;State 字段命名尽量表达作用范围,例如 isInitialLoading 而不是泛泛的 loading;Effect 命名尽量表达一次性动作,例如 OpenInstaller、ShowSnackbar、FinishWithResult。这些细节看似琐碎,但它们会直接影响半年后的可读性。
还有一个容易踩的坑,是把 Action 写成接口调用的翻版。例如 LoadApiCalled、ReportClicked 这类命名会把实现细节暴露出来。Action 应该站在页面行为角度命名:用户请求刷新、选择某项、确认提交、页面返回、权限结果到达。至于它背后是调接口、读缓存、写本地,还是先上报再跳转,都应该由 ViewModel 和 Repository 决定。这样当实现顺序变化时,UI 协议不需要跟着改。
State 也不应该追求"字段越少越好"。复杂页面需要足够表达力,关键是字段之间语义清楚。比如 isRefreshing 与 isInitialLoading 同时存在并不重复,它们代表不同体验;dialog: DialogState? 比多个 showXxxDialog 更容易扩展;contentState 表达主体互斥状态,局部提交态则单独存在。好的 State 读起来应该像页面说明书,而不是接口字段集合。
还有一种常见误区,是把所有状态都设计成 Boolean。Boolean 简单,但表达力有限。isLoading、isError、isEmpty 同时出现时,维护者必须猜测它们能不能同时为 true。对于互斥状态,用 sealed interface 更稳;对于可组合状态,用 data class 更自然。状态类型本身就是文档,它应该帮助编译器和维护者理解哪些情况允许并存,哪些情况必须互斥。
05
为什么 Compose 天然适合 MVI
Compose 的核心是声明式 UI。我们不再写 textView.text = ... 或 button.isEnabled = ...,而是根据状态描述画面:
@Composable
fun SubmitButton(
state: FormUiState,
onAction: (FormAction) -> Unit,
) {
Button(
enabled = state.canSubmit,
onClick = { onAction(FormAction.SubmitRequested) },
) {
Text(if (state.isSubmitting) "提交中" else "提交")
}
}
这与 MVI 的单向数据流非常契合。

Compose + MVI 单向循环
在实际项目中,建议把 Compose 页面拆成 Route 和 Screen。Route 负责连接 ViewModel、收集 State、消费 Effect、调用导航和权限等平台能力;Screen 只接收 State 和回调,专注展示。这样 Screen 可以预览、截图测试,也不会依赖 Hilt、NavController 或 Repository。
@Composable
fun PageRoute(viewModel: PageViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(viewModel) {
viewModel.effects.collect { effect -> handleEffect(effect) }
}
PageScreen(
state = state,
onAction = viewModel::dispatch,
)
}
Route/Screen 拆分是 Compose + MVI 落地时最值得坚持的习惯。很多页面刚迁移 Compose 时,会把 ViewModel 直接传给所有子组件,短期写起来很快,但子组件马上失去复用能力。一个列表项组件如果直接调用 viewModel.dispatch(...),它就无法在别的页面复用,也不容易 Preview。更好的方式是让子组件只接收普通数据和 lambda,哪怕最终这些 lambda 都会回到 onAction。
Compose 不要求所有页面都使用 MVI,但它要求我们尊重状态所有权。输入内容、校验错误、接口结果、提交状态、筛选条件应进入 UiState;滚动位置、局部菜单展开、动画中间态可以留在 Composable。判断标准很简单:这个状态是否属于页面业务事实,是否需要跨组件共享,是否要被测试覆盖。
remember 与 ViewModel State 的边界也需要统一。输入框内容、筛选条件、接口结果、错误文案、提交状态属于页面业务事实,应该进入 UiState;滚动状态、临时菜单展开、动画控制、局部焦点状态可以使用 remember 或 rememberSaveable。如果一个状态会参与接口请求、按钮可用态、业务校验或跨组件展示,就不要只放在 Composable 内部,否则页面会出现两份事实来源。
稳定性也很重要。UiState 最好是不可变 data class,列表作为只读 List 暴露,不要把 MutableList、Context、View、CoroutineScope 放进 State。列表渲染要提供稳定 key,局部 item 状态变化时只替换目标 item。MVI 的"单 State"是逻辑出口,不代表性能上粗暴重绘;Compose 会根据参数读取和稳定性做重组优化,我们要提供清晰、不可变、可比较的状态。

XML/MVVM 与 Compose/MVI 开发体验对比
最常见的错误是在普通重组路径里触发副作用:
if (state.shouldNavigate) {
navController.navigate("detail")
}
这类代码可能因为重组重复导航。正确做法是把导航建模为 Effect,在 Route 层的 LaunchedEffect 中收集。State 负责"画面是什么",Effect 负责"发生一次什么"。
Compose 还有一个优势是让 UI 状态的视觉验证更便宜。只要 Screen 不依赖 ViewModel,就可以用多个 Preview 或截图测试覆盖加载、空态、错误、内容、提交中等状态。过去 XML 页面经常要跑完整流程才能看到某个状态,现在只要构造对应 UiState。MVI 把状态收敛成一个对象,正好降低了这种视觉验证的成本。
不过,Compose 也会放大状态设计不清的问题。命令式 View 里,某些临时更新可能只发生一次,看起来没有太大影响;Compose 中,只要状态还在那里,UI 就会持续按它渲染。比如把 shouldOpenDetail 放进 State,页面重组后仍然会尝试跳转;把 Snackbar 文案放进 State,生命周期恢复后可能再次提示。这也是为什么 MVI 要把 State 和 Effect 分开:声明式 UI 越强,状态语义越要干净。
Compose 页面还需要注意"局部重组"和"业务重算"的区别。重组本身不可怕,可怕的是每次重组都重新做重型业务计算,或者因为状态对象不稳定导致大范围 UI 反复执行。列表过滤、分组、统计这类业务派生结果,应该尽量在 ViewModel 或 UiState 中准备好;滚动位置触发的纯 UI 判断,则可以使用 derivedStateOf 留在 Composable。这样既不把 UI 细节塞进 ViewModel,也不把业务规则散在 UI 树里。
06
Flow、协程与 Hilt 的组合价值
MVI 在 Kotlin/Android 项目里通常会与 Flow 和协程配合。最常见的组合是:StateFlow<UiState> 表示页面状态,SharedFlow<UiEffect> 表示一次性副作用,viewModelScope 管理异步任务,Repository 内部或注入的 Dispatcher 处理 IO。

Flow 选择速查
StateFlow 适合 UiState,因为它永远有当前值。页面重新进入生命周期时,不需要等待下一次 emit 就能拿到最新画面。SharedFlow 更适合 Effect,因为一次性事件通常没有"当前值"概念。Channel 也能表达一次性消息,但更适合内部队列或单消费者任务;如果只是 ViewModel 向 UI 发导航、提示、权限请求,SharedFlow 与生命周期收集更自然。
复杂页面的状态 bug 很多来自并发,而不是字段本身。搜索输入适合"后发优先",用户输入新关键词后旧请求应取消或忽略;表单提交适合"首发锁定",请求结束前后续点击忽略;列表 item 操作适合"按 id 隔离",不同 item 可并发,同一 item 不重复提交。异步结果落 State 前还要校验上下文:请求开始时页面处于 A 分类,返回时用户可能已经切到 B 分类,旧结果不能覆盖新状态。
错误处理也应该有语义边界。首屏加载失败通常进入 State,因为页面要持续展示失败态;下拉刷新失败但保留旧数据,可以发 Snackbar Effect;表单字段错误应该进入 State,因为它需要持续展示;未授权需要登录则更像导航 Effect。不要把所有异常都压成一句"请求失败",也不要把底层异常直接丢给 UI。
Flow 的组合也要克制。Repository 可以暴露持续数据流,例如本地缓存、登录状态、配置变化;ViewModel 可以把这些流与页面 Action 结合,产出 UiState。但不要让 UI 同时收集太多业务 Flow。UI 只收集 UiState,并通过 Action 表达操作。否则 Compose 页面会重新承担状态组合工作,MVI 的边界就被削弱了。
StateFlow 与 SharedFlow 的 replay 策略也需要按语义选择。UiState 必须有当前值,因此 StateFlow 合适;Effect 通常不希望旧页面重建后重复消费,因此 SharedFlow 不应随意设置较大的 replay。extraBufferCapacity = 1 可以避免短时间 UI 未开始收集时丢掉一条事件,但如果 Effect 很关键,例如支付结果、文件保存结果,可能还需要结合页面结果或持久状态设计,而不是简单依赖内存事件流。
Hilt 与 MVI 没有强绑定。小项目完全可以手动注入 Repository;中大型项目则推荐 Hilt,因为 ViewModel、Repository、ApiService、DataStore、Dispatcher、设备能力 Provider 之间依赖关系会快速增多。Hilt 的价值不是让代码更"高级",而是让依赖图显式、可替换、生命周期可控。

Hilt 取舍
在 MVI 项目里,Hilt 主要提供 Repository、平台能力 Provider、Dispatcher、网络与本地数据源。不要把 Hilt Module 写成业务脚本:Module 只负责对象如何创建,不负责判断用户状态、拼业务规则、选择页面路径。ViewModel 仍然应该是页面行为的组织者,Repository 仍然应该是数据能力的边界。
Dispatcher 注入是一个小细节,但会影响测试质量。直接在 ViewModel 里写 Dispatchers.IO 可以运行,但单测难以控制虚拟时间。更好的做法是定义 AppDispatchers,生产环境提供 IO/Main/Default,测试环境提供 TestDispatcher。这样 debounce、timeout、并发取消都能用 advanceUntilIdle() 或虚拟时间稳定验证。
Hilt 在测试中的价值不只是替换 Repository。更重要的是让构造关系统一。普通 JVM 单测可以直接 new ViewModel 并传 fake;Instrumented Test 可以替换 Module;不同环境下的 Dispatcher、BaseUrl、Interceptor、Provider 都能被明确替换。依赖一旦显式,测试环境就不必绕过大量单例和静态工具类。
协程任务的生命周期也需要设计。并不是所有任务都应该因为页面暂时不可见而立即取消。普通列表刷新随 ViewModel 结束取消很合理;文件下载、安装包处理、支付状态查询这类任务则可能需要更长生命周期或独立管理器。MVI 不负责替你决定任务活多久,它负责把任务过程和结果表达成 State/Effect。任务生命周期、重试策略、断点续传和持久化,仍然应该按业务重要性设计。
对于点击上报、日志、埋点这类"失败不阻断主流程"的动作,也要明确边界。它们可以由 Repository 或专门的 Tracker 处理,失败时记录日志即可,不要随意改变 UiState。只有当失败会影响用户可见结果时,才应该进入 State 或 Effect。这样能避免把后台统计问题放大成用户体验问题,也能让页面主流程更稳定。
07
工程分层:让变化停在该停的位置
MVI 主要治理 UI 层和表现层,但它不能替代完整工程分层。一个健康的 Android 项目仍然需要 UI、Presentation、Repository/Domain、Data/Platform 之间的清晰边界。

通用 Android 项目分层
UI Layer 只关心怎么画。Compose Screen 接收 State 和回调,不直接依赖 Repository,不调用接口,不持有业务对象的可变引用。Activity 或 Route 承接系统能力,例如 Activity Result、权限、WebView 容器、系统安装器。
Presentation/MVI Layer 负责把行为变成状态。ViewModel 接收 Action,调用 Repository,更新 State,发出 Effect。任何维护者想知道页面能做什么,应该先看 Action;想知道页面有哪些画面,应该看 UiState;想知道页面会触发哪些一次性动作,应该看 Effect。
Repository 不应只是 ApiService 的改名包装。它应该隐藏数据来源,完成 DTO 到 Domain/UI Model 的转换,收敛网络错误,聚合本地缓存与远端数据,提供稳定的数据能力。ViewModel 不应该知道"这个列表来自哪个接口,是否先读缓存,失败如何降级";它只关心刷新、提交、切换、打开等页面行为。
模型边界尤其重要。DTO 描述接口协议,Entity 描述本地存储,Domain Model 描述业务对象,UI Model 描述页面展示。小项目不一定需要完整 Domain 层,但不建议把接口 DTO 直接塞进 UiState。否则后端字段变化、UI 改版、业务规则调整会互相污染。
平台能力也应该被包装成接口。设备标识、剪贴板、文件目录、权限状态、下载、安装器等都和 Android Framework 强相关。ViewModel 可以依赖 DeviceIdProvider、Downloader、PermissionChecker 这样的稳定接口,而不是直接持有 Context 或系统对象。这样测试更容易,厂商兼容和系统版本差异也不会污染页面状态机。
网络层也要避免把协议细节泄漏给页面。HTTP 状态码、业务 code、错误 body、空 body、解析异常,最好统一收敛成 ApiResult 或领域错误。ViewModel 不应该到处 try/catch,也不应该关心某个接口失败时原始 code 是多少。它应该面对更稳定的语义:成功、无权限、参数错误、网络不可用、服务器异常。这样页面错误处理才能一致,日志和埋点也更容易统一。
模型转换可以放在 Repository,也可以放在独立 mapper,取决于项目规模。中小项目让 Repository 输出 UI Model 并不丢人,重点是转换点清楚;大项目可以增加 Domain Model 和 UseCase,把跨页面业务规则沉淀下来。不要为了分层而分层,也不要为了省事让 DTO 穿透全项目。好的分层应该让变化停下来,而不是让所有需求都必须穿过更多文件。
依赖方向要保持单向。UI 依赖 Presentation,Presentation 依赖 Repository,Repository 依赖 DataSource 和 Provider。反过来,Repository 不应该知道某个 Composable 怎么展示,DataSource 不应该知道页面有几个按钮。依赖方向一旦反了,MVI 的页面状态再清楚,也挡不住下层代码反向牵动 UI。
分层的目的不是目录好看,而是变化隔离。接口字段变化时,影响应尽量停在 DTO 和 mapper;UI 改版时,影响应尽量停在 UI Model、UiState 和 Composable;平台权限策略变化时,影响应尽量停在 Provider 和 Effect 消费层。
在中大型项目中,这种单向依赖还会影响模块化。UI 模块可以依赖 presentation contract,data 模块提供 Repository 实现,network 模块只关心接口协议。即使当前项目还没有拆 Gradle module,也可以先在包结构和依赖方向上遵守这个原则。等项目真的需要模块化时,迁移成本会小很多。
分层还会影响接口文档和代码注释的可信度。接口文档描述的是请求方式、参数、响应字段和错误语义;DTO 注释应该解释字段来源和兼容规则;Repository 注释应该说明业务能力和失败行为;ViewModel 注释应该说明 Action 与 State 的关系。如果这些层混在一起,注释很容易写成"把字段赋值给字段"的废话。边界清楚之后,注释才能解释"为什么"和"业务约束",而不是重复代码。
08
复杂页面建模:真正考验 MVI 的地方
简单页面写 MVI 看不出太多价值,复杂页面才是试金石。复杂不是字段多,而是状态之间有约束。
加载态不能只靠一个 isLoading。首次加载、下拉刷新、局部按钮提交、保留旧数据的错误提示,语义不同。首次加载失败可能展示全屏错误;下拉刷新失败通常保留旧列表并提示;局部按钮提交失败只影响某个 item。State 需要表达这些差异,而不是把所有过程揉成一个布尔值。
空态和错误态也值得单独设计。很多页面只区分 loading=true/false,导致首次加载、空数据、刷新失败、接口异常都挤在一起。更好的建模是区分主体内容状态和局部操作状态:主体内容负责 Loading、Content、Empty、Failed;局部操作负责 refreshing、submitting、deleting。这样页面可以做到首次失败展示全屏错误,刷新失败保留旧数据,局部按钮失败只影响当前按钮。
弹窗要分两类。删除确认、切换确认、筛选面板这类可恢复弹窗属于 State,因为旋转屏幕后用户仍然应该看到;操作成功提示、刷新失败 Snackbar 这类一次性反馈属于 Effect。Snackbar 如果有"撤销"按钮,UI 展示 Snackbar 后再把 UndoRequested 作为 Action 发回 ViewModel,不要让 UI 直接改业务状态。
导航、权限和系统页面也要走 Effect。用户点击按钮后,ViewModel 判断业务条件,发出 RequestPermission 或 OpenSettings,Activity/Route 消费 Effect 并调用系统能力;系统回调再作为 Action 回到 ViewModel。流程看起来长一点,但权限分支可以测试,平台能力也不会散落在 Composable 里。
WebView、文件下载、安装器这类平台能力更能体现边界。WebView 负责展示页面和监听下载链接;ViewModel 负责判断下载状态、更新进度 State、处理失败和完成;打开系统安装器是 Effect;断点续传、.part 临时文件、Range 请求这些技术细节放在 Downloader 或 Repository 中。UI 只需要知道"准备中、下载中、失败、完成、等待安装",不需要知道文件系统细节。
下载和安装流程是一个很典型的复杂状态机。用户看到的是"未开始、准备中、下载中、失败、完成、等待授权、打开安装器",但底层可能包含文件是否已存在、临时文件是否可续传、服务端是否支持 Range、通知栏权限是否已授予、未知来源安装权限是否打开等。UI 不应该感知这些细节。Downloader 负责技术流程,ViewModel 把它转换成下载 State 和安装 Effect,Activity 消费 Effect 进入系统页面。这样用户路径清楚,底层实现也可替换。
主页面通常也很复杂。它可能包含底部导航、侧边栏、启动状态、当前选中空间、深链处理、返回键优先级。跨区域共享的事实应该进入 MainUiState;子页面内部列表数据仍由子页面 ViewModel 管理。这样"加入成功后才刷新侧边栏""未知深链不切换""先关弹窗再关侧边栏再退出"这些规则都能被状态机表达,而不是靠多个页面之间的隐式约定。
外部输入同样应该进入 Action。深链、推送、桌面快捷方式、系统分享、外部页面返回,本质上都不是 UI 内部点击,而是系统输入。如果 Activity 收到深链后直接改 UI,很容易绕过状态机。更稳的做法是解析为 DeepLinkReceived(target),由 ViewModel 根据当前 State 判断目标是否可用、是否已加入、是否需要确认、是否应忽略。
页面返回结果也要显式。很多老页面会在 onResume 中无条件刷新,简单但粗糙。MVI 更推荐让子页面完成关键动作后返回结果,上级 Route 收到结果后 dispatch ChildReturned(result),ViewModel 决定是否刷新。普通返回不刷新,成功变更才刷新。这个规则看似小,却能减少很多无意义请求,也能避免用户只是看了一眼页面就触发状态变化。
对于主页面和多子页面结构,还要避免"父 ViewModel 管一切"。主页面保存跨区域事实,例如当前 Tab、全局启动状态、侧边栏是否展开、当前选中对象;子页面保存自己的列表、输入、刷新和错误。父子边界不清时,主 ViewModel 很容易变成巨大中转站,任何子页面改动都要动主页面。合理的做法是只把真正跨页面共享或影响导航结构的状态上提,其余状态留在子页面 ViewModel。
复杂页面还要处理"成功才刷新"的规则。很多应用会在返回上级页面时无条件重新拉数据,短期省事,长期会带来性能浪费和状态闪烁。MVI 更适合表达精确规则:加入成功、编辑保存、安装完成这类结果才触发刷新;普通返回、取消、失败不刷新。把这些规则写成 Action 和 State 转换,比散在 onResume 或回调里更容易维护。
09
迁移与测试:不要把架构升级做成冒险
从 XML/MVVM 迁移到 Compose/MVI,最怕一把梭。更稳妥的方式是先收拢状态,再统一事件,最后迁移 UI。

迁移路线图
第一步不要先改 UI,而是把多个 LiveData/Flow 聚合成一个 UiState。即使页面仍然是 XML,也可以先观察一个状态快照。第二步把 ViewModel 的多个公开方法收敛为 Action。第三步拆出 Effect,把导航、Toast、权限、页面结果从 State 中拿出来。完成这些之后,迁移 Compose 会顺很多,因为 Compose 页面需要的正是 State、回调和 Effect 收集器。
迁移时不要顺手重写业务规则。技术迁移和业务优化应该分开。第一轮迁移尽量保持接口语义、刷新时机、返回结果、关键提示、埋点含义一致。否则上线后出问题,很难判断是 Compose 问题、MVI 问题,还是业务被改坏了。
迁移过程中还要警惕"新旧状态并存"。一个页面如果既有 XML 时代遗留的 LiveData,又有新的 UiState,还在 Composable 内部 remember 一份业务字段,问题会比迁移前更难查。每迁移一个页面,都应该明确主状态源是谁;旧状态流如果只是中间过渡,要有清理计划。否则项目会同时背着 MVVM 和 MVI 的成本,却拿不到任何一边的完整收益。
测试策略也要跟着变化。MVI 的好处是可以绕过真实 UI 测页面行为。Reducer 或状态转换可以做纯 JVM 单测;ViewModel 可以测试 Action 输入后的 State 变化;Effect 可以测试是否只发一次;Repository 可以用 fake 替换真实网络。涉及 debounce、timeout、并发取消时,要注入 TestDispatcher,用虚拟时间推进,不要依赖 Thread.sleep。
一个 ViewModel 行为测试大致是这样:
@Test
fun refreshFailed_keepsOldItemsAndShowsMessage() = runTest {
val repository = FakeRepository()
val viewModel = ListViewModel(repository, testDispatchers)
repository.nextResult = Result.success(oldItems)
viewModel.dispatch(Action.RefreshRequested)
advanceUntilIdle()
repository.nextResult = Result.failure(NetworkError)
viewModel.dispatch(Action.RefreshRequested)
advanceUntilIdle()
assertEquals(oldItems, viewModel.state.value.items)
assertEquals(Effect.ShowSnackbar("刷新失败"), viewModel.effects.first())
}
UI 测试仍然需要,但不应承担所有信心。更合理的测试金字塔是:底部大量状态和 ViewModel 测试,中间少量 Repository 契约测试,顶部少量关键路径 Compose UI 测试。复杂页面的状态分支越多,越应该把大部分验证下沉到 ViewModel 层。
测试 fake 也不能只返回固定成功值。复杂页面需要 fake 支持成功、失败、延迟、连续调用返回不同结果、记录调用参数。比如"刷新失败不清空旧数据"这种行为,必须先让 fake 返回成功数据,再返回失败,才能验证 State 是否保留旧列表并发出提示。好的 fake 不是为了省事,而是为了模拟页面真正会遇到的状态变化。
代码评审也要随架构变化。以前 review 可能主要看接口是否调用成功、UI 是否展示正确;MVI 页面还要看新增交互是否有 Action,State 是否可重复渲染,Effect 是否承载一次性行为,异步返回是否校验上下文,Screen 是否绕过 Route 直接依赖 ViewModel。评审规则一旦稳定,团队就会慢慢形成共同语言。
迁移后的性能验证也不能忽略。Compose 页面需要关注列表 key、状态对象稳定性、图片加载、过度重组和长列表滚动;MVI 页面需要关注 Action 是否触发重复请求,Effect 是否被重复收集,State 是否频繁输出半成品。性能问题和架构问题经常交织在一起:状态设计不稳定,UI 就会频繁重组;请求并发不受控,页面就会反复闪 loading。迁移验收应同时看功能、状态流和体验。
迁移 PR 的粒度也要控制。一次 PR 同时修改 UI 框架、接口行为、缓存策略、埋点语义和错误文案,风险会很高。更稳妥的方式是先做结构迁移并保持行为等价,再在后续 PR 中优化业务和体验。评审者也应该要求迁移说明列出"保持不变的行为"和"有意改变的行为"。这样上线后出现问题时,团队能快速判断是迁移引入的回归,还是预期中的产品调整。
10
团队落地:效率、边界与常见坑
MVI + Compose 的短期开发速度不一定更快。它会多出 Action、State、Effect、dispatch、Route/Screen 拆分。简单页面可能显得啰嗦。但在复杂页面和多人协作中,它能降低理解成本、排障成本和回归风险。新人接手页面时,先看 Action 就能知道页面行为;排查 UI 状态问题时,看单一 UiState 快照比追多个流更直接;新增交互时,先补 Action,再补状态转换和测试,路径更固定。

项目规模选型建议
不要为了 MVI 而 MVI。静态页、一次性活动页、低交互页面可以继续使用普通 Compose + ViewModel。团队刚开始迁移时,也可以采用弱 MVI:先统一 UiState,再逐步引入 Action 和 Effect。架构应该允许渐进,而不是要求所有代码一次到位。
团队规范比个人偏好更重要。Action 命名用请求式还是过去式,loading 字段如何命名,Effect 是否允许携带 Intent,Route/Screen 如何拆分,错误文案在哪一层转换,ViewModel 单测覆盖哪些 Action,这些都需要约定。规范不必很长,但要能在代码评审中执行。

常见坑速查
以一个真实项目的抽象结构为例,迁移后可以形成这样的组织:core/mvi 放跨页面协议;每个页面拥有自己的 Action、State、Effect、ViewModel;Compose Screen 与 ViewModel 分开;Activity 只承接系统能力和页面容器;Repository 隐藏数据来源;Hilt 负责依赖装配。TaskGo 在这里仅作为这种结构的一个轻量样本,重点不是业务,而是边界。
如果团队要正式推广,可以先做一页式模板,而不是一开始写厚厚的规范。模板里放一个标准 ViewModel、一个 Route/Screen 拆分、一个 Effect 收集示例、一个 Repository fake、一个 ViewModel 单测。让正确写法变得顺手,比反复讲概念更有效。等团队熟悉后,再沉淀命名、目录、错误处理和测试覆盖规则。
衡量架构迁移是否成功,也不要只看"代码是不是更像 MVI"。更有意义的指标是:新人理解页面主流程是否更快,排查状态 bug 是否更容易,重复导航和重复提示是否减少,新增交互的修改点是否稳定,单测是否能覆盖关键行为。如果迁移后只是文件变多、样板变多、问题没有减少,那说明只是换了写法,没有真正建立状态纪律。
对于文章读者来说,最容易立即采用的并不是"全量改成 MVI",而是三件小事:把复杂页面多个状态流收敛成一个 UiState;把一次性导航和提示从 State 中拆成 Effect;把用户操作从多个公开方法收敛成 Action。只要这三步完成,页面已经具备 MVI 的核心骨架。之后再引入 Compose、Hilt、测试模板和更细的分层,会顺得多。
团队推广时也可以把这三件事做成代码评审检查项。看到新增公开方法,问一句是否应该是 Action;看到 State 里出现 shouldNavigate,问一句是否应该是 Effect;看到 UI 同时收集多个业务 Flow,问一句是否应该聚合为 UiState。持续几轮之后,团队会自然形成状态纪律,而不是依赖某个人反复提醒。
最后,架构文档本身也要保持更新。MVI 模板、命名规范、Effect 边界、测试写法和迁移路线,都应该随着项目实践调整。最好的规范不是一次写完,而是在真实问题中不断校准。只要团队愿意把踩过的坑沉淀回模板,架构就会从"个人经验"变成"组织能力"。
当团队能用同一套语言讨论"这是 State 还是 Effect""这个输入是否应该是 Action""这个错误应该由哪一层处理"时,架构就不再只是目录和类名,而会变成日常协作中的共识。这种共识比任何单个框架都更重要。
也正因为如此,MVI 的推广不应该只靠一次重构,而应该伴随代码评审、测试补充、文档更新和组件沉淀一起发生。它是一种持续改进的工作方式,需要耐心,也需要持续复盘,并在真实需求中反复验证,逐步形成稳定习惯和团队默契,最终服务长期维护和演进。
最后给一个落地 Checklist:
• 复杂页面是否只有一个主要 UiState。
• 用户事件是否统一通过 Action 进入 ViewModel。
• 导航、提示、权限、系统页面是否使用 Effect。
• Screen 是否尽量无状态,Route 是否负责平台能力。
• Repository 是否隐藏网络、本地和设备数据来源。
• 异步结果落 State 前是否校验当前上下文。
• 关键 Action 是否有 ViewModel 单测。
总结
MVI 的核心价值,不是让代码看起来更有"架构感",而是把复杂页面里最容易失控的东西显式化:用户意图、页面状态和一次性副作用。
MVVM 仍然是优秀架构,尤其适合简单页面和已有项目。但在 Compose 时代,声明式 UI 对状态一致性的要求更高。UI 是 State 的函数,事件向上回传,这与 MVI 的单向数据流天然契合。StateFlow 负责持续状态,SharedFlow 负责一次性 Effect,Hilt 负责依赖装配,Repository 负责数据边界。它们组合起来,形成的是一套可读、可测、可演进的工程实践。
当然,MVI 也有代价:更多类型、更多样板、更多前期设计。不要为了模式而模式。真正值得使用 MVI 的地方,是那些状态复杂、事件复杂、副作用复杂、多人长期维护的页面。
如果只记住一句话,我希望是这一句:
Compose 让 UI 变成状态的函数,MVI 让状态变化变成可追踪的流程。
真正落地时,不必追求一次性把所有页面都改成完美形态。可以先从最痛的页面开始:那些经常出现重复刷新、重复跳转、状态互相覆盖、返回后行为不确定的页面,最适合用 MVI 重新梳理。把输入、状态和副作用列清楚,本身就是一次架构复盘。等团队感受到排障和协作收益,再把模板推广到更多页面,迁移会自然很多。
也不要忘记保留工程判断。MVI 是工具,不是目的;Compose 是表达 UI 的方式,不是业务复杂度的遮羞布。最终目标始终是让页面行为更稳定,让代码更容易修改,让测试更容易覆盖,让团队在需求变化面前更有底气。
当这些目标成立时,架构才真正产生价值;如果只是换了一批名词,却没有让状态更清楚、边界更稳定、问题更好查,那就应该及时回头调整。好的架构最终会让日常开发更踏实,而不是让每次改动都变成对模式本身的迁就,也让团队能持续演进,并保留清晰的判断力。