MVI vs MVVM 讨论记录
我对 MVI 一直不理解,所以和 Claude Code 有了以下对话,不纠结是黑是白,只加深两种模式之间的理解。
第一轮:MVVM 和 MVI 有什么本质的区别
问题
我想知道在 Android 开发中:MVVM 和 MVI 有什么本质的区别
回答
MVVM vs MVI 的本质区别
两者都是基于单向数据流的架构,但在状态管理粒度上有根本差异:
MVVM(Model-View-ViewModel)
- 状态分散 :ViewModel 中有多个独立的
StateFlow/LiveData - 事件处理 :View 直接调用 ViewModel 的方法(
viewModel.login()) - 状态更新:各字段独立更新,可能出现状态不一致
kotlin
class LoginViewModel : ViewModel() {
val username = MutableStateFlow("")
val loading = MutableStateFlow(false)
val error = MutableStateFlow<String?>(null)
fun login() { ... }
}
MVI(Model-View-Intent)
- 状态集中:单一不可变 State 对象描述整个屏幕状态
- 事件处理:View 发送 Intent(密封类),ViewModel 通过 Reducer 处理
- 状态更新:每次生成全新的 State,保证原子性一致
kotlin
data class LoginState(val username: String = "", val loading: Boolean = false, val error: String? = null)
sealed class LoginIntent {
data class TypeUsername(val text: String) : LoginIntent()
object Submit : LoginIntent()
}
// Reducer: (State, Intent) -> State
本质差异总结
| 维度 | MVVM | MVI |
|---|---|---|
| 状态 | 多个可变流 | 单一不可变对象 |
| 事件 | 方法调用 | Intent 密封类 |
| 可预测性 | 中等(状态可能不同步) | 高(状态原子更新) |
| 样板代码 | 少 | 多 |
| 调试 | 需要跟踪多个流 | 可回放 Intent 序列 |
实际选择:简单页面用 MVVM 就够了;复杂交互(多状态联动、需要时间旅行调试)用 MVI 更安全。Compose 本身的单向数据流天然更亲近 MVI。
第二轮:直接调用函数 vs 发射 Intent 间接调用
问题
深入这个话题:尤其关于事件:直接调用函数,和发射 Intent 间接调用 有什么本质区别和改变吗?是不是过度设计
回答
直接调用 vs Intent 分发:本质区别在哪?
先说结论:大多数场景下,Intent 确实是过度设计
两者在运行时的执行路径几乎一样:
less
// MVVM:直接调用
button.onClick { viewModel.login(username, password) }
// MVI:发送 Intent → reduce
button.onClick { viewModel.dispatch(LoginIntent.Submit(username, password)) }
// 然后内部: when(intent) { is Submit -> doLogin(intent.username, intent.password) }
最终都是调同一个函数。Intent 只是多了一层间接。
Intent 真正带来的区别
1. 可序列化/可记录
Intent 是数据类,可以被记录、回放、序列化:
less
// 可以做到:记录用户操作序列
val history = listOf(
TypeUsername("john"),
TypePassword("123"),
Submit
)
// 回放调试、上报 crash 现场
直接方法调用做不到这一点------调用完就没了,没有痕迹。
2. 统一入口 = 统一拦截
所有操作经过同一个 dispatch,可以在一处加日志、节流、权限校验:
kotlin
fun dispatch(intent: Intent) {
log(intent) // 统一日志
if (rateLimiter.check()) return // 统一节流
reduce(currentState, intent)
}
MVVM 中每个方法各管各的,想统一拦截要靠 AOP 或手动在每个方法里加。
3. 强制穷举
when(intent) 配合 sealed class,编译器强制你处理每种情况。MVVM 的方法则是"有就调、没有就忘了"。
什么时候不值得
如果你不需要:
- 操作回放/时间旅行调试
- 统一拦截层
- 严格的状态机建模
那 Intent 层就是纯粹的样板代码。一个表单页面有 save()、validate()、clear() 三个方法,直接调用完全够了。把它们包成 Intent 密封类只是增加了跳转层级,没带来实际收益。
实际建议
| 场景 | 选择 |
|---|---|
| 普通 CRUD 页面 | 直接方法调用 |
| 复杂多步流程(如支付、注册向导) | Intent 有价值 |
| 需要操作日志/crash 还原 | Intent 有价值 |
| 团队很大、需要严格规范 | Intent 降低沟通成本 |
不要因为"MVI 更现代"就选它。 架构服务于问题,不是反过来。
第三轮:在每个函数中添加日志不也能实现相同效果吗
问题
还是无法理解!!! 在每个函数中添加日志不是也能实现相同的效果吗
回答
你说得对,确实能实现相同效果。让我更诚实地分析这个问题。
逐条拆解:Intent 的"优势"是否站得住脚
统一日志 → 每个函数加也行
kotlin
// MVI 方式
fun dispatch(intent: Intent) {
log(intent) // 一处搞定
reduce(state, intent)
}
// MVVM 方式
fun login() { log("login"); ... }
fun register() { log("register"); ... }
fun logout() { log("logout"); ... }
差别只是"一处写 vs 多处写"。 如果你有 5 个方法,多写 5 行 log()。这不是架构差异,是代码量差异,而且很小。
操作回放 → 你真的需要吗?
99% 的 Android 应用从来不会用到"回放用户操作序列"这个功能。这是 Redux DevTools 带火的概念,在前端调试复杂 SPA 时有用,但移动端几乎没有落地场景。
编译器穷举 → 直接方法调用根本不存在遗漏问题
when(sealed class) 强制穷举是针对"分发"模式的。你直接写方法,压根就不存在漏处理的可能------方法写了就是处理了,没写就是没这个功能。
所以本质是什么?
Intent 模式的真正价值只有一个:把"做什么"和"怎么做"在代码结构上解耦。
kotlin
// Intent 定义了"做什么"(纯数据)
sealed class Intent {
object Login : Intent()
}
// Reducer 定义了"怎么做"(纯函数)
fun reduce(state: State, intent: Intent): State
这在状态极其复杂、多个 Intent 交叉影响同一份 State时有意义------因为所有状态变更都经过同一个 Reducer,你可以精确控制状态转换,避免竞争。
但对于"一个按钮调一个方法改几个字段"的场景,这种解耦就是无意义的抽象层。
最终判断
对大多数 Android 页面来说,MVI 的 Intent 模式就是过度设计。 它从 Redux 继承过来,而 Redux 的设计背景是浏览器中不可变状态管理------那是 JavaScript 没有原生响应式系统时的产物。Compose 本身的 State + StateFlow 已经解决了核心问题。
用 MVVM 直接调方法,简洁明了,没什么可心虚的。
第四轮:Reducer 真能控制异步状态吗?
问题
这在状态极其复杂、多个 Intent 交叉影响同一份 State时有意义------因为所有状态变更都经过同一个 Reducer,你可以精确控制状态转换,避免竞争。
我想知道你说的上面这句话是否成立???
比如 ViewModel 中函数大多数是请求数据,这已经是异步了,通过一个函数来控制,真能控制吗? 如果这样说:直接调用函数,虽然会有多处调用,但是调用都是在 Main 线程中,所以实际上这些调用虽然没有通过同一个函数控制,但是实际上还是有一个明确的先后顺序的
回答
你的质疑非常到位。
Reducer "精确控制状态转换"------这个说法的真实性
你说的完全正确:Main 线程本身就是串行的
kotlin
// MVVM:两个按钮点击
fun loadProfile() { _state.value = state.value.copy(loading = true); fetchProfile() }
fun loadOrders() { _state.value = state.value.copy(loading = true); fetchOrders() }
这两个调用都在 Main 线程,不可能同时执行。先点哪个就先执行哪个,不存在竞争。
MVI 的 Reducer 也控制不了异步
kotlin
fun reduce(state: State, intent: Intent): State = when (intent) {
is LoadProfile -> state.copy(loading = true) // 同步部分可控
is ProfileLoaded -> state.copy(profile = intent.data) // 但这个 intent 什么时候来?不可控
is LoadOrders -> state.copy(loading = true)
is OrdersLoaded -> state.copy(orders = intent.data)
}
ProfileLoaded 和 OrdersLoaded 哪个先回来,取决于网络,Reducer 管不了。所以"精确控制状态转换"在异步场景下是伪命题------无论 MVVM 还是 MVI,真正的难点(异步回调的时序)两者同样无法在结构上保证。
那 Reducer 到底在什么场景下有实际意义?
纯同步、多字段联动的状态机场景:
kotlin
// 比如一个复杂表单,字段之间有约束关系
fun reduce(state: FormState, intent: FormIntent): FormState = when (intent) {
is SelectCountry -> state.copy(
country = intent.country,
city = "", // 换国家要清城市
zipFormat = getFormat(intent.country) // 联动邮编格式
)
...
}
这里一个操作要同时改 3 个字段且有逻辑依赖。放在 Reducer 里能保证原子性------一次 copy 产出完整新状态。
但 MVVM 用一个方法也能做到:
kotlin
fun selectCountry(country: Country) {
_state.update {
it.copy(country = country, city = "", zipFormat = getFormat(country))
}
}
效果完全一样。 只是 MVI 用 sealed class 强制了这种写法,而 MVVM 靠开发者自觉。
总结
| 论点 | 是否成立 |
|---|---|
| "Reducer 能精确控制异步状态" | 不成立,异步时序两者都控制不了 |
| "Main 线程调用本身就有序" | 成立,你说得对 |
| "Reducer 保证同步状态原子性" | 成立,但 StateFlow.update {} 也能做到 |
| MVI 比 MVVM 更安全 | 在异步为主的 Android 开发中,差别微乎其微 |
我之前说的那句话不够严谨。MVI Reducer 在实际 Android 开发中的"控制优势"被大幅夸大了。
第五轮:MVI 集中 State 对 Compose 重组的影响
问题
继续聊 MVI 中的把状态 State 都集中到一个类中。这种方式是否是一种好的方式:假如我有两种状态 a,b ;MVI 中 state(a,b);MVVM 中两个状态是独立的。那来聊聊当其中一个状态a 发生变化时,对重组的影响。 a 实际用在 Composable A 中;b 实际用在 Composable B 中;在我的理解中,如果采用的MVVM,那么a变化时只会使 Composable A 重组,B不会重组;但是如果采用的MVI,因为整个 state 类都变了,而 Composable A B 都会受影响,都需要重组
回答
你的理解完全正确,这正是 MVI 单一 State 的一个实际缺陷。
MVI 集中 State 对 Compose 重组的影响
验证你的判断
kotlin
// MVI:单一 State
data class ScreenState(val a: String, val b: Int)
@Composable
fun Screen(state: ScreenState) {
ComposableA(state.a) // 读了整个 state
ComposableB(state.b) // 读了整个 state
}
当 a 变化 → ScreenState 是新对象 → Screen 重组 → A 和 B 都重新调用。
虽然 Compose 有 skip 优化(如果 ComposableB 的参数 state.b 没变,且参数是 stable 的,编译器可能跳过其内部执行),但 Screen 这层的重组是跑不掉的。
kotlin
// MVVM:独立 State
val a = stateFlow<String>()
val b = stateFlow<Int>()
@Composable
fun Screen(viewModel: VM) {
val a by viewModel.a.collectAsState()
val b by viewModel.b.collectAsState()
ComposableA(a)
ComposableB(b)
}
这里 a 变化 → 只有 a 的 State 对象变了 → Compose 精确知道只有读取 a 的作用域需要重组。ComposableB 完全不受影响。
但有一个关键细节
如果 MVI 这样写,可以缓解问题:
kotlin
@Composable
fun Screen(state: ScreenState) {
// 用 remember + derivedStateOf 或直接传子字段
ComposableA(state.a) // 只传子字段,不传整个 state
ComposableB(state.b)
}
因为 ComposableA(state.a) 中 state.a 是 String(Stable + equals 比较),如果值没变,Compose 编译器插件会生成 skip 逻辑。所以 B 的内部不会真正执行 ,但 Screen 级别的重组开销仍然存在。
实际影响有多大?
| 场景 | 影响 |
|---|---|
| State 字段少(<10个),子 Composable 参数都是基本类型/String | 几乎无感,skip 优化兜底 |
| State 字段多,子 Composable 接收 List/Map 等不稳定类型 | 明显劣化,每次都重组 |
| 高频更新(如拖拽坐标、动画帧) | 严重劣化,MVVM 独立 State 明显更优 |
结论
MVI 的单一 State 和 Compose 的精细化重组机制是天然矛盾的。 Compose 的重组优化依赖"精确知道哪个 State 变了",而 MVI 把所有东西塞进一个对象,等于主动放弃了这种精确性。
MVVM 的独立 StateFlow 在 Compose 中反而是更优的模式------它与 Compose 的响应式模型完美对齐。
第六轮:总结
问题
所以我可以理解成,MVI 两种策略都有弊端。不如用 MVVM
回答
基本可以这样理解,但加一个限定更准确:
在 Compose(包括 CMP)的场景下,MVVM 是更务实的选择。
MVI 的核心设计(单一 State + Intent 分发 + Reducer)源自 React/Redux 生态,那个环境没有 Compose 这种编译器级别的精细重组。搬到 Compose 后,它的"集中状态"反而与框架的优化机制冲突,而"Intent 分发"在大多数业务场景下又没有比直接调方法好多少。
不过也不用完全否定 MVI------如果团队已经在用且运转良好,强行切换的成本也不值得。架构选择没有绝对对错,只有适不适合当前的技术栈和团队规模。
对你个人或新项目来说,Compose + MVVM(ViewModel 暴露多个独立 StateFlow + 直接方法调用)是最简洁高效的组合。