86、MVI架构入门:Intent、State、Effect、单向数据流、与Compose结合
MVI,全称是Model-View-Intent。说实话,我第一次看到这个缩写时,脑子里蹦出来的是“Model-View-Intent?这不就是MVP换了个马甲吗?”后来深入用了一段时间才发现,它俩完全是两码事。
MVI的核心思想,说白了就是单向数据流。你想想看,在传统的MVP或MVVM里,数据流经常是双向的——ViewModel改了数据,View刷新;View触发事件,ViewModel又改数据。来回倒腾,时间长了就容易出问题。MVI则强制规定:数据只能朝一个方向流动。
我个人习惯把MVI理解为“状态机 + 事件驱动”。每个页面都有一个不可变的状态对象,用户操作产生Intent,Reducer根据Intent计算出新状态,View根据新状态重新渲染。就这么简单。
MVI的三个核心概念
咱们先拆开来看。MVI里有三个关键角色:Intent、State、Effect。
| 角色 | 作用 | 类比 |
|---|---|---|
| Intent | 用户意图或系统事件 | 你按下遥控器的按钮 |
| State | 当前UI的快照(不可变) | 电视屏幕当前显示的画面 |
| Effect | 一次性副作用(如Toast、导航) | 遥控器震动反馈 |
Intent 不是Android里的那个Intent。它只是一个数据类,用来描述“用户想干什么”。比如用户点击了登录按钮,那就是一个 LoginIntent.Submit。
State 是整个页面的状态快照。注意,它是不可变的。每次状态变化,都是创建一个新的State对象。这样做的好处是——你永远知道当前UI处于什么状态,不会出现“ViewModel里某个字段被偷偷改了”的情况。
Effect 是我觉得MVI最妙的设计。有些操作不适合放在State里,比如弹Toast、跳转页面、播放音效。这些是一次性事件,State管不了。Effect就是专门处理这些的。
核心原则:State决定UI长什么样,Effect决定UI做什么事。
单向数据流的工作流程
我画了一张图,帮你理解整个流程:
流程是这样的:
- 用户操作产生 Intent
- Reducer 接收当前State和Intent,计算出新State
- 新State 驱动UI重新渲染
- 如果有一次性操作,通过 Effect 处理
你看,数据永远是从上往下流,没有回头路。这就是单向数据流的精髓。
与Compose结合:实战代码
光说不练假把式。咱们直接上代码。假设我们要做一个简单的计数器页面。
先定义State:
data class CounterState(
val count: Int = 0,
val isLoading: Boolean = false
)
再定义Intent:
sealed class CounterIntent {
object Increment : CounterIntent()
object Decrement : CounterIntent()
object Reset : CounterIntent()
}
然后是Effect:
sealed class CounterEffect {
data class ShowToast(val message: String) : CounterEffect()
object Vibrate : CounterEffect()
}
接下来是ViewModel。这里我用了Compose的 mutableStateOf 来管理状态:
class CounterViewModel : ViewModel() {
private val _state = mutableStateOf(CounterState())
val state: State<CounterState> = _state
private val _effect = Channel<CounterEffect>(Channel.BUFFERED)
val effect = _effect.receiveAsFlow()
fun processIntent(intent: CounterIntent) {
when (intent) {
CounterIntent.Increment -> {
_state.value = _state.value.copy(
count = _state.value.count + 1
)
_effect.trySend(CounterEffect.Vibrate)
}
CounterIntent.Decrement -> {
if (_state.value.count > 0) {
_state.value = _state.value.copy(
count = _state.value.count - 1
)
} else {
_effect.trySend(
CounterEffect.ShowToast("不能再减了")
)
}
}
CounterIntent.Reset -> {
_state.value = CounterState()
_effect.trySend(
CounterEffect.ShowToast("已重置")
)
}
}
}
}
个人经验:Effect用Channel而不是SharedFlow,是因为Channel能保证每个事件只被消费一次。SharedFlow如果collector挂掉了,事件就丢了。我在项目中踩过这个坑,后来全换成了Channel。
最后是Compose UI:
@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
val state by viewModel.state.collectAsState()
LaunchedEffect(Unit) {
viewModel.effect.collect { effect ->
when (effect) {
is CounterEffect.ShowToast -> {
Toast.makeText(
LocalContext.current,
effect.message,
Toast.LENGTH_SHORT
).show()
}
is CounterEffect.Vibrate -> {
// 震动逻辑
}
}
}
}
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "计数: ${state.count}",
fontSize = 32.sp
)
Spacer(modifier = Modifier.height(24.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Button(onClick = {
viewModel.processIntent(CounterIntent.Decrement)
}) {
Text("-")
}
Button(onClick = {
viewModel.processIntent(CounterIntent.Increment)
}) {
Text("+")
}
}
Spacer(modifier = Modifier.height(16.dp))
OutlinedButton(onClick = {
viewModel.processIntent(CounterIntent.Reset)
}) {
Text("重置")
}
}
}
嗯,这里要注意一点:LaunchedEffect 里收集Effect时,如果页面被销毁了,协程会自动取消,不会造成内存泄漏。Compose在这方面做得确实不错。
避坑指南
我曾经在一个项目里把State定义成了可变对象,结果多个地方同时修改,UI渲染乱套了。排查了两天才发现是State没有用 copy() 创建新对象。所以记住:State必须不可变。
还有一次,我把网络请求的Loading状态放在了Effect里。结果每次请求完成,Loading状态一闪而过,UI根本来不及显示加载动画。后来才意识到:Loading是UI状态,应该放在State里;请求成功或失败的消息提示,才应该放在Effect里。
警告:不要把网络请求、数据库操作写在Reducer里。Reducer应该是纯函数,只做状态转换。副作用请放在ViewModel的processIntent方法里,或者用UseCase处理。
MVI的优势与适用场景
MVI最大的好处是可预测性。你想想看,每个状态变化都有迹可循——用户发了什么Intent,Reducer怎么处理的,新状态是什么。调试的时候,你只需要看State的变化历史,就能定位问题。
但MVI也有缺点。代码量会多一些,每个页面都要定义State、Intent、Effect三个类。对于简单页面,确实有点杀鸡用牛刀。
我个人建议:
- 复杂页面(表单、多步骤流程、实时数据)→ 用MVI
- 简单页面(纯展示、一两个按钮)→ 用MVVM就够了
说白了,工具是为人服务的。别为了用MVI而用MVI,合适才是最好的。
最后说一句,MVI和Compose真的是天生一对。Compose的声明式UI + MVI的单向数据流,写起来特别顺畅。你试试就知道了。
公众号:蓝海资料掘金营,微信deep3321