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做什么事。

单向数据流的工作流程

我画了一张图,帮你理解整个流程:

User Intent Reducer State 渲染UI Effect 操作 处理 新状态

流程是这样的:

  1. 用户操作产生 Intent
  2. Reducer 接收当前State和Intent,计算出新State
  3. 新State 驱动UI重新渲染
  4. 如果有一次性操作,通过 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