App 架构演进:MVC → MVP → MVVM → MVI,一篇看懂

很多工程师对架构有一种误解:以为它只是"换名字"------把 Controller 叫 ViewModel,把 View 叫 Composable......确实只是命名游戏,但更核心的是责任边界和测试能力

今天我们用一个 TODO App 作为例子,从远古的 MVC 一直走到现代的 MVI,看清楚每一步为什么要演进,以及各自真实的缺陷在哪里


一、MVC:最朴素的时代

一个 TODO 页面的 MVC

  • ModelTodo 数据类 + TodoDatabase(SQLiteOpenHelper)
  • Viewactivity_main.xml
  • ControllerMainActivity
kotlin 复制代码
class MainActivity : AppCompatActivity() {
    private lateinit var db: TodoDatabase

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        db = TodoDatabase(this)

        btnAdd.setOnClickListener {
            val text = etInput.text.toString()
            db.insert(Todo(text))
            refreshList()
        }
    }

    private fun refreshList() {
        val todos = db.all()
        // 直接操作 View
        listView.adapter = ArrayAdapter(this, ..., todos)
    }
}

真实问题

  1. Activity 啥都干------业务逻辑、View 操作、数据库------膨胀到 1000+ 行。
  2. 没法测试------想测业务逻辑?必须启动设备。
  3. 无法横向拆分------想多人协作开发,无从下手。

为什么"Android 官方 MVC"是失败的

Google 早期的 MVC 文档里把 Activity 同时算 View 和 Controller,结果Controller 名存实亡,Activity 成了大杂烩。这是 Android 架构争议的根源。


二、MVP:把 View 和 Controller 拆开

演化动机

测试不了,那就把逻辑从 Activity 里抽出来。

kotlin 复制代码
// View 接口
interface MainView {
    fun showTodos(todos: List<Todo>)
    fun showEmpty()
}

// Presenter
class MainPresenter(private val view: MainView, private val repo: TodoRepo) {
    fun load() {
        val todos = repo.getAll()
        if (todos.isEmpty()) view.showEmpty() else view.showTodos(todos)
    }

    fun addTodo(text: String) {
        repo.insert(Todo(text))
        load()
    }
}

// Activity 实现 View
class MainActivity : AppCompatActivity(), MainView {
    private lateinit var presenter: MainPresenter

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        presenter = MainPresenter(this, TodoRepo())
        presenter.load()
        btnAdd.setOnClickListener { presenter.addTodo(etInput.text.toString()) }
    }

    override fun showTodos(todos: List<Todo>) { /* 渲染列表 */ }
    override fun showEmpty() { /* 显示空态 */ }
}

解决了什么

  • ✅ 可以测 Presenter 了------mock 一个 View 就行。
  • ✅ Activity 变薄。

没解决什么

  • ❌ Presenter 还是手工通知 View------容易漏。
  • ❌ 每个 View 接口都要手动声明,文件数翻倍。
  • ❌ 状态管理混乱------转屏后 Presenter 可能丢失正在进行的回调。

三、MVVM:让数据自动驱动 UI

演化动机

MVP 的本质问题:View 要主动问 Presenter 拿数据。如果数据能"自动"流到 View,世界就简单了。

MVVM 引入两个核心概念:

  1. ViewModel 暴露 StateFlow / LiveData------可观察的数据流。
  2. View 通过 collect() 订阅------UI 自动响应数据变化。
kotlin 复制代码
// ViewModel
class TodoViewModel(private val repo: TodoRepo) : ViewModel() {
    private val _todos = MutableStateFlow<List<Todo>>(emptyList())
    val todos: StateFlow<List<Todo>> = _todos

    fun addTodo(text: String) {
        viewModelScope.launch {
            repo.insert(Todo(text))
            _todos.value = repo.getAll()
        }
    }

    init {
        viewModelScope.launch { _todos.value = repo.getAll() }
    }
}

// Activity(用 Compose)
@Composable
fun TodoScreen(viewModel: TodoViewModel = viewModel()) {
    val todos by viewModel.todos.collectAsState()
    TodoContent(todos = todos, onAdd = viewModel::addTodo)
}

解决了什么

  • ✅ 数据驱动 UI,不再依赖手工同步
  • ✅ 配置变更安全------ViewModel 与 Activity 生命周期解耦。
  • ✅ 测试简单------直接测 ViewModel 暴露的 StateFlow。

没解决什么

  • ❌ 没有明确的"事件"概念------UI 触发的动作只能调 ViewModel 方法,意图传递不显式
  • ❌ 复杂页面里,StateFlow 越来越多时,状态管理变得混乱。

四、MVI:让"用户意图"也成数据流

演化动机

MVVM 没解决:用户的操作(点击、滑动、输入)和数据状态往往是耦合的------MVI 把它们统一抽象。

MVI 的三要素:

  • Model/StateUiState------单一不可变状态。
  • View:渲染 State,触发 Intent。
  • Intent :用户的意图(如 AddTodo("买菜")Refresh)。
kotlin 复制代码
// 单一状态
data class TodoUiState(
    val todos: List<Todo> = emptyList(),
    val input: String = "",
    val isLoading: Boolean = false,
    val error: String? = null
)

// 意图
sealed interface TodoIntent {
    data class UpdateInput(val text: String) : TodoIntent
    data class AddTodo(val text: String) : TodoIntent
    object Refresh : TodoIntent
}

// 单事件
sealed interface TodoEffect {
    data class ShowToast(val msg: String) : TodoEffect
}

// ViewModel
class TodoViewModel : ViewModel() {
    private val _state = MutableStateFlow(TodoUiState())
    val state: StateFlow<TodoUiState> = _state

    private val _effects = Channel<TodoEffect>(Channel.BUFFERED)
    val effects = _effects.receiveAsFlow()

    fun onIntent(intent: TodoIntent) {
        when (intent) {
            is TodoIntent.UpdateInput -> _state.update { it.copy(input = intent.text) }
            is TodoIntent.AddTodo -> addTodo(intent.text)
            TodoIntent.Refresh -> refresh()
        }
    }

    private fun addTodo(text: String) {
        viewModelScope.launch {
            _state.update { it.copy(isLoading = true) }
            runCatching { repo.insert(Todo(text)) }
                .onSuccess {
                    _state.update { it.copy(input = "", isLoading = false, todos = repo.getAll()) }
                    _effects.send(TodoEffect.ShowToast("已添加"))
                }
                .onFailure { e ->
                    _state.update { it.copy(isLoading = false, error = e.message) }
                }
        }
    }
}

解决了什么

  • 状态集中------任何时刻 UI 都由单一 State 决定。
  • 意图显式------所有用户操作都通过 Intent,方便审计。
  • 副作用隔离------发一次性事件(Toast、跳转)用 Effect,State 只存"持续状态"。

代价

  • ❌ 每个屏幕都要设计 State/Intent/Effect 三件套,前期投入大
  • ❌ 小页面用 MVI 是过度设计。

五、真实选型建议

我给团队的选型准则:

场景 推荐架构
单 Activity、纯 Compose、状态简单 MVVM + StateFlow
中等复杂度页面(3-5 个状态字段) MVVM + Sealed Intent,轻量 MVI
复杂业务页面(表单 + 列表 + 异步 + 一次性事件) MVI,强制三件套
老项目维护(Java + XML 为主) MVP 或 MVVM 渐进
全公司统一框架 MVI + 框架封装(如 Orbit、MAVI)

我的团队在 2025 年初切到了 MVI + Compose + 协程 这个组合,一年后看:

  • 状态 bug 减少了 40%
  • 新人接手项目速度加快------看 State 数据类就能理解整个页面。
  • 复杂度没有显著上升------Intent/Effect 在小页面里也就 10 行。

六、一个真实的"中等页面"完整 MVI 样板

为了让今天的文章不只停留在概念,我留一个完整的 MVI 骨架,把 Compose + 协程 + State 一次串起来。下一篇文章里我们会用这个骨架作为性能优化的载体。

scss 复制代码
@Composable
fun TodoScreen(
    viewModel: TodoViewModel = viewModel(),
    snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }
) {
    val state by viewModel.state.collectAsStateWithLifecycle()

    // 一次性事件
    LaunchedEffect(Unit) {
        viewModel.effects.collect { effect ->
            when (effect) {
                is TodoEffect.ShowToast -> snackbarHostState.showSnackbar(effect.msg)
            }
        }
    }

    // 渲染
    Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { padding ->
        Column(Modifier.padding(padding)) {
            TextField(
                value = state.input,
                onValueChange = { viewModel.onIntent(TodoIntent.UpdateInput(it)) }
            )
            Button(onClick = { viewModel.onIntent(TodoIntent.AddTodo(state.input)) }) {
                Text("添加")
            }
            if (state.isLoading) CircularProgressIndicator()
            state.error?.let { Text(it, color = Color.Red) }
            LazyColumn {
                items(state.todos, key = { it.id }) { todo ->
                    Text(todo.text)
                }
            }
        }
    }
}

小结

架构不是目的,可维护性才是

  • MVC:不要写新项目再用,但你能一眼看出它的失败在哪。
  • MVP:适合老项目渐进改造,但成本高。
  • MVVM:当前主流,推荐大多数新页面使用。
  • MVI:复杂页面的银弹,小页面慎用。

参考阅读

  • Google 官方 "Guide to app architecture"
  • MVI in Android with Jetpack Compose --- ZOMBIELAND 实战
相关推荐
2601_962218473 小时前
万象生鲜系统智能报表引擎技术为生鲜企业提供数字化经营分析能力
大数据·运维·微服务·云原生·架构
阿拉斯攀登3 小时前
Java-PHP反序列化漏洞原理与实战
架构
艺杯羹3 小时前
AI编程时代软件工程怎么学:从底层思维认知到驱动智能体的架构跃迁
java·人工智能·ai·架构·软件工程·ai编程
(Charon)3 小时前
【Kafka】消息队列学习(一):为什么需要Kafka?从消息队列到整体架构
学习·架构·kafka
AI_Auto3 小时前
架构视角看数字化转型|完整复盘:架构先行,完成数字化组织与运营重构
网络·重构·架构
晚安日记wanna4 小时前
政务 Agent 面试真题五个得分点缺一就掉档
面试·架构
Quor4 小时前
Zorv AI GenUI 技术架构深度解析:从双面设计到安全边界
人工智能·ui·架构
晚安日记wanna4 小时前
分布式和微服务差在哪从一次订单超时雪崩说起
面试·架构
李兆龙的博客4 小时前
从一到无穷大 #91:从 Habitat 看存储平台的整合与分工
数据库·人工智能·架构