很多工程师对架构有一种误解:以为它只是"换名字"——把 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)
}
}
真实问题
- Activity 啥都干——业务逻辑、View 操作、数据库——膨胀到 1000+ 行。
- 没法测试——想测业务逻辑?必须启动设备。
- 无法横向拆分——想多人协作开发,无从下手。
为什么"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 引入两个核心概念:
- ViewModel 暴露
StateFlow/LiveData——可观察的数据流。 - 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:复杂页面的银弹,小页面慎用。
参考阅读
- Google 官方 "Guide to app architecture"
- MVI in Android with Jetpack Compose — ZOMBIELAND 实战