过去三篇我们谈的是"具体技术"——生命周期、Compose、协程。这一篇开始,我们谈"如何把这些技术组织起来",也就是架构。
很多工程师对架构有一种误解:以为它只是"换名字"——把 Controller 叫 ViewModel,把 View 叫 Composable……确实只是命名游戏,但更核心的是责任边界和测试能力。
今天我们用一个 TODO App 作为例子,从远古的 MVC 一直走到现代的 MVI,看清楚每一步为什么要演进,以及各自真实的缺陷在哪里。
一、MVC:最朴素的时代
一个 TODO 页面的 MVC
Model:
Todo数据类 +TodoDatabase(SQLiteOpenHelper)View:
activity_main.xmlController:
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 是过度设计。
五、真实选型建议
我给团队的选型准则:
我的团队在 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 篇:性能优化——启动、内存、卡顿的一站式排查手册》。
这一篇是这个系列的第一个"高级"主题。我们会讲:
冷启动优化的三板斧
内存泄漏的 5 个高频场景和定位工具
卡顿的真正原因(不是主线程做了一件事,而是主线程做了不该它做的事)
Perfetto + Systrace + Choreographer 工具链
性能优化这门学问,大多数工程师只会用 ProGuard,但真正有效的是更深一层的方法。
参考阅读
Google 官方 "Guide to app architecture"
MVI in Android with Jetpack Compose — ZOMBIELAND 实战
《Android 架构解密》第 4 章
上一篇文章:第 3 篇《Kotlin 协程:从 Thread 切到挂起函数的正确姿势》