MVC、MVP、MVVM、MVI 都在回答同一个问题:界面、状态和业务行为应该如何协作? Clean Architecture 回答的是更大范围的问题:业务规则如何保持独立,代码依赖应该指向哪里,系统边界应该如何划分?
因此,它们不是五个互斥选项。一个项目完全可以在表现层使用 MVVM 或 MVI,同时在整个工程中遵循 Clean Architecture。
本文以 Android/Kotlin 为例,但原理同样适用于前端、桌面端和其他客户端开发。重点不是背缩写,而是理解:
- 谁持有状态;
- 谁接收用户事件;
- 谁执行决策;
- 谁更新界面;
- 依赖指向哪里;
- 每一部分如何单独测试。
1. 先建立共同语言
1.1 数据、状态、事件和副作用
| 概念 | 含义 | 示例 |
|---|---|---|
| Data | 从数据库、网络或传感器获得的原始数据 | UserDto、数据库行 |
| Domain Model | 能表达业务含义的领域对象 | User、Order |
| UI State | 某一时刻渲染完整界面所需的数据 | 加载中、内容、错误、按钮是否可用 |
| Event / Intent | 已经发生的输入 | 点击登录、输入账号、下拉刷新 |
| Action | 系统根据输入决定执行的行为 | 校验参数、调用登录用例 |
| Side Effect | 不能只靠纯计算完成的外部交互 | 网络、数据库、导航、Toast、日志 |
最容易踩坑的是把"状态"和"一次性事件"混在一起:
isLoading = true是可持续观察的状态;- "跳转到首页"通常是消费一次的效果;
- "登录失败"可以是页面持续展示的错误状态,也可以是只弹一次的提示,取决于产品语义。
1.2 关注点分离
关注点分离不是把代码机械地分成很多文件,而是让每一部分只有一种主要变化原因:
- UI 样式改变时,不应该修改数据库代码;
- API 字段改变时,不应该迫使领域规则跟着改变;
- 业务规则改变时,不应该依赖 Activity、Fragment 或 Compose;
- 导航方式改变时,不应该重写业务计算。
1.3 控制反转与依赖倒置
高层业务不直接创建低层实现,而依赖抽象:
kotlin
// 领域层定义它需要什么
interface UserRepository {
suspend fun login(account: String, password: String): Result<User>
}
// 数据层提供具体实现
class DefaultUserRepository(
private val api: UserApi,
private val dao: UserDao,
) : UserRepository {
override suspend fun login(account: String, password: String): Result<User> {
// 调用 API、缓存结果,并把 DTO 转成领域对象
TODO()
}
}
domain 知道 UserRepository 接口,但不知道 Retrofit、Room 或文件系统。数据层反过来实现领域层需要的接口。这就是 Clean Architecture 依赖规则落到代码里的关键方式。
2. 一张表看懂 MVC、MVP、MVVM、MVI
| 维度 | MVC | MVP | MVVM | MVI |
|---|---|---|---|---|
| 表现层中间角色 | Controller | Presenter | ViewModel | Store / Reducer(命名不固定) |
| View 是否主动取数据 | 经常会 | 通常不会 | 观察状态 | 只根据单一状态渲染 |
| View 与中间层关系 | 容易双向耦合 | 通过 View 接口双向调用 | 数据观察 + 事件上送 | 严格单向循环 |
| 状态来源 | 分散 | Presenter 字段或 Model | ViewModel 暴露的可观察状态 | 单一不可变 State |
| 更新方式 | 命令式为主 | Presenter 命令 View | 状态变化驱动 View | oldState + mutation -> newState |
| 一次性效果 | Controller 直接执行 | Presenter 调用 View | 单独事件流或由 UI 根据状态处理 | Effect 流 / Middleware |
| 测试难度 | 取决于 Controller 是否与框架解耦 | Presenter 易做纯 JVM 测试 | ViewModel 易测,需要理解异步流 | Reducer 最易测,但整体约束更多 |
| 主要优势 | 简单、直观 | View 与逻辑边界明确 | 生命周期友好、响应式 | 状态可预测、可追踪、适合复杂交互 |
| 主要风险 | Massive Controller、边界含糊 | 接口膨胀、样板代码多 | "什么都塞进 ViewModel" | 样板和抽象成本、Effect 处理复杂 |
这些名称在不同框架中的定义并不完全一致。判断一个项目使用了什么模式,不要只看类名,要看状态所有权和真实数据流。
3. MVC:由 Controller 协调输入与模型
3.1 三个部分分别做什么
Model
Model 表示数据和业务能力。广义的 Model 可以包含实体、规则、仓库和服务;在一些早期客户端实现中,它也可能只是数据对象。
Model 不应该知道按钮、文本框或页面生命周期。它回答"业务上发生什么",而不是"界面如何显示"。
View
View 负责展示和接收用户输入,例如 XML View、Activity 页面、Web 模板。理想情况下,它只做:
- 读取用于展示的数据;
- 渲染文本、列表、加载状态;
- 将点击、输入等操作交给 Controller。
Controller
Controller 解释用户输入,调用 Model,再选择或更新 View。典型过程是:
- View 把点击事件交给 Controller;
- Controller 校验输入并调用 Model;
- Model 返回结果;
- Controller 决定显示内容、错误或跳转。
#mermaid-svg-Tge8ukK3MTFx1gxn{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Tge8ukK3MTFx1gxn .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Tge8ukK3MTFx1gxn .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Tge8ukK3MTFx1gxn .error-icon{fill:#552222;}#mermaid-svg-Tge8ukK3MTFx1gxn .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Tge8ukK3MTFx1gxn .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Tge8ukK3MTFx1gxn .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Tge8ukK3MTFx1gxn .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Tge8ukK3MTFx1gxn .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Tge8ukK3MTFx1gxn .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Tge8ukK3MTFx1gxn .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Tge8ukK3MTFx1gxn .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Tge8ukK3MTFx1gxn .marker.cross{stroke:#333333;}#mermaid-svg-Tge8ukK3MTFx1gxn svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Tge8ukK3MTFx1gxn p{margin:0;}#mermaid-svg-Tge8ukK3MTFx1gxn .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Tge8ukK3MTFx1gxn .cluster-label text{fill:#333;}#mermaid-svg-Tge8ukK3MTFx1gxn .cluster-label span{color:#333;}#mermaid-svg-Tge8ukK3MTFx1gxn .cluster-label span p{background-color:transparent;}#mermaid-svg-Tge8ukK3MTFx1gxn .label text,#mermaid-svg-Tge8ukK3MTFx1gxn span{fill:#333;color:#333;}#mermaid-svg-Tge8ukK3MTFx1gxn .node rect,#mermaid-svg-Tge8ukK3MTFx1gxn .node circle,#mermaid-svg-Tge8ukK3MTFx1gxn .node ellipse,#mermaid-svg-Tge8ukK3MTFx1gxn .node polygon,#mermaid-svg-Tge8ukK3MTFx1gxn .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Tge8ukK3MTFx1gxn .rough-node .label text,#mermaid-svg-Tge8ukK3MTFx1gxn .node .label text,#mermaid-svg-Tge8ukK3MTFx1gxn .image-shape .label,#mermaid-svg-Tge8ukK3MTFx1gxn .icon-shape .label{text-anchor:middle;}#mermaid-svg-Tge8ukK3MTFx1gxn .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Tge8ukK3MTFx1gxn .rough-node .label,#mermaid-svg-Tge8ukK3MTFx1gxn .node .label,#mermaid-svg-Tge8ukK3MTFx1gxn .image-shape .label,#mermaid-svg-Tge8ukK3MTFx1gxn .icon-shape .label{text-align:center;}#mermaid-svg-Tge8ukK3MTFx1gxn .node.clickable{cursor:pointer;}#mermaid-svg-Tge8ukK3MTFx1gxn .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Tge8ukK3MTFx1gxn .arrowheadPath{fill:#333333;}#mermaid-svg-Tge8ukK3MTFx1gxn .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Tge8ukK3MTFx1gxn .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Tge8ukK3MTFx1gxn .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Tge8ukK3MTFx1gxn .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Tge8ukK3MTFx1gxn .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Tge8ukK3MTFx1gxn .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Tge8ukK3MTFx1gxn .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Tge8ukK3MTFx1gxn .cluster text{fill:#333;}#mermaid-svg-Tge8ukK3MTFx1gxn .cluster span{color:#333;}#mermaid-svg-Tge8ukK3MTFx1gxn div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Tge8ukK3MTFx1gxn .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Tge8ukK3MTFx1gxn rect.text{fill:none;stroke-width:0;}#mermaid-svg-Tge8ukK3MTFx1gxn .icon-shape,#mermaid-svg-Tge8ukK3MTFx1gxn .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Tge8ukK3MTFx1gxn .icon-shape p,#mermaid-svg-Tge8ukK3MTFx1gxn .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Tge8ukK3MTFx1gxn .icon-shape .label rect,#mermaid-svg-Tge8ukK3MTFx1gxn .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Tge8ukK3MTFx1gxn .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Tge8ukK3MTFx1gxn .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Tge8ukK3MTFx1gxn :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 操作
事件
调用
结果/通知
选择或更新
用户
View
Controller
Model
3.2 一个最小实现
kotlin
interface LoginView {
val account: String
val password: String
fun showLoading(visible: Boolean)
fun showError(message: String)
fun openHome()
}
class LoginController(
private val view: LoginView,
private val login: suspend (String, String) -> Result<User>,
) {
suspend fun onLoginClicked() {
val account = view.account
val password = view.password
if (account.isBlank() || password.isBlank()) {
view.showError("账号和密码不能为空")
return
}
view.showLoading(true)
login(account, password)
.onSuccess { view.openHome() }
.onFailure { view.showError(it.message ?: "登录失败") }
view.showLoading(false)
}
}
上面的 Controller 同时读取 View、调用业务能力、命令 View。代码简单,但 View 和 Controller 关系紧密。若 Controller 直接持有 Android Activity,单元测试就会变重,生命周期泄漏风险也会上升。
3.3 MVC 的作用
- 把输入协调从数据模型中拿出来;
- 让同一 Model 有机会服务不同 View;
- 对页面少、交互简单的系统,开发成本低。
3.4 需要理解什么
- Model 不是只有数据库实体,它更接近"业务与数据能力";
- Web MVC 和客户端 MVC 的 View 更新机制可能完全不同;
- Android 早期常把 Activity 同时当作 View 和 Controller,因此容易形成数千行的 Activity;
- Controller 如果依赖框架类型,应通过接口或更清晰的边界降低耦合。
3.5 适用与不适用
适合简单 CRUD、页面生命周期短、团队希望保持低抽象成本的场景。不适合状态很多、并发请求多、需要精细恢复界面状态的复杂客户端页面。
4. MVP:Presenter 通过 View Contract 驱动界面
4.1 MVP 为什么出现
客户端 MVC 中,View 和 Controller 的边界经常模糊。MVP 把 View 抽象成接口,由 Presenter 持有这个接口。Presenter 不需要知道它背后是 Activity、Fragment 还是桌面窗口。
4.2 三个部分分别做什么
Model
提供业务数据和操作。它可以是 Repository、Use Case 或其他业务服务,但不感知 View。
View
实现一个明确的 Contract,例如:
kotlin
interface LoginContract {
interface View {
fun renderLoading()
fun renderForm()
fun renderError(message: String)
fun navigateHome()
}
interface Presenter {
fun attach(view: View)
fun detach()
fun login(account: String, password: String)
}
}
View 接收命令并执行具体 UI 操作。它把输入值作为事件参数传给 Presenter,避免 Presenter 主动读取控件。
Presenter
Presenter 负责表现逻辑:校验输入、调用 Model、将结果转换成 View 能理解的命令。它通常不继承 Android 类,所以可以在 JVM 上快速测试。
kotlin
class LoginPresenter(
private val loginUseCase: LoginUseCase,
private val scope: CoroutineScope,
) : LoginContract.Presenter {
private var view: LoginContract.View? = null
override fun attach(view: LoginContract.View) {
this.view = view
view.renderForm()
}
override fun detach() {
view = null
}
override fun login(account: String, password: String) {
if (account.isBlank() || password.isBlank()) {
view?.renderError("账号和密码不能为空")
return
}
scope.launch {
view?.renderLoading()
loginUseCase(account, password)
.onSuccess { view?.navigateHome() }
.onFailure { view?.renderError(it.message ?: "登录失败") }
}
}
}
#mermaid-svg-AZCuiFZcHD6YUbwG{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-AZCuiFZcHD6YUbwG .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-AZCuiFZcHD6YUbwG .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-AZCuiFZcHD6YUbwG .error-icon{fill:#552222;}#mermaid-svg-AZCuiFZcHD6YUbwG .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-AZCuiFZcHD6YUbwG .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-AZCuiFZcHD6YUbwG .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-AZCuiFZcHD6YUbwG .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-AZCuiFZcHD6YUbwG .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-AZCuiFZcHD6YUbwG .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-AZCuiFZcHD6YUbwG .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-AZCuiFZcHD6YUbwG .marker{fill:#333333;stroke:#333333;}#mermaid-svg-AZCuiFZcHD6YUbwG .marker.cross{stroke:#333333;}#mermaid-svg-AZCuiFZcHD6YUbwG svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-AZCuiFZcHD6YUbwG p{margin:0;}#mermaid-svg-AZCuiFZcHD6YUbwG .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-AZCuiFZcHD6YUbwG .cluster-label text{fill:#333;}#mermaid-svg-AZCuiFZcHD6YUbwG .cluster-label span{color:#333;}#mermaid-svg-AZCuiFZcHD6YUbwG .cluster-label span p{background-color:transparent;}#mermaid-svg-AZCuiFZcHD6YUbwG .label text,#mermaid-svg-AZCuiFZcHD6YUbwG span{fill:#333;color:#333;}#mermaid-svg-AZCuiFZcHD6YUbwG .node rect,#mermaid-svg-AZCuiFZcHD6YUbwG .node circle,#mermaid-svg-AZCuiFZcHD6YUbwG .node ellipse,#mermaid-svg-AZCuiFZcHD6YUbwG .node polygon,#mermaid-svg-AZCuiFZcHD6YUbwG .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-AZCuiFZcHD6YUbwG .rough-node .label text,#mermaid-svg-AZCuiFZcHD6YUbwG .node .label text,#mermaid-svg-AZCuiFZcHD6YUbwG .image-shape .label,#mermaid-svg-AZCuiFZcHD6YUbwG .icon-shape .label{text-anchor:middle;}#mermaid-svg-AZCuiFZcHD6YUbwG .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-AZCuiFZcHD6YUbwG .rough-node .label,#mermaid-svg-AZCuiFZcHD6YUbwG .node .label,#mermaid-svg-AZCuiFZcHD6YUbwG .image-shape .label,#mermaid-svg-AZCuiFZcHD6YUbwG .icon-shape .label{text-align:center;}#mermaid-svg-AZCuiFZcHD6YUbwG .node.clickable{cursor:pointer;}#mermaid-svg-AZCuiFZcHD6YUbwG .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-AZCuiFZcHD6YUbwG .arrowheadPath{fill:#333333;}#mermaid-svg-AZCuiFZcHD6YUbwG .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-AZCuiFZcHD6YUbwG .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-AZCuiFZcHD6YUbwG .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-AZCuiFZcHD6YUbwG .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-AZCuiFZcHD6YUbwG .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-AZCuiFZcHD6YUbwG .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-AZCuiFZcHD6YUbwG .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-AZCuiFZcHD6YUbwG .cluster text{fill:#333;}#mermaid-svg-AZCuiFZcHD6YUbwG .cluster span{color:#333;}#mermaid-svg-AZCuiFZcHD6YUbwG div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-AZCuiFZcHD6YUbwG .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-AZCuiFZcHD6YUbwG rect.text{fill:none;stroke-width:0;}#mermaid-svg-AZCuiFZcHD6YUbwG .icon-shape,#mermaid-svg-AZCuiFZcHD6YUbwG .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-AZCuiFZcHD6YUbwG .icon-shape p,#mermaid-svg-AZCuiFZcHD6YUbwG .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-AZCuiFZcHD6YUbwG .icon-shape .label rect,#mermaid-svg-AZCuiFZcHD6YUbwG .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-AZCuiFZcHD6YUbwG .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-AZCuiFZcHD6YUbwG .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-AZCuiFZcHD6YUbwG :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 事件 + 参数
调用
结果
View 接口命令
View
Presenter
Model
4.3 MVP 的核心原理
MVP 使用的是**被动视图(Passive View)**思想:View 不负责业务决策,Presenter 告诉它显示什么。测试 Presenter 时,用 Fake View 记录调用即可:
kotlin
class FakeLoginView : LoginContract.View {
val rendered = mutableListOf<String>()
override fun renderLoading() { rendered += "loading" }
override fun renderForm() { rendered += "form" }
override fun renderError(message: String) { rendered += "error:$message" }
override fun navigateHome() { rendered += "home" }
}
4.4 MVP 的作用与代价
作用:
- 把 Activity/Fragment 里的表现逻辑移到普通 Kotlin 类;
- 通过 View 接口让 Presenter 容易测试;
- 对命令式 View 系统非常自然。
代价:
- 每个页面可能出现大量 Contract 方法;
- View 与 Presenter 一一对应时,复用能力有限;
attach/detach、异步任务取消处理不当会导致内存泄漏或回调已销毁页面;- 多个异步结果同时更新 View 时,最终状态不够直观。
4.5 需要理解什么
- 接口隔离:Contract 应表达页面能力,而不是暴露具体控件;
- 生命周期:异步任务必须随 Presenter 或 View 的生命周期取消;
- Presenter 的命令调用顺序本身就是状态变化,需要测试;
- MVP 并不自动等于 Clean Architecture,Presenter 仍然可能直接调用数据库。
5. MVVM:View 观察 ViewModel 暴露的状态
5.1 三个部分分别做什么
Model
Model 仍然是业务与数据能力。在 Clean Architecture 项目里,ViewModel 通常调用 Use Case,而不是直接操作 Retrofit 或 Room。
View
View 订阅 ViewModel 的可观察状态,并把用户事件传给 ViewModel。View 不应把 Android 控件对象交给 ViewModel。
ViewModel
ViewModel 保存和生产展示所需的状态,处理表现层事件,并把领域结果映射成 UI 模型。AndroidX ViewModel 还提供配置变更期间的实例保留和 viewModelScope。
kotlin
data class LoginUiState(
val account: String = "",
val password: String = "",
val isLoading: Boolean = false,
val errorMessage: String? = null,
val canSubmit: Boolean = false,
)
sealed interface LoginEvent {
data class AccountChanged(val value: String) : LoginEvent
data class PasswordChanged(val value: String) : LoginEvent
data object SubmitClicked : LoginEvent
data object ErrorDismissed : LoginEvent
}
sealed interface LoginEffect {
data object NavigateHome : LoginEffect
}
kotlin
class LoginViewModel(
private val login: LoginUseCase,
) : ViewModel() {
private val _state = MutableStateFlow(LoginUiState())
val state: StateFlow<LoginUiState> = _state.asStateFlow()
private val _effects = Channel<LoginEffect>(Channel.BUFFERED)
val effects: Flow<LoginEffect> = _effects.receiveAsFlow()
fun onEvent(event: LoginEvent) {
when (event) {
is LoginEvent.AccountChanged -> updateForm(account = event.value)
is LoginEvent.PasswordChanged -> updateForm(password = event.value)
LoginEvent.SubmitClicked -> submit()
LoginEvent.ErrorDismissed ->
_state.update { it.copy(errorMessage = null) }
}
}
private fun updateForm(
account: String = _state.value.account,
password: String = _state.value.password,
) {
_state.update {
it.copy(
account = account,
password = password,
canSubmit = account.isNotBlank() && password.isNotBlank(),
)
}
}
private fun submit() = viewModelScope.launch {
val snapshot = _state.value
if (!snapshot.canSubmit || snapshot.isLoading) return@launch
_state.update { it.copy(isLoading = true, errorMessage = null) }
login(snapshot.account, snapshot.password)
.onSuccess { _effects.send(LoginEffect.NavigateHome) }
.onFailure { error ->
_state.update {
it.copy(errorMessage = error.message ?: "登录失败")
}
}
_state.update { it.copy(isLoading = false) }
}
}
Compose View 只负责收集、渲染和转发:
kotlin
@Composable
fun LoginRoute(
viewModel: LoginViewModel,
navigateHome: () -> Unit,
) {
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(viewModel) {
viewModel.effects.collect { effect ->
when (effect) {
LoginEffect.NavigateHome -> navigateHome()
}
}
}
LoginScreen(
state = state,
onEvent = viewModel::onEvent,
)
}
#mermaid-svg-8Vik5MQgHl8TnpMy{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-8Vik5MQgHl8TnpMy .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-8Vik5MQgHl8TnpMy .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-8Vik5MQgHl8TnpMy .error-icon{fill:#552222;}#mermaid-svg-8Vik5MQgHl8TnpMy .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-8Vik5MQgHl8TnpMy .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-8Vik5MQgHl8TnpMy .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-8Vik5MQgHl8TnpMy .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-8Vik5MQgHl8TnpMy .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-8Vik5MQgHl8TnpMy .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-8Vik5MQgHl8TnpMy .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-8Vik5MQgHl8TnpMy .marker{fill:#333333;stroke:#333333;}#mermaid-svg-8Vik5MQgHl8TnpMy .marker.cross{stroke:#333333;}#mermaid-svg-8Vik5MQgHl8TnpMy svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-8Vik5MQgHl8TnpMy p{margin:0;}#mermaid-svg-8Vik5MQgHl8TnpMy .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-8Vik5MQgHl8TnpMy .cluster-label text{fill:#333;}#mermaid-svg-8Vik5MQgHl8TnpMy .cluster-label span{color:#333;}#mermaid-svg-8Vik5MQgHl8TnpMy .cluster-label span p{background-color:transparent;}#mermaid-svg-8Vik5MQgHl8TnpMy .label text,#mermaid-svg-8Vik5MQgHl8TnpMy span{fill:#333;color:#333;}#mermaid-svg-8Vik5MQgHl8TnpMy .node rect,#mermaid-svg-8Vik5MQgHl8TnpMy .node circle,#mermaid-svg-8Vik5MQgHl8TnpMy .node ellipse,#mermaid-svg-8Vik5MQgHl8TnpMy .node polygon,#mermaid-svg-8Vik5MQgHl8TnpMy .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-8Vik5MQgHl8TnpMy .rough-node .label text,#mermaid-svg-8Vik5MQgHl8TnpMy .node .label text,#mermaid-svg-8Vik5MQgHl8TnpMy .image-shape .label,#mermaid-svg-8Vik5MQgHl8TnpMy .icon-shape .label{text-anchor:middle;}#mermaid-svg-8Vik5MQgHl8TnpMy .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-8Vik5MQgHl8TnpMy .rough-node .label,#mermaid-svg-8Vik5MQgHl8TnpMy .node .label,#mermaid-svg-8Vik5MQgHl8TnpMy .image-shape .label,#mermaid-svg-8Vik5MQgHl8TnpMy .icon-shape .label{text-align:center;}#mermaid-svg-8Vik5MQgHl8TnpMy .node.clickable{cursor:pointer;}#mermaid-svg-8Vik5MQgHl8TnpMy .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-8Vik5MQgHl8TnpMy .arrowheadPath{fill:#333333;}#mermaid-svg-8Vik5MQgHl8TnpMy .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-8Vik5MQgHl8TnpMy .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-8Vik5MQgHl8TnpMy .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-8Vik5MQgHl8TnpMy .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-8Vik5MQgHl8TnpMy .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-8Vik5MQgHl8TnpMy .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-8Vik5MQgHl8TnpMy .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-8Vik5MQgHl8TnpMy .cluster text{fill:#333;}#mermaid-svg-8Vik5MQgHl8TnpMy .cluster span{color:#333;}#mermaid-svg-8Vik5MQgHl8TnpMy div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-8Vik5MQgHl8TnpMy .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-8Vik5MQgHl8TnpMy rect.text{fill:none;stroke-width:0;}#mermaid-svg-8Vik5MQgHl8TnpMy .icon-shape,#mermaid-svg-8Vik5MQgHl8TnpMy .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-8Vik5MQgHl8TnpMy .icon-shape p,#mermaid-svg-8Vik5MQgHl8TnpMy .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-8Vik5MQgHl8TnpMy .icon-shape .label rect,#mermaid-svg-8Vik5MQgHl8TnpMy .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-8Vik5MQgHl8TnpMy .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-8Vik5MQgHl8TnpMy .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-8Vik5MQgHl8TnpMy :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Event
Use Case
Domain Result
Observable State
One-off Effect
View
ViewModel
Model
5.2 MVVM 的核心原理
传统 MVVM 强调 Data Binding:ViewModel 暴露属性,View 通过绑定自动同步。现代 Android 更常见的是 StateFlow + Compose/XML:
- ViewModel 是状态生产者;
- View 是状态消费者;
- 用户操作形成事件,反向送到 ViewModel;
- UI 状态通常是不可变数据类,每次用
copy生成新快照。
这已经具备单向数据流特征,但只要没有强制统一 Intent、Reducer 和单一状态容器,通常仍称为 MVVM。
5.3 StateFlow 为什么适合状态
- 始终有当前值,新订阅者能立刻拿到快照;
- 相同值会被合并,适合描述状态而非事件;
- 与协程、Lifecycle、Compose 集成自然;
- 状态更新可以集中在 ViewModel 内部,外部只读。
不要把导航、Toast 等一次性动作简单放进长期状态后永不消费,否则屏幕旋转或重新订阅时可能重复执行。也不要迷信"所有东西都必须是事件";如果错误消息应该持续显示,它就是状态。
5.4 MVVM 的作用与风险
作用:
- UI 更新从命令调用转为状态观察;
- ViewModel 不持有 View,降低生命周期耦合;
- 对响应式 UI、配置变更和多数据源组合友好。
风险:
- ViewModel 可能膨胀成新的"上帝类";
- 多个
LiveData/Flow分别代表局部状态时,组合快照可能互相矛盾; - 双向 Data Binding 会隐藏事件来源,使调试困难;
- 仅仅使用 AndroidX
ViewModel并不代表实现了 MVVM。
5.5 需要理解什么
- 生命周期感知收集与结构化并发;
- 冷流、热流、
StateFlow、SharedFlow、Channel的语义差异; - UI State 和 Domain Model 的边界;
- 状态提升、不可变数据和单向数据流;
- ViewModel 只应持有 UI 需要的状态,不应持有 Activity/View 引用。
6. MVI:把页面建模为可重复推导的状态机
6.1 MVI 中的三个核心概念
不同库命名不同,但通常包含:
Model / State
这里的 Model 往往专指完整、不可变的 UI State,不是 MVC 中广义的数据层 Model。
kotlin
data class SearchState(
val query: String = "",
val isLoading: Boolean = false,
val items: List<SearchItemUi> = emptyList(),
val error: String? = null,
)
View
View 对同一个 State 做纯渲染,并把用户动作转为 Intent。理想形式接近:
text
UI = render(State)
Intent
Intent 表示"发生了什么"或"用户想做什么",不等于 Android 的 android.content.Intent。
kotlin
sealed interface SearchIntent {
data class QueryChanged(val query: String) : SearchIntent
data object SearchClicked : SearchIntent
data object RetryClicked : SearchIntent
}
实践中还会增加三个角色:
- Processor / Actor / Middleware:执行异步用例和副作用;
- Mutation / Result:描述这次处理将怎样改变状态;
- Reducer:用旧状态和 Mutation 纯计算新状态。
#mermaid-svg-PzJPhRnOlNRMMrHW{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-PzJPhRnOlNRMMrHW .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-PzJPhRnOlNRMMrHW .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-PzJPhRnOlNRMMrHW .error-icon{fill:#552222;}#mermaid-svg-PzJPhRnOlNRMMrHW .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-PzJPhRnOlNRMMrHW .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-PzJPhRnOlNRMMrHW .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-PzJPhRnOlNRMMrHW .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-PzJPhRnOlNRMMrHW .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-PzJPhRnOlNRMMrHW .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-PzJPhRnOlNRMMrHW .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-PzJPhRnOlNRMMrHW .marker{fill:#333333;stroke:#333333;}#mermaid-svg-PzJPhRnOlNRMMrHW .marker.cross{stroke:#333333;}#mermaid-svg-PzJPhRnOlNRMMrHW svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-PzJPhRnOlNRMMrHW p{margin:0;}#mermaid-svg-PzJPhRnOlNRMMrHW .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-PzJPhRnOlNRMMrHW .cluster-label text{fill:#333;}#mermaid-svg-PzJPhRnOlNRMMrHW .cluster-label span{color:#333;}#mermaid-svg-PzJPhRnOlNRMMrHW .cluster-label span p{background-color:transparent;}#mermaid-svg-PzJPhRnOlNRMMrHW .label text,#mermaid-svg-PzJPhRnOlNRMMrHW span{fill:#333;color:#333;}#mermaid-svg-PzJPhRnOlNRMMrHW .node rect,#mermaid-svg-PzJPhRnOlNRMMrHW .node circle,#mermaid-svg-PzJPhRnOlNRMMrHW .node ellipse,#mermaid-svg-PzJPhRnOlNRMMrHW .node polygon,#mermaid-svg-PzJPhRnOlNRMMrHW .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-PzJPhRnOlNRMMrHW .rough-node .label text,#mermaid-svg-PzJPhRnOlNRMMrHW .node .label text,#mermaid-svg-PzJPhRnOlNRMMrHW .image-shape .label,#mermaid-svg-PzJPhRnOlNRMMrHW .icon-shape .label{text-anchor:middle;}#mermaid-svg-PzJPhRnOlNRMMrHW .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-PzJPhRnOlNRMMrHW .rough-node .label,#mermaid-svg-PzJPhRnOlNRMMrHW .node .label,#mermaid-svg-PzJPhRnOlNRMMrHW .image-shape .label,#mermaid-svg-PzJPhRnOlNRMMrHW .icon-shape .label{text-align:center;}#mermaid-svg-PzJPhRnOlNRMMrHW .node.clickable{cursor:pointer;}#mermaid-svg-PzJPhRnOlNRMMrHW .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-PzJPhRnOlNRMMrHW .arrowheadPath{fill:#333333;}#mermaid-svg-PzJPhRnOlNRMMrHW .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-PzJPhRnOlNRMMrHW .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-PzJPhRnOlNRMMrHW .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-PzJPhRnOlNRMMrHW .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-PzJPhRnOlNRMMrHW .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-PzJPhRnOlNRMMrHW .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-PzJPhRnOlNRMMrHW .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-PzJPhRnOlNRMMrHW .cluster text{fill:#333;}#mermaid-svg-PzJPhRnOlNRMMrHW .cluster span{color:#333;}#mermaid-svg-PzJPhRnOlNRMMrHW div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-PzJPhRnOlNRMMrHW .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-PzJPhRnOlNRMMrHW rect.text{fill:none;stroke-width:0;}#mermaid-svg-PzJPhRnOlNRMMrHW .icon-shape,#mermaid-svg-PzJPhRnOlNRMMrHW .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-PzJPhRnOlNRMMrHW .icon-shape p,#mermaid-svg-PzJPhRnOlNRMMrHW .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-PzJPhRnOlNRMMrHW .icon-shape .label rect,#mermaid-svg-PzJPhRnOlNRMMrHW .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-PzJPhRnOlNRMMrHW .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-PzJPhRnOlNRMMrHW .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-PzJPhRnOlNRMMrHW :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Intent
调用 Use Case
Mutation
New State
State
Effect
View
Store
Processor / Middleware
Reducer
6.2 Reducer 的实现原理
Reducer 应尽量是纯函数:相同输入永远得到相同输出,不访问网络、不读时间、不修改外部变量。
kotlin
sealed interface SearchMutation {
data class QueryUpdated(val value: String) : SearchMutation
data object Loading : SearchMutation
data class Loaded(val items: List<SearchItemUi>) : SearchMutation
data class Failed(val message: String) : SearchMutation
}
fun reduce(
state: SearchState,
mutation: SearchMutation,
): SearchState = when (mutation) {
is SearchMutation.QueryUpdated ->
state.copy(query = mutation.value, error = null)
SearchMutation.Loading ->
state.copy(isLoading = true, error = null)
is SearchMutation.Loaded ->
state.copy(isLoading = false, items = mutation.items)
is SearchMutation.Failed ->
state.copy(isLoading = false, error = mutation.message)
}
纯 Reducer 的价值是可预测:只需要输入旧状态和 Mutation,就能断言新状态,无须启动 Android 或协程环境。
6.3 一个精简 Store
kotlin
class SearchStore(
private val search: SearchUseCase,
private val scope: CoroutineScope,
) {
private val intents = Channel<SearchIntent>(Channel.UNLIMITED)
private val _state = MutableStateFlow(SearchState())
val state: StateFlow<SearchState> = _state.asStateFlow()
init {
scope.launch {
for (intent in intents) handle(intent)
}
}
fun accept(intent: SearchIntent) {
intents.trySend(intent)
}
private suspend fun handle(intent: SearchIntent) {
when (intent) {
is SearchIntent.QueryChanged ->
dispatch(SearchMutation.QueryUpdated(intent.query))
SearchIntent.SearchClicked,
SearchIntent.RetryClicked -> {
val query = state.value.query
if (query.isBlank()) return
dispatch(SearchMutation.Loading)
search(query)
.onSuccess { dispatch(SearchMutation.Loaded(it.map(::toUi))) }
.onFailure {
dispatch(SearchMutation.Failed(it.message ?: "搜索失败"))
}
}
}
}
private fun dispatch(mutation: SearchMutation) {
_state.update { old -> reduce(old, mutation) }
}
}
6.4 MVI 为什么更可预测
- 所有 UI 输入经过同一入口;
- 页面由一个完整状态快照描述;
- 状态更新经过统一 Reducer;
- 数据流方向固定,事件来源容易追踪;
- 可以记录
Intent -> Mutation -> State,甚至实现时间旅行调试。
但"单一状态"不是整个 App 只有一个巨大对象。更合理的是以页面、功能或有明确生命周期的组件为边界。
6.5 副作用和并发是 MVI 的难点
Reducer 必须保持纯净,因此网络请求、数据库写入、导航、埋点等应由 Middleware/Actor 或独立 Effect 处理。
还必须明确并发策略:
- 搜索输入:新请求到来时取消旧请求,类似
flatMapLatest; - 提交订单:忽略重复点击,或通过幂等键保证安全;
- 分页加载:串行执行并保存页码;
- 多来源刷新:决定合并、排队还是最新结果覆盖。
如果只画出单向箭头,却不定义这些策略,MVI 仍然可能产生竞态条件。
6.6 MVI 的作用与代价
作用:
- 复杂页面状态更一致;
- Reducer 和状态转换易测试;
- 操作历史清晰,适合调试和审计;
- 声明式 UI 可以直接消费状态。
代价:
- Intent、Mutation、State、Effect 可能带来样板代码;
- 大列表每次复制状态需要注意对象粒度和稳定性;
- 简单页面使用完整 MVI 可能过度设计;
- Effect 的投递、消费和进程重建语义必须由团队约定。
6.7 需要理解什么
- 有限状态机、不可变数据和纯函数;
- Reducer、Middleware、Store 各自的边界;
- 背压、取消、去重、串行化等并发语义;
- 状态恢复与一次性 Effect 的交付保证;
- 等价状态、持久集合和渲染性能。
7. MVVM 和 MVI 到底差在哪里
两者在现代 Android 中非常接近,因为都常使用 ViewModel + StateFlow + Compose。真正差异在约束强度,不在类名。
| 问题 | 常见 MVVM | 典型 MVI |
|---|---|---|
| 事件入口 | 可有多个方法,如 login()、refresh() |
通常统一为 accept(Intent) |
| 页面状态 | 可以拆成多个 Flow | 倾向单一完整 State |
| 状态修改 | ViewModel 任意位置可更新 | 统一通过 Reducer/Mutation |
| 业务副作用 | ViewModel 方法中直接执行 | 交给 Processor/Middleware,再产出结果 |
| 数据流 | 通常单向,但依赖团队自律 | 模式层面强制闭环 |
| 调试 | 观察 StateFlow | 可记录完整状态转换日志 |
可以把 MVI 理解为:对单向数据流、不可变状态和状态转换施加了更强约束的表现层架构 。一个 MVVM 页面如果使用单一 UiState、统一事件入口和纯 Reducer,实际上已经具有 MVI 风格。没有必要为了"模式纯度"反复改名。
8. Clean Architecture:让业务规则独立于框架和外部细节
8.1 核心理念
Clean Architecture 的目标不是规定目录必须叫 domain/data/presentation,而是保护高价值、变化慢的业务规则,使它们不依赖变化快的实现细节。
它最重要的规则是:
源码依赖只能指向更内层。内层不能知道外层的类型和框架。
典型分层从内到外如下:
#mermaid-svg-iCMDS48xqUFdFy0q{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-iCMDS48xqUFdFy0q .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-iCMDS48xqUFdFy0q .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-iCMDS48xqUFdFy0q .error-icon{fill:#552222;}#mermaid-svg-iCMDS48xqUFdFy0q .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-iCMDS48xqUFdFy0q .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-iCMDS48xqUFdFy0q .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-iCMDS48xqUFdFy0q .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-iCMDS48xqUFdFy0q .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-iCMDS48xqUFdFy0q .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-iCMDS48xqUFdFy0q .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-iCMDS48xqUFdFy0q .marker{fill:#333333;stroke:#333333;}#mermaid-svg-iCMDS48xqUFdFy0q .marker.cross{stroke:#333333;}#mermaid-svg-iCMDS48xqUFdFy0q svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-iCMDS48xqUFdFy0q p{margin:0;}#mermaid-svg-iCMDS48xqUFdFy0q .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-iCMDS48xqUFdFy0q .cluster-label text{fill:#333;}#mermaid-svg-iCMDS48xqUFdFy0q .cluster-label span{color:#333;}#mermaid-svg-iCMDS48xqUFdFy0q .cluster-label span p{background-color:transparent;}#mermaid-svg-iCMDS48xqUFdFy0q .label text,#mermaid-svg-iCMDS48xqUFdFy0q span{fill:#333;color:#333;}#mermaid-svg-iCMDS48xqUFdFy0q .node rect,#mermaid-svg-iCMDS48xqUFdFy0q .node circle,#mermaid-svg-iCMDS48xqUFdFy0q .node ellipse,#mermaid-svg-iCMDS48xqUFdFy0q .node polygon,#mermaid-svg-iCMDS48xqUFdFy0q .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-iCMDS48xqUFdFy0q .rough-node .label text,#mermaid-svg-iCMDS48xqUFdFy0q .node .label text,#mermaid-svg-iCMDS48xqUFdFy0q .image-shape .label,#mermaid-svg-iCMDS48xqUFdFy0q .icon-shape .label{text-anchor:middle;}#mermaid-svg-iCMDS48xqUFdFy0q .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-iCMDS48xqUFdFy0q .rough-node .label,#mermaid-svg-iCMDS48xqUFdFy0q .node .label,#mermaid-svg-iCMDS48xqUFdFy0q .image-shape .label,#mermaid-svg-iCMDS48xqUFdFy0q .icon-shape .label{text-align:center;}#mermaid-svg-iCMDS48xqUFdFy0q .node.clickable{cursor:pointer;}#mermaid-svg-iCMDS48xqUFdFy0q .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-iCMDS48xqUFdFy0q .arrowheadPath{fill:#333333;}#mermaid-svg-iCMDS48xqUFdFy0q .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-iCMDS48xqUFdFy0q .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-iCMDS48xqUFdFy0q .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-iCMDS48xqUFdFy0q .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-iCMDS48xqUFdFy0q .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-iCMDS48xqUFdFy0q .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-iCMDS48xqUFdFy0q .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-iCMDS48xqUFdFy0q .cluster text{fill:#333;}#mermaid-svg-iCMDS48xqUFdFy0q .cluster span{color:#333;}#mermaid-svg-iCMDS48xqUFdFy0q div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-iCMDS48xqUFdFy0q .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-iCMDS48xqUFdFy0q rect.text{fill:none;stroke-width:0;}#mermaid-svg-iCMDS48xqUFdFy0q .icon-shape,#mermaid-svg-iCMDS48xqUFdFy0q .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-iCMDS48xqUFdFy0q .icon-shape p,#mermaid-svg-iCMDS48xqUFdFy0q .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-iCMDS48xqUFdFy0q .icon-shape .label rect,#mermaid-svg-iCMDS48xqUFdFy0q .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-iCMDS48xqUFdFy0q .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-iCMDS48xqUFdFy0q .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-iCMDS48xqUFdFy0q :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Frameworks & Drivers
Compose / Retrofit / Room
Interface Adapters
ViewModel / Controller / Repository Impl / Mapper
Application Business Rules
Use Cases
Enterprise Business Rules
Entities / Value Objects
箭头表示源码依赖方向,不是运行时调用方向。运行时 Use Case 可以通过接口调用 Repository 实现,但编译时是外层实现依赖内层接口。
8.2 各层的原理和作用
Entities:领域实体与核心规则
Entities 表达最稳定的业务概念、约束和计算。
kotlin
@JvmInline
value class Money private constructor(val cents: Long) {
companion object {
fun ofCents(value: Long): Money {
require(value >= 0) { "金额不能为负数" }
return Money(value)
}
}
}
data class Order(
val id: String,
val total: Money,
val status: OrderStatus,
) {
fun canCancel(): Boolean = status == OrderStatus.PENDING
}
它不应导入 android.*、Retrofit 注解、Room 注解或 UI 类型。
Use Cases:应用特定业务流程
Use Case 编排实体和端口,表达系统能完成的一个动作。
kotlin
class CancelOrderUseCase(
private val orders: OrderRepository,
) {
suspend operator fun invoke(orderId: String): Result<Order> = runCatching {
val order = requireNotNull(orders.find(orderId)) { "订单不存在" }
check(order.canCancel()) { "当前状态不能取消" }
orders.save(order.copy(status = OrderStatus.CANCELLED))
}
}
Use Case 不负责弹 Toast、选择按钮颜色或解析 Retrofit Response。它接收与业务相关的输入,返回业务相关的输出。
Interface Adapters:内外模型适配
这一层把外部格式转换成内层能理解的格式,常见内容有:
- MVC Controller、MVP Presenter、MVVM ViewModel、MVI Store;
- Repository 实现;
- DTO/Entity/Domain/UI Model 的 Mapper;
- Web Controller、Presenter、Gateway Adapter。
Frameworks & Drivers:可替换的技术细节
包括 Compose、Activity、Retrofit、Room、文件系统、推送 SDK、具体 DI 框架等。它们重要,但不应决定领域规则的形状。
8.3 Boundary、Port 和 Adapter
"接口放在哪里"应按所有权决定:接口属于使用它来表达需求的一方。
kotlin
// domain:Use Case 需要的输出端口
interface OrderRepository {
suspend fun find(id: String): Order?
suspend fun save(order: Order): Order
}
// data:外层适配器
class NetworkOrderRepository(
private val api: OrderApi,
private val mapper: OrderMapper,
) : OrderRepository {
override suspend fun find(id: String): Order? =
api.getOrder(id).takeIf { it.found }?.let(mapper::toDomain)
override suspend fun save(order: Order): Order =
api.updateOrder(mapper.toRequest(order)).let(mapper::toDomain)
}
这也是 Dependency Inversion Principle 的实际应用:业务定义端口,基础设施提供适配器。
8.4 跨边界的数据为什么要映射
一个模型从数据库一路传到 UI 看起来省事,但会把外层细节泄漏到内层:
text
OrderResponse (网络字段、可空、字符串时间)
↓ mapper
Order (领域约束、值对象、业务枚举)
↓ mapper
OrderUiModel (格式化价格、展示文本、资源标识)
并非每个小项目都需要为所有层创建重复模型。判断标准是:
- 两侧变化频率是否不同;
- 是否存在语义或格式转换;
- 是否需要阻止框架注解进入领域层;
- 映射带来的保护价值是否大于维护成本。
8.5 Clean Architecture 不是什么
- 不是固定的三模块模板;
- 不是文件夹越多越干净;
- 不是每个操作都必须建一个只有一行代码的 Use Case;
- 不是禁止使用框架,而是把框架放在合适的边界外;
- 不是让所有层互相复制数据;
- 不是为了模式而牺牲交付速度。
9. 四种表现层模式如何与 Clean Architecture 组合
Clean Architecture 约束整个系统的依赖,MVC/MVP/MVVM/MVI 只选择表现层内部的协作方式:
#mermaid-svg-kznWVBWc8l5bEvjX{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-kznWVBWc8l5bEvjX .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-kznWVBWc8l5bEvjX .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-kznWVBWc8l5bEvjX .error-icon{fill:#552222;}#mermaid-svg-kznWVBWc8l5bEvjX .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-kznWVBWc8l5bEvjX .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-kznWVBWc8l5bEvjX .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-kznWVBWc8l5bEvjX .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-kznWVBWc8l5bEvjX .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-kznWVBWc8l5bEvjX .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-kznWVBWc8l5bEvjX .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-kznWVBWc8l5bEvjX .marker{fill:#333333;stroke:#333333;}#mermaid-svg-kznWVBWc8l5bEvjX .marker.cross{stroke:#333333;}#mermaid-svg-kznWVBWc8l5bEvjX svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-kznWVBWc8l5bEvjX p{margin:0;}#mermaid-svg-kznWVBWc8l5bEvjX .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-kznWVBWc8l5bEvjX .cluster-label text{fill:#333;}#mermaid-svg-kznWVBWc8l5bEvjX .cluster-label span{color:#333;}#mermaid-svg-kznWVBWc8l5bEvjX .cluster-label span p{background-color:transparent;}#mermaid-svg-kznWVBWc8l5bEvjX .label text,#mermaid-svg-kznWVBWc8l5bEvjX span{fill:#333;color:#333;}#mermaid-svg-kznWVBWc8l5bEvjX .node rect,#mermaid-svg-kznWVBWc8l5bEvjX .node circle,#mermaid-svg-kznWVBWc8l5bEvjX .node ellipse,#mermaid-svg-kznWVBWc8l5bEvjX .node polygon,#mermaid-svg-kznWVBWc8l5bEvjX .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-kznWVBWc8l5bEvjX .rough-node .label text,#mermaid-svg-kznWVBWc8l5bEvjX .node .label text,#mermaid-svg-kznWVBWc8l5bEvjX .image-shape .label,#mermaid-svg-kznWVBWc8l5bEvjX .icon-shape .label{text-anchor:middle;}#mermaid-svg-kznWVBWc8l5bEvjX .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-kznWVBWc8l5bEvjX .rough-node .label,#mermaid-svg-kznWVBWc8l5bEvjX .node .label,#mermaid-svg-kznWVBWc8l5bEvjX .image-shape .label,#mermaid-svg-kznWVBWc8l5bEvjX .icon-shape .label{text-align:center;}#mermaid-svg-kznWVBWc8l5bEvjX .node.clickable{cursor:pointer;}#mermaid-svg-kznWVBWc8l5bEvjX .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-kznWVBWc8l5bEvjX .arrowheadPath{fill:#333333;}#mermaid-svg-kznWVBWc8l5bEvjX .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-kznWVBWc8l5bEvjX .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-kznWVBWc8l5bEvjX .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kznWVBWc8l5bEvjX .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-kznWVBWc8l5bEvjX .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kznWVBWc8l5bEvjX .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-kznWVBWc8l5bEvjX .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-kznWVBWc8l5bEvjX .cluster text{fill:#333;}#mermaid-svg-kznWVBWc8l5bEvjX .cluster span{color:#333;}#mermaid-svg-kznWVBWc8l5bEvjX div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-kznWVBWc8l5bEvjX .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-kznWVBWc8l5bEvjX rect.text{fill:none;stroke-width:0;}#mermaid-svg-kznWVBWc8l5bEvjX .icon-shape,#mermaid-svg-kznWVBWc8l5bEvjX .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kznWVBWc8l5bEvjX .icon-shape p,#mermaid-svg-kznWVBWc8l5bEvjX .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-kznWVBWc8l5bEvjX .icon-shape .label rect,#mermaid-svg-kznWVBWc8l5bEvjX .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kznWVBWc8l5bEvjX .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-kznWVBWc8l5bEvjX .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-kznWVBWc8l5bEvjX :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} View / Compose
Controller / Presenter
ViewModel / Store
Use Case
Repository Port
Repository Adapter
API / DB / Device
注意 Repository Adapter --> Repository Port 表示实现关系;运行时调用仍从 Use Case 经过接口到具体实现。
9.1 MVC + Clean Architecture
Controller 只调用 Use Case,不直接操作数据库。View 保持轻量,领域实体不感知 Controller。适合简单请求-响应式界面。
9.2 MVP + Clean Architecture
Presenter 是 Interface Adapter:把 View 事件转换成 Use Case 输入,再把 Use Case 输出转换成 View 命令。Presenter 的测试用 Fake View 和 Fake Use Case 完成。
9.3 MVVM + Clean Architecture
ViewModel 调用 Use Case,把领域结果映射为 UiState。Data 层实现 Domain 层的 Repository 接口。这是现代 Android 中最均衡、最常见的组合。
9.4 MVI + Clean Architecture
Store/Processor 调用 Use Case,Reducer 只处理表现层 Mutation。领域层不应该知道 Intent、UiState 或 Reducer。适合复杂表单、实时协作、交易流程、分页筛选等状态密集页面。
10. 一个可落地的 Android 工程结构
可以先按功能划分,再在功能内部体现边界:
text
app/
navigation/ # App 入口、路由、依赖组装
feature-login/
presentation/
LoginRoute.kt # 生命周期收集、导航适配
LoginScreen.kt # 无状态 UI
LoginViewModel.kt # MVVM;或 LoginStore.kt
LoginUiState.kt
LoginEvent.kt
LoginEffect.kt
domain/
LoginUseCase.kt
UserRepository.kt # 领域层拥有端口
User.kt
data/
DefaultUserRepository.kt # 实现领域端口
remote/UserApi.kt
remote/LoginRequest.kt
remote/UserResponse.kt
UserMapper.kt
core-network/
core-database/
大型项目可将 presentation/domain/data 拆成 Gradle 模块来强制依赖规则;小项目先用 package 即可。模块化是保护边界的手段,不是目标。
推荐依赖关系:
text
app -> feature:di/presentation
feature:presentation -> feature:domain
feature:data -> feature:domain
feature:domain -> Kotlin/少量通用库
presentation 不依赖 data 的具体类型;App 或 DI 入口负责把 DefaultUserRepository 注入 LoginUseCase。
11. 测试策略:测试行为和边界,不测试框架本身
| 层/角色 | 测试重点 | 推荐方式 |
|---|---|---|
| Entity / Value Object | 业务不变量、边界值 | 纯单元测试 |
| Use Case | 业务分支、端口交互 | Fake Repository |
| Mapper | 字段、空值、枚举、时间格式 | 输入输出表格测试 |
| Controller / Presenter | 输入到 View 命令的转换 | Fake View + Fake Use Case |
| ViewModel | Event 到 State/Effect | 协程测试调度器 + Flow 收集 |
| Reducer | 旧状态 + Mutation -> 新状态 |
参数化纯函数测试 |
| Repository Adapter | 缓存策略、错误映射、数据源组合 | Fake API/DAO 或集成测试 |
| View | 渲染、语义、关键交互 | Compose/UI 测试 |
MVI Reducer 的示例测试:
kotlin
@Test
fun `Loaded stops loading and replaces items`() {
val old = SearchState(isLoading = true)
val items = listOf(SearchItemUi(id = "1", title = "Clean Architecture"))
val new = reduce(old, SearchMutation.Loaded(items))
assertFalse(new.isLoading)
assertEquals(items, new.items)
assertNull(new.error)
}
测试价值来自清晰边界。若测试 Presenter 必须启动 Activity,或测试 Use Case 必须连真实数据库,说明依赖方向可能已经泄漏。
12. 如何选择
选择 MVC,当你满足这些条件
- 页面与状态非常简单;
- 生命周期和异步任务少;
- 需要最低的抽象与文件数量;
- 团队能保持 Controller 与框架适度解耦。
选择 MVP,当你满足这些条件
- 使用命令式 View/XML,难以自然使用数据绑定;
- 需要把逻辑从 Activity/Fragment 中快速抽离;
- 团队熟悉 Contract 和生命周期管理;
- 现有项目已经有成熟的 Presenter 测试体系。
选择 MVVM,当你满足这些条件
- 使用 Compose、SwiftUI 或响应式 UI;
- 希望通过状态驱动渲染;
- 需要 ViewModel 处理配置变化和异步任务;
- 页面复杂度中等,希望在结构与成本之间平衡。
选择 MVI,当你满足这些条件
- 页面有大量相互约束的状态;
- 多个异步来源可能竞态;
- 需要明确记录每次状态转换;
- 团队愿意统一 Intent、Reducer、Effect 和并发规则。
默认建议
对新的 Android/Compose 项目,可以从以下最小方案开始:
- 使用 MVVM;
- 每个页面一个不可变
UiState; - View 只渲染状态并发送事件;
- ViewModel 调用 Use Case;
- Domain 定义 Repository 接口,Data 提供实现;
- 状态复杂到难以追踪时,再引入 Reducer/MVI 约束。
这是渐进式设计:先保护关键边界,再用真实复杂度证明额外抽象的必要性。
13. 常见误区
误区一:用了 ViewModel 就是 MVVM
如果 Activity 仍然包含业务判断,ViewModel 只存几个变量,数据流并没有真正分离。
误区二:MVI 就是把所有方法改成 Intent
统一事件入口只是表象。没有不可变 State、明确 Reducer、隔离副作用和并发规则,就没有得到 MVI 的主要收益。
误区三:Clean Architecture 必须分成三个 Gradle Module
模块能强制依赖方向,但小项目可先用 package。真正的判断标准是领域代码能否脱离 UI、网络和数据库独立测试。
误区四:Repository 是 DAO 和 API 的简单包装
Repository 表达领域需要的数据能力,可能负责缓存策略、来源协调、错误映射和模型转换。它的接口不应该只是逐个复制 Retrofit 方法。
误区五:所有逻辑都属于 ViewModel
- 业务规则放 Entity/Use Case;
- 跨数据源协调放 Repository;
- 展示格式和页面状态放 ViewModel;
- 像素、动画和平台交互放 View。
误区六:层之间绝不能跳过
Clean Architecture 关心依赖方向,不要求每次调用都机械经过所有层。只读取本地 UI 偏好时,可以按复杂度选择直接使用一个合适抽象,不必制造空洞的转发类。
14. 学习路线:真正需要掌握哪些东西
第一阶段:语言与平台基础
- Kotlin 不可变数据类、密封接口、扩展函数;
- 协程、结构化并发、取消和异常传播;
- Android 生命周期、配置变更、进程重建;
- Compose 状态、重组和状态提升,或传统 View 生命周期。
第二阶段:数据与状态
- State 与 Event 的语义;
Flow、StateFlow、SharedFlow、Channel;- 单向数据流;
- 状态机、Reducer、幂等性和竞态条件。
第三阶段:设计原则
- SOLID,重点是单一职责和依赖倒置;
- Dependency Injection 与对象组装;
- Port/Adapter、Repository、Use Case;
- DTO、Domain Model、UI Model 的映射边界。
第四阶段:验证能力
- Fake、Stub、Mock 的差别;
- 协程与 Flow 测试;
- Contract、集成和 UI 测试;
- 用模块依赖或静态检查阻止边界倒置。
学习时建议用同一个小功能分别写 MVP、MVVM 和 MVI,再比较测试代码、状态恢复和新增需求时需要修改的位置。模式的价值会比只看架构图更直观。
15. 最终结论
- MVC:Controller 协调 View 与 Model,简单直接,但客户端中容易边界模糊。
- MVP:Presenter 通过 View 接口发出命令,适合命令式 UI 和 Presenter 单元测试。
- MVVM:View 观察 ViewModel 状态,适合响应式 UI,是现代 Android 的常见平衡点。
- MVI:Intent 经处理和归约形成新 State,数据流最受约束,适合状态复杂且需要可追踪性的页面。
- Clean Architecture:保护业务规则,通过依赖规则、边界、Port 和 Adapter 让框架成为可替换细节。
不要从"哪个缩写最先进"出发。先评估状态复杂度、生命周期、并发、测试需求和团队认知,再选择足够解决问题的约束。好的架构不是拥有最多的层,而是让变化被限制在正确的位置。