Administrator
发布于 2026-08-25 / 23 阅读
0
0

系列文章《Android 工程师的 100 篇进阶笔记》第 4 篇:App 架构演进:MVC → MVP → MVVM → MVI,一篇看懂

过去三篇我们谈的是"具体技术"——生命周期、Compose、协程。这一篇开始,我们谈"如何把这些技术组织起来",也就是架构。

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

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


一、MVC:最朴素的时代

一个 TODO 页面的 MVC

  • Model:Todo 数据类 + TodoDatabase(SQLiteOpenHelper)

  • View:activity_main.xml

  • Controller:MainActivity

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 里抽出来。

// 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 自动响应数据变化。

// 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/State:UiState——单一不可变状态。

  • View:渲染 State,触发 Intent。

  • Intent:用户的意图(如 AddTodo("买菜")、Refresh)。

// 单一状态
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 一次串起来。下一篇文章里我们会用这个骨架作为性能优化的载体。

@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:复杂页面的银弹,小页面慎用。

下一篇《性能优化》会用 MVI 项目作为示例,看看怎么用工具链排查启动、内存、卡顿问题。


下篇预告

下一篇是《第 5 篇:性能优化——启动、内存、卡顿的一站式排查手册》。

这一篇是这个系列的第一个"高级"主题。我们会讲:

  1. 冷启动优化的三板斧

  2. 内存泄漏的 5 个高频场景和定位工具

  3. 卡顿的真正原因(不是主线程做了一件事,而是主线程做了不该它做的事)

  4. Perfetto + Systrace + Choreographer 工具链

性能优化这门学问,大多数工程师只会用 ProGuard,但真正有效的是更深一层的方法。


参考阅读

  • Google 官方 "Guide to app architecture"

  • MVI in Android with Jetpack Compose — ZOMBIELAND 实战

  • 《Android 架构解密》第 4 章

  • 上一篇文章:第 3 篇《Kotlin 协程:从 Thread 切到挂起函数的正确姿势》


评论