实战:构建一个完整的MVI架构计数器应用

好了,终于到了动手环节。前面我们聊了那么多密封类、内联类的理论,说实话,不写点真东西,总觉得心里不踏实。这一章,我们就用MVI架构,从零搭一个计数器应用。

你可能会想:「计数器?太简单了吧?」嗯,我当年也这么想。但真正动手时才发现,MVI的闭环设计、状态管理、副作用处理,每一个环节都有讲究。说白了,计数器虽小,五脏俱全。

项目结构概览

我个人习惯把MVI分成三层:View(界面层)、Intent(意图层)、State(状态层)。再加一个Effect处理副作用。这样结构清晰,后期维护也省心。

核心思想:用户操作 → Intent → Reducer → State → UI渲染。单向数据流,没有乱七八糟的双向绑定。

先看整体架构图:

MVI计数器架构图 用户 Intent Reducer State UI Effect

第一步:定义状态与意图

先写密封类。为什么用密封类?因为状态是有限的、确定的。你想想看,计数器无非就几种状态:正常显示、加载中、出错。用密封类可以穷举所有情况,编译器还能帮你检查。

// 状态密封类
sealed class CounterState {
    object Idle : CounterState()
    data class Counting(val count: Int) : CounterState()
    data class Error(val message: String) : CounterState()
}

// 意图密封类
sealed class CounterIntent {
    object Increment : CounterIntent()
    object Decrement : CounterIntent()
    object Reset : CounterIntent()
    data class SetValue(val value: Int) : CounterIntent()
}

这里有个细节:Idleobject,因为它是单例;Countingdata class,因为需要携带计数值。我在项目中见过有人把所有状态都写成data class,其实没必要——没有数据的状态,用object更省内存。

第二步:Reducer——核心逻辑

Reducer就是一个纯函数。给它当前状态和意图,它返回新状态。没有副作用,没有网络请求,干干净净。

fun counterReducer(state: CounterState, intent: CounterIntent): CounterState {
    return when (intent) {
        is CounterIntent.Increment -> {
            val current = (state as? CounterState.Counting)?.count ?: 0
            CounterState.Counting(current + 1)
        }
        is CounterIntent.Decrement -> {
            val current = (state as? CounterState.Counting)?.count ?: 0
            if (current <= 0) {
                CounterState.Error("计数不能为负")
            } else {
                CounterState.Counting(current - 1)
            }
        }
        is CounterIntent.Reset -> CounterState.Counting(0)
        is CounterIntent.SetValue -> {
            if (intent.value < 0) {
                CounterState.Error("不能设为负数")
            } else {
                CounterState.Counting(intent.value)
            }
        }
    }
}

注意看Decrement的处理。我曾经在项目里没做边界检查,结果用户狂点减号,计数变成负数,UI直接崩了。从那以后,我养成了「任何状态变更都要做合法性校验」的习惯。

小技巧:Reducer里不要写任何异步代码。如果你需要网络请求或数据库操作,请交给Effect层处理。

第三步:ViewModel——状态持有者

ViewModel负责持有状态、接收意图、触发Reducer、处理副作用。这里我们用StateFlow来暴露状态,用Channel来发送一次性事件(比如Toast提示)。

class CounterViewModel : ViewModel() {
    private val _state = MutableStateFlow<CounterState>(CounterState.Idle)
    val state: StateFlow<CounterState> = _state.asStateFlow()

    private val _effect = Channel<CounterEffect>(Channel.BUFFERED)
    val effect: Flow<CounterEffect> = _effect.receiveAsFlow()

    fun processIntent(intent: CounterIntent) {
        val newState = counterReducer(_state.value, intent)
        _state.value = newState

        // 处理副作用
        if (newState is CounterState.Error) {
            viewModelScope.launch {
                _effect.send(CounterEffect.ShowToast(newState.message))
            }
        }
    }
}

sealed class CounterEffect {
    data class ShowToast(val message: String) : CounterEffect()
}

这里有个坑:Channel的容量设置。我一开始用Channel.RENDEZVOUS,结果快速点击时事件丢失了。后来改成Channel.BUFFERED才解决。你想想看,用户连续点击时,每个点击都应该触发对应的反馈,不能丢。

第四步:UI层——Compose实现

UI层就简单了。观察状态,渲染界面。用户点击时发送意图。

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

    // 处理副作用
    LaunchedEffect(Unit) {
        viewModel.effect.collect { effect ->
            when (effect) {
                is CounterEffect.ShowToast -> {
                    Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show()
                }
            }
        }
    }

    Column(
        modifier = Modifier.fillMaxSize().padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        when (state) {
            is CounterState.Idle -> {
                Text("准备就绪", fontSize = 24.sp)
                Button(onClick = { viewModel.processIntent(CounterIntent.Reset) }) {
                    Text("开始计数")
                }
            }
            is CounterState.Counting -> {
                val count = (state as CounterState.Counting).count
                Text("计数: $count", fontSize = 48.sp)
                Row {
                    Button(onClick = { viewModel.processIntent(CounterIntent.Increment) }) {
                        Text("+")
                    }
                    Spacer(modifier = Modifier.width(16.dp))
                    Button(onClick = { viewModel.processIntent(CounterIntent.Decrement) }) {
                        Text("-")
                    }
                }
                Button(onClick = { viewModel.processIntent(CounterIntent.Reset) }) {
                    Text("重置")
                }
            }
            is CounterState.Error -> {
                val error = (state as CounterState.Error).message
                Text("出错了: $error", color = Color.Red)
                Button(onClick = { viewModel.processIntent(CounterIntent.Reset) }) {
                    Text("重置")
                }
            }
        }
    }
}

看到when表达式了吗?因为CounterState是密封类,编译器知道所有分支都覆盖了。如果你漏掉一个分支,代码根本编译不过。这就是密封类在MVI中的最大价值——编译期安全

第五步:内联类的妙用

最后,我们给计数器加一个内联类,用来包装计数值。为什么?因为原始Int太容易混淆了。万一有人把「步长」和「计数值」搞混了呢?

@JvmInline
value class Count(val value: Int) {
    init {
        require(value >= 0) { "计数不能为负" }
    }

    operator fun plus(other: Int): Count = Count(value + other)
    operator fun minus(other: Int): Count = Count(value - other)
}

// 在State中使用
data class Counting(val count: Count) : CounterState()

内联类在编译时会被替换成原始类型,所以没有额外内存开销。但它提供了类型安全——你不能把一个StepSize赋值给Count。我在重构一个老项目时,就因为「值类型混乱」导致过一个线上bug,后来用内联类彻底解决了。

注意:内联类的init块会在每次构造时执行。如果你在循环中大量创建内联类,可能会有性能损耗。不过对于计数器这种场景,完全不用担心。

完整流程回顾

我们来走一遍完整流程:

  1. 用户点击「+」按钮
  2. UI层发送CounterIntent.Increment
  3. ViewModel调用counterReducer,生成新状态Counting(Count(1))
  4. 状态通过StateFlow下发到UI层
  5. Compose重组,显示「计数: 1」

整个过程是单向的、可预测的。没有数据突变,没有隐式状态修改。你想想看,如果以后要加「撤销」功能,只需要保存历史状态列表,然后回退就行了——因为每个状态都是不可变的。

组件 职责 关键点
CounterState 定义所有可能的状态 密封类,穷举所有分支
CounterIntent 定义用户的所有操作 密封类,每个操作一个子类
counterReducer 纯函数,状态转换 无副作用,可测试
CounterViewModel 持有状态,处理副作用 StateFlow + Channel
CounterEffect 一次性事件(Toast等) 密封类,与状态分离
Count 类型安全的计数值 内联类,零开销

说实话,这个计数器虽然简单,但MVI的核心思想全在里面了。你把这个吃透了,以后写任何复杂页面——购物车、表单、聊天列表——都可以套用这个模式。区别只是状态更复杂、意图更多、Reducer逻辑更重,但骨架是一样的。

嗯,动手试试吧。把代码敲一遍,然后试着加一个「步长设置」功能。你会发现,MVI的扩展性真的很好——加一个新意图,加一个状态分支,改一下Reducer,完事。不会牵一发而动全身。


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