用 “奶茶店订单系统” 讲懂 MVI 架构

MVI 的 "单向数据流" 和 "状态驱动 UI",跟奶茶店从 "顾客点单→前台处理→制作→反馈" 的流程几乎一模一样,用这个故事讲,小白也能秒懂。

一、先搞懂:MVI 到底是什么?(奶茶店故事版)

MVI 全称Model-View-Intent ,核心是 "单向数据流 " 和 "UI 完全由状态驱动"。我们用 "奶茶店" 对应 MVI 的 4 个核心角色:

MVI 组件 奶茶店角色 核心职责
View 顾客 1. 提出需求(比如 "要一杯三分糖珍珠奶茶");2. 接收结果(比如 "订单已制作完成")并做出反应(拿到奶茶)
Intent 顾客的 "点单需求" 封装 View 的所有用户行为(点单、加配料、取消订单),是 View 发给 ViewModel 的 "指令"
ViewModel 奶茶店前台 1. 接收顾客的需求(Intent);2. 协调制作间(数据层)处理;3. 把 "订单状态"(比如 "制作中""已完成")实时告诉顾客
Model 订单数据 + 订单状态 1. 状态(State) :订单当前的情况(初始、加载中、成功、失败);2. 数据(Data) :订单详情(奶茶类型、配料、价格)

核心逻辑一句话:顾客(View)只提需求(Intent),不关心制作;前台(ViewModel)只处理和同步状态,不做具体制作;制作间(Repository)只做奶茶,不跟顾客直接沟通------ 全程单向流动,不会乱。

二、代码实现:手把手写一个 "奶茶订单 APP"(XML 版)

我们就实现一个简单功能:用户输入奶茶类型 + 配料,点击 "提交订单",APP 显示 "加载中→订单成功 / 失败",点击 "取消订单" 则重置状态。

1. 第一步:定义 "顾客的需求"------Intent(密封类)

顾客的需求是固定的(提交订单、取消订单),所以用密封类(Sealed Class)定义所有可能的 Intent,避免乱传无效需求。

kotlin 复制代码
// 对应"顾客的所有可能需求"
sealed class OrderIntent {
    // 提交订单:携带奶茶类型和配料(需求的参数)
    data class PlaceOrder(val milkTeaType: String, val topping: String) : OrderIntent()
    // 取消订单:无参数
    object CancelOrder : OrderIntent()
}

2. 第二步:定义 "订单状态"------State(密封类)

订单的状态也是固定的(初始、加载中、成功、失败),用密封类定义,这样 View 能 "精准响应每一种状态"(比如 "加载中" 显示进度条,"成功" 显示订单详情)。

kotlin 复制代码
// 对应"订单的所有可能状态"
sealed class OrderState {
    // 初始状态(刚打开APP,还没点单)
    object Initial : OrderState()
    // 加载中(提交订单后,制作间正在做)
    object Loading : OrderState()
    // 成功(订单完成,携带订单数据)
    data class Success(val orderData: OrderData) : OrderState()
    // 失败(比如没珍珠了,携带错误信息)
    data class Error(val errorMsg: String) : OrderState()
}

// 对应"订单详情数据"(Model的一部分)
data class OrderData(
    val orderId: String,       // 订单号
    val milkTeaType: String,  // 奶茶类型
    val topping: String,      // 配料
    val status: String        // 订单状态描述
)

3. 第三步:实现 "奶茶前台"------ViewModel

ViewModel 是 MVI 的核心,负责 "接收 Intent→调用数据层→更新 State"。这里用StateFlow(状态流)来同步 State,因为它能 "实时向 View 推送最新状态"。

kotlin 复制代码
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.util.UUID

class MilkTeaViewModel : ViewModel() {
    // 1. 私有可变StateFlow(前台自己管理状态,不允许外部直接改)
    private val _orderState = MutableStateFlow<OrderState>(OrderState.Initial)
    // 2. 对外暴露不可变StateFlow(View只能"观察",不能"修改")
    val orderState: StateFlow<OrderState> = _orderState.asStateFlow()

    // 3. 接收View发送的Intent(处理顾客需求的入口)
    fun handleIntent(intent: OrderIntent) {
        when (intent) {
            // 处理"提交订单"需求
            is OrderIntent.PlaceOrder -> placeOrder(intent.milkTeaType, intent.topping)
            // 处理"取消订单"需求
            OrderIntent.CancelOrder -> cancelOrder()
        }
    }

    // 4. 调用"制作间"(Repository)处理订单
    private fun placeOrder(milkTeaType: String, topping: String) {
        viewModelScope.launch {
            // 第一步:先发送"加载中"状态(告诉顾客:正在做,稍等)
            _orderState.value = OrderState.Loading

            try {
                // 第二步:调用制作间做奶茶(模拟网络/本地数据请求,延迟2秒)
                val orderData = MilkTeaRepository.submitOrder(milkTeaType, topping)
                // 第三步:发送"成功"状态(告诉顾客:做好了,拿奶茶)
                _orderState.value = OrderState.Success(orderData)
            } catch (e: Exception) {
                // 第四步:如果失败,发送"错误"状态(告诉顾客:没珍珠了)
                _orderState.value = OrderState.Error(e.message ?: "订单提交失败,请重试")
            }
        }
    }

    // 5. 取消订单(重置为初始状态)
    private fun cancelOrder() {
        _orderState.value = OrderState.Initial
    }
}

// 模拟"奶茶制作间"(Repository:数据层,负责实际的业务逻辑)
object MilkTeaRepository {
    // 提交订单(模拟异步请求)
    suspend fun submitOrder(milkTeaType: String, topping: String): OrderData {
        delay(2000) // 模拟制作时间(2秒)
        
        // 模拟偶尔失败(比如配料没了)
        if (topping == "芋圆") {
            throw Exception("抱歉,芋圆卖完了,换个配料吧~")
        }

        // 制作成功,返回订单数据(订单号用UUID随机生成)
        return OrderData(
            orderId = UUID.randomUUID().toString().substring(0, 8),
            milkTeaType = milkTeaType,
            topping = topping,
            status = "制作完成,请到取餐口领取"
        )
    }
}

4. 第四步:实现 "顾客界面"------View(Activity+XML)

View 的职责只有两个:发送 Intent (点击按钮)和观察 State 并更新 UI(根据状态显示进度条 / 订单 / 错误信息)。

(1)XML 布局(res/layout/activity_milk_tea.xml)

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:padding="20dp">

    <!-- 1. 输入奶茶类型 -->
    <EditText
        android:id="@+id/etMilkTeaType"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="输入奶茶类型(如:珍珠奶茶)"
        android:inputType="text"/>

    <!-- 2. 输入配料 -->
    <EditText
        android:id="@+id/etTopping"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="输入配料(如:珍珠/椰果,试下芋圆会失败)"
        android:inputType="text"
        android:layout_marginTop="10dp"/>

    <!-- 3. 按钮:提交/取消订单 -->
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        android:layout_marginTop="20dp">

        <Button
            android:id="@+id/btnPlaceOrder"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:text="提交订单"
            android:layout_marginEnd="10dp"/>

        <Button
            android:id="@+id/btnCancelOrder"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:text="取消订单"/>
    </LinearLayout>

    <!-- 4. 加载中进度条(初始隐藏) -->
    <ProgressBar
        android:id="@+id/progressBar"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:layout_marginTop="20dp"
        android:visibility="gone"/>

    <!-- 5. 显示订单结果(状态/详情/错误) -->
    <TextView
        android:id="@+id/tvOrderResult"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="20dp"
        android:textSize="16sp"/>

</LinearLayout>

(2)Activity 代码(MilkTeaActivity.kt)

kotlin 复制代码
import android.os.Bundle
import android.view.View
import android.widget.Button
import android.widget.EditText
import android.widget.ProgressBar
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.ViewModelProvider
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import androidx.lifecycle.lifecycleScope

class MilkTeaActivity : AppCompatActivity() {
    // 1. 绑定控件
    private lateinit var etMilkTeaType: EditText
    private lateinit var etTopping: EditText
    private lateinit var btnPlaceOrder: Button
    private lateinit var btnCancelOrder: Button
    private lateinit var progressBar: ProgressBar
    private lateinit var tvOrderResult: TextView

    // 2. 初始化ViewModel
    private lateinit var viewModel: MilkTeaViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_milk_tea)

        // 3. 绑定控件
        initViews()

        // 4. 初始化ViewModel
        viewModel = ViewModelProvider(this)[MilkTeaViewModel::class.java]

        // 5. 发送Intent(按钮点击事件)
        initClickListeners()

        // 6. 观察State,更新UI(核心:View只被动接收状态)
        observeOrderState()
    }

    private fun initViews() {
        etMilkTeaType = findViewById(R.id.etMilkTeaType)
        etTopping = findViewById(R.id.etTopping)
        btnPlaceOrder = findViewById(R.id.btnPlaceOrder)
        btnCancelOrder = findViewById(R.id.btnCancelOrder)
        progressBar = findViewById(R.id.progressBar)
        tvOrderResult = findViewById(R.id.tvOrderResult)
    }

    private fun initClickListeners() {
        // 提交订单:发送PlaceOrder Intent(携带输入的参数)
        btnPlaceOrder.setOnClickListener {
            val milkTeaType = etMilkTeaType.text.toString()
            val topping = etTopping.text.toString()
            if (milkTeaType.isBlank() || topping.isBlank()) {
                tvOrderResult.text = "请输入奶茶类型和配料!"
                return@setOnClickListener
            }
            viewModel.handleIntent(OrderIntent.PlaceOrder(milkTeaType, topping))
        }

        // 取消订单:发送CancelOrder Intent
        btnCancelOrder.setOnClickListener {
            viewModel.handleIntent(OrderIntent.CancelOrder)
        }
    }

    private fun observeOrderState() {
        // 用lifecycleScope观察StateFlow(自动绑定生命周期,避免内存泄漏)
        lifecycleScope.launch {
            viewModel.orderState.collect { state ->
                // 根据不同状态更新UI(View的核心工作)
                when (state) {
                    OrderState.Initial -> {
                        // 初始状态:隐藏进度条,清空结果,启用按钮
                        progressBar.visibility = View.GONE
                        tvOrderResult.text = ""
                        btnPlaceOrder.isEnabled = true
                    }
                    OrderState.Loading -> {
                        // 加载中:显示进度条,清空结果,禁用按钮(防止重复点击)
                        progressBar.visibility = View.VISIBLE
                        tvOrderResult.text = ""
                        btnPlaceOrder.isEnabled = false
                    }
                    is OrderState.Success -> {
                        // 成功:隐藏进度条,显示订单详情,启用按钮
                        progressBar.visibility = View.GONE
                        val order = state.orderData
                        tvOrderResult.text = "订单成功!\n" +
                                "订单号:${order.orderId}\n" +
                                "奶茶:${order.milkTeaType}\n" +
                                "配料:${order.topping}\n" +
                                "状态:${order.status}"
                        btnPlaceOrder.isEnabled = true
                    }
                    is OrderState.Error -> {
                        // 失败:隐藏进度条,显示错误信息,启用按钮
                        progressBar.visibility = View.GONE
                        tvOrderResult.text = "订单失败:${state.errorMsg}"
                        btnPlaceOrder.isEnabled = true
                    }
                }
            }
        }
    }
}

三、时序图:看清楚 MVI 的 "单向数据流"

用时序图能直观看到 "数据从 View 出发,最终回到 View" 的完整流程,全程单向,没有反向依赖。

DataSource (模拟数据)Repository (制作间)ViewModelView (Activity)DataSource (模拟数据)Repository (制作间)ViewModelView (Activity)用户输入后点击按钮,发送需求接收Intent后,先同步"加载中"状态状态更新后,View自动响应观察orderState(StateFlow)发送初始状态Initial发送OrderIntent.PlaceOrder(珍珠奶茶, 珍珠)发送状态Loading显示进度条,禁用按钮调用submitOrder(珍珠奶茶, 珍珠)模拟请求(延迟2秒)返回订单数据返回OrderData发送状态Success(OrderData)隐藏进度条,显示订单详情发送OrderIntent.CancelOrder发送状态Initial清空结果,恢复初始界面

四、MVI 的核心优势(为什么要学它?)

  1. 调试超简单:单向数据流就像 "奶茶订单流程",出问题时能顺着 "View→ViewModel→Repository" 一步步查,比如 "订单没显示",可能是 ViewModel 没发 Success 状态,也可能是 Repository 抛了异常。
  2. 避免状态不一致 - 单一数据源 UI 绝对统一:UI 完全由 State 驱动,只要 State 相同,UI 就一定相同。比如 "Loading 状态" 无论在什么场景下,都是显示进度条 + 禁用按钮,不会出现 "有的地方 Loading 显示进度条,有的地方显示文字" 的混乱。
  3. 解耦彻底:View 只做 "发送 Intent + 更新 UI",ViewModel 只做 "处理 Intent + 同步 State",Repository 只做 "业务逻辑",各司其职,改一个地方不会影响其他地方(比如改制作流程,不用动前台和顾客界面)。
  4. 线程安全 - 不可变对象天然线程安全
  5. 易于测试 - Intent → State 的映射关系清晰
相关推荐
LiuYaoheng4 小时前
【Android】布局优化:include、merge、ViewStub的使用及注意事项
android·java
Kapaseker4 小时前
Kotlin Flow 的 emit 和 tryEmit 有什么区别
android·kotlin
好好学习啊天天向上5 小时前
Android Studio 撕开安卓手机投屏
android·智能手机·android studio
Android-Flutter6 小时前
android - JPG图片转换HDR图片,heic格式
android
诸神黄昏EX13 小时前
Android Build系列专题【篇四:编译相关语法】
android
雨白16 小时前
优雅地处理协程:取消机制深度剖析
android·kotlin
leon_zeng016 小时前
更改 Android 应用 ID (ApplicationId) 后遭遇记
android·发布
2501_9160074717 小时前
iOS 混淆工具链实战,多工具组合完成 IPA 混淆与加固(iOS混淆|IPA加固|无源码混淆|App 防反编译)
android·ios·小程序·https·uni-app·iphone·webview
Jeled19 小时前
Retrofit 与 OkHttp 全面解析与实战使用(含封装示例)
android·okhttp·android studio·retrofit