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

MVC、MVP、MVVM、MVI 都在回答同一个问题:界面、状态和业务行为应该如何协作? Clean Architecture 回答的是更大范围的问题:业务规则如何保持独立,代码依赖应该指向哪里,系统边界应该如何划分?

因此,它们不是五个互斥选项。一个项目完全可以在表现层使用 MVVM 或 MVI,同时在整个工程中遵循 Clean Architecture。

本文以 Android/Kotlin 为例,但原理同样适用于前端、桌面端和其他客户端开发。重点不是背缩写,而是理解:

  • 谁持有状态;
  • 谁接收用户事件;
  • 谁执行决策;
  • 谁更新界面;
  • 依赖指向哪里;
  • 每一部分如何单独测试。

1. 先建立共同语言

1.1 数据、状态、事件和副作用

概念 含义 示例
Data 从数据库、网络或传感器获得的原始数据 UserDto、数据库行
Domain Model 能表达业务含义的领域对象 UserOrder
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 需要理解什么

  • 生命周期感知收集与结构化并发;
  • 冷流、热流、StateFlowSharedFlowChannel 的语义差异;
  • 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 的语义;
  • FlowStateFlowSharedFlowChannel
  • 单向数据流;
  • 状态机、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 让框架成为可替换细节。

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

相关推荐
事圆则缓1 小时前
Android组件化指南
android·kotlin
binbin_522 小时前
HarmonyOS 应用功耗优化实战:定位耗电、收口任务与验证回归
android·回归·harmonyos
AFinalStone3 小时前
Android 7系统无障碍服务(七)手势分发与 KeyEvent 处理
android·无障碍服务
事圆则缓3 小时前
Jetpack Compose Effect 完全指南
android·kotlin
平头哥技术团队12 小时前
Day 10 | 工欲善其事:VS Code 配置与项目归档
android·开发语言·前端·javascript·html·交互
冬木家居13 小时前
40㎡客厅变形记,小家住出大自由[特殊字符]
android·经验分享·笔记·智能家居·微信公众平台
AFinalStone14 小时前
Android 7系统无障碍服务(六)输入事件拦截与 TouchExplorer
android·无障碍服务
天空之城--14 小时前
MT管理器Android逆向工程完全指南:从入门到实战
android
Anhty15 小时前
2026九月最新变声器测评:iOS安卓双端适配,低延迟运行更稳定
android·人工智能·功能测试·ios·智能手机