MVC、MVP、MVVM、MVI 的区别与实现原理

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。典型过程是:

  1. View 把点击事件交给 Controller;
  2. Controller 校验输入并调用 Model;
  3. Model 返回结果;
  4. 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 项目,可以从以下最小方案开始:

  1. 使用 MVVM;
  2. 每个页面一个不可变 UiState;
  3. View 只渲染状态并发送事件;
  4. ViewModel 调用 Use Case;
  5. Domain 定义 Repository 接口,Data 提供实现;
  6. 状态复杂到难以追踪时,再引入 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 让框架成为可替换细节。

不要从"哪个缩写最先进"出发。先评估状态复杂度、生命周期、并发、测试需求和团队认知,再选择足够解决问题的约束。好的架构不是拥有最多的层,而是让变化被限制在正确的位置。

相关推荐
千里马学框架4 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台4 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone4 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc4 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo4 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077004 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
ai2work4 天前
ch23 综合复刻:从零做一个最小可用版本(capstone)
kotlin
其实防守也摸鱼4 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
ai2work4 天前
ch21 签名、校验与发版
kotlin
AFinalStone4 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui