23、架构模式:MVI 模式在 Compose 中的应用,单向数据流实践,Reducer 与 Store

聊到 Compose 的架构,绕不开 MVI。说实话,我最早接触 MVI 是在做前端 React 项目时,那时候叫 Redux。后来转到 Android 开发,发现这个模式在 Compose 里简直如鱼得水。为什么?因为 Compose 本身就是声明式 UI,天然适合单向数据流。

今天我们就来拆解 MVI 的三个核心概念:单向数据流ReducerStore。我会结合我在实际项目中的踩坑经验,帮你避开那些常见的坑。

什么是 MVI?

MVI 全称是 Model-View-Intent。你可能会问,这和 MVP、MVVM 有什么区别?

简单说:

  • Model:代表应用的状态(State),是一个不可变的数据对象。
  • View:负责渲染 UI,根据 State 显示内容。
  • Intent:不是 Android 的 Intent,而是用户的操作意图。比如点击按钮、输入文字,都算 Intent。

核心思想就一句话:数据单向流动,状态集中管理

单向数据流示意图:

用户操作 → Intent → Reducer → 新 State → View 更新

整个过程是闭环的,没有反向修改数据的路径。

下面我用一张 SVG 图来展示这个流程,你一看就明白。

用户操作 (Intent) Reducer (纯函数) 新 State (不可变) View 更新 (Compose)

你看,数据从用户操作出发,经过 Reducer 处理,生成新 State,然后 View 自动刷新。整个过程没有回头路,这就是单向数据流的精髓。

为什么 Compose 适合 MVI?

Compose 的核心理念是「状态驱动 UI」。你想想看,Compose 函数本身就是根据 State 来渲染界面的。State 变了,UI 自动重组。这和 MVI 的「State → View」完全吻合。

我在项目中遇到过一个问题:用 MVVM 时,ViewModel 里经常出现多个 LiveData,互相依赖,调试起来很头疼。后来切换到 MVI,所有状态集中到一个 State 对象里,问题一下子就清晰了。

我的建议:如果你的 Compose 项目有 5 个以上的页面,或者涉及复杂的状态交互,MVI 会是一个很好的选择。它让状态变化变得可预测、可追踪。

核心概念拆解

1. State(状态)

State 就是应用在某一时刻的快照。它必须是不可变的(immutable)。为什么?因为只有不可变,才能保证每次状态变化都是全新的对象,Compose 才能准确判断是否需要重组。

// 定义一个 State 数据类
data class CounterState(
    val count: Int = 0,
    val isLoading: Boolean = false,
    val errorMessage: String? = null
)

注意:所有字段都用 val,不要用 var。如果你需要修改,就创建一个新的对象。

2. Intent(意图)

Intent 是用户操作的抽象。它不是一个具体的类,而是一个 sealed class 或接口,用来描述所有可能的用户行为。

sealed class CounterIntent {
    object Increment : CounterIntent()
    object Decrement : CounterIntent()
    data class SetValue(val value: Int) : CounterIntent()
}

这样做的好处是:所有操作一目了然。你打开这个文件,就知道这个页面能做什么。

3. Reducer(归约器)

Reducer 是一个纯函数。它接收当前的 State 和一个 Intent,然后返回一个新的 State。纯函数的意思是:同样的输入,永远得到同样的输出,没有副作用。

fun counterReducer(state: CounterState, intent: CounterIntent): CounterState {
    return when (intent) {
        is CounterIntent.Increment -> state.copy(count = state.count + 1)
        is CounterIntent.Decrement -> state.copy(count = state.count - 1)
        is CounterIntent.SetValue -> state.copy(count = intent.value)
    }
}

这里用了 Kotlin 的 copy() 方法,它基于原对象创建一个新对象,只修改指定字段。这就是不可变性的典型用法。

注意:Reducer 里绝对不能做网络请求、数据库读写等操作。它只负责计算新状态。我曾经见过有人把网络请求写在 Reducer 里,结果状态混乱,调试了整整一天。

4. Store(存储)

Store 是 MVI 的核心容器。它持有当前的 State,并负责分发 Intent 给 Reducer,然后更新 State。在 Compose 中,我们通常用 ViewModel 来实现 Store。

class CounterViewModel : ViewModel() {
    // 使用 MutableStateFlow 来持有状态
    private val _state = MutableStateFlow(CounterState())
    val state: StateFlow<CounterState> = _state.asStateFlow()

    // 分发 Intent
    fun dispatch(intent: CounterIntent) {
        val currentState = _state.value
        val newState = counterReducer(currentState, intent)
        _state.value = newState
    }
}

在 Compose 中,我们这样使用:

@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
    val state by viewModel.state.collectAsState()

    Column(
        modifier = Modifier.fillMaxSize().padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text(text = "计数: ${state.count}", fontSize = 32.sp)
        Spacer(modifier = Modifier.height(16.dp))
        Row {
            Button(onClick = { viewModel.dispatch(CounterIntent.Decrement) }) {
                Text("-")
            }
            Spacer(modifier = Modifier.width(16.dp))
            Button(onClick = { viewModel.dispatch(CounterIntent.Increment) }) {
                Text("+")
            }
        }
    }
}

你看,整个流程非常清晰:用户点击按钮 → 发送 Intent → ViewModel 调用 Reducer → 更新 State → Compose 自动重组 UI。

实际项目中的经验

我在做一个电商 App 时,购物车页面用了 MVI。一开始觉得有点啰嗦,但后来发现调试 bug 时太爽了。为什么?因为每个状态变化都有迹可循。我只需要打印出每次 State 的变化,就能定位问题。

举个例子,用户添加商品到购物车:

sealed class CartIntent {
    data class AddItem(val productId: String, val quantity: Int) : CartIntent()
    data class RemoveItem(val productId: String) : CartIntent()
    object Checkout : CartIntent()
}

data class CartState(
    val items: List<CartItem> = emptyList(),
    val totalPrice: Double = 0.0,
    val isCheckingOut: Boolean = false
)

fun cartReducer(state: CartState, intent: CartIntent): CartState {
    return when (intent) {
        is CartIntent.AddItem -> {
            // 添加商品逻辑
            val newItems = state.items + CartItem(intent.productId, intent.quantity)
            state.copy(items = newItems, totalPrice = calculateTotal(newItems))
        }
        is CartIntent.RemoveItem -> {
            val newItems = state.items.filter { it.productId != intent.productId }
            state.copy(items = newItems, totalPrice = calculateTotal(newItems))
        }
        is CartIntent.Checkout -> {
            state.copy(isCheckingOut = true)
        }
    }
}

这里有个细节:calculateTotal 是一个纯函数,只依赖传入的列表计算总价。这样测试起来非常方便。

避坑指南:我曾经把网络请求放在 Reducer 里,结果导致状态更新和网络回调顺序错乱。正确的做法是:在 ViewModel 中处理副作用(网络请求、数据库等),然后通过新的 Intent 将结果送回 Reducer。

副作用处理

MVI 中,副作用(Side Effect)是指那些不在 Reducer 中处理的操作,比如网络请求、导航、弹 Toast 等。我习惯用 Kotlin 协程来处理:

class CartViewModel : ViewModel() {
    private val _state = MutableStateFlow(CartState())
    val state: StateFlow<CartState> = _state.value

    fun dispatch(intent: CartIntent) {
        when (intent) {
            is CartIntent.Checkout -> {
                // 先更新状态为加载中
                _state.value = cartReducer(_state.value, intent)
                // 发起网络请求
                viewModelScope.launch {
                    try {
                        val result = apiService.checkout(_state.value.items)
                        // 请求成功后,发送新的 Intent
                        _state.value = cartReducer(_state.value, CartIntent.CheckoutSuccess(result))
                    } catch (e: Exception) {
                        _state.value = cartReducer(_state.value, CartIntent.CheckoutError(e.message))
                    }
                }
            }
            else -> {
                _state.value = cartReducer(_state.value, intent)
            }
        }
    }
}

这样,副作用被隔离在 ViewModel 中,Reducer 始终保持纯净。测试时,你只需要测试 Reducer 的逻辑,不需要 mock 网络请求。

总结

MVI 模式在 Compose 中的优势很明显:

  • 可预测性:状态变化路径唯一,调试方便
  • 可测试性:Reducer 是纯函数,单元测试简单
  • 可维护性:所有操作集中管理,代码结构清晰

当然,MVI 也不是银弹。如果你的页面逻辑非常简单,比如只有一个开关按钮,用 MVI 反而显得冗余。我个人的经验是:当页面有 3 个以上交互点,或者状态之间有依赖关系时,MVI 的价值就体现出来了

嗯,关于 MVI 在 Compose 中的应用,今天就聊到这里。你可以在自己的项目中试试看,从一个小页面开始,慢慢体会单向数据流带来的好处。

公众号:蓝海资料掘金营,微信deep3321