系列一:架构思想进阶 | 第1篇 Android 架构演进实录:从 MVC 的“万能类”到 MVVM 的数据驱动

📱 系列一:架构思想进阶 | 第1篇

Android 架构演进实录:从 MVC 的"万能类"到 MVVM 的数据驱动

本文导读

你是否曾面对一个几千行代码的 Activity 感到绝望?是否因为改一处逻辑而引发三个地方的崩溃?

本文将带你复盘 Android 架构从 MVC、MVP 到 MVVM 的演进历程。我们不仅会聊透原理,还会通过可运行的代码架构流程图企业级避坑指南 ,帮你彻底摆脱"代码屎山"的困扰。

全文较长,建议收藏后阅读。


0. 引子:为什么你的 Activity 会变成"屎山"?

在 Android 开发的早期,写代码是一件很"快"的事。

产品要一个登录页,你创建一个 LoginActivity,写下 findViewById,写一个 OnClickListener,在里面直接调用网络请求,把结果存进 SharedPreferences,然后跳转到主页。整个过程行云流水,半小时搞定。

但问题也随之而来。

几个月后,需求开始堆叠:

  • 增加"记住密码"功能。
  • 增加"第三方登录"(微信、QQ、Apple)。
  • 增加"验证码登录"与"密码登录"的切换。
  • 增加"风控校验"、"协议勾选"、"防抖处理"。

你开始往 LoginActivity 里不断塞代码。最终,它变成了一个拥有 3000 行代码 的"万能类"。此时,哪怕只是改一个文案,你都要小心翼翼,生怕触动了某根脆弱的逻辑链条。

这不是你一个人的问题,而是架构缺失的必然结果。


1. MVC:Android 世界的"默认陷阱"

教科书上的 MVC(Model-View-Controller)是清晰的,但在 Android 里,它往往变了味。

1.1 理论上的 MVC

#mermaid-svg-aTBcgmGST27xzw0b{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-aTBcgmGST27xzw0b .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-aTBcgmGST27xzw0b .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-aTBcgmGST27xzw0b .error-icon{fill:#552222;}#mermaid-svg-aTBcgmGST27xzw0b .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-aTBcgmGST27xzw0b .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-aTBcgmGST27xzw0b .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-aTBcgmGST27xzw0b .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-aTBcgmGST27xzw0b .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-aTBcgmGST27xzw0b .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-aTBcgmGST27xzw0b .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-aTBcgmGST27xzw0b .marker{fill:#333333;stroke:#333333;}#mermaid-svg-aTBcgmGST27xzw0b .marker.cross{stroke:#333333;}#mermaid-svg-aTBcgmGST27xzw0b svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-aTBcgmGST27xzw0b p{margin:0;}#mermaid-svg-aTBcgmGST27xzw0b .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-aTBcgmGST27xzw0b .cluster-label text{fill:#333;}#mermaid-svg-aTBcgmGST27xzw0b .cluster-label span{color:#333;}#mermaid-svg-aTBcgmGST27xzw0b .cluster-label span p{background-color:transparent;}#mermaid-svg-aTBcgmGST27xzw0b .label text,#mermaid-svg-aTBcgmGST27xzw0b span{fill:#333;color:#333;}#mermaid-svg-aTBcgmGST27xzw0b .node rect,#mermaid-svg-aTBcgmGST27xzw0b .node circle,#mermaid-svg-aTBcgmGST27xzw0b .node ellipse,#mermaid-svg-aTBcgmGST27xzw0b .node polygon,#mermaid-svg-aTBcgmGST27xzw0b .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-aTBcgmGST27xzw0b .rough-node .label text,#mermaid-svg-aTBcgmGST27xzw0b .node .label text,#mermaid-svg-aTBcgmGST27xzw0b .image-shape .label,#mermaid-svg-aTBcgmGST27xzw0b .icon-shape .label{text-anchor:middle;}#mermaid-svg-aTBcgmGST27xzw0b .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-aTBcgmGST27xzw0b .rough-node .label,#mermaid-svg-aTBcgmGST27xzw0b .node .label,#mermaid-svg-aTBcgmGST27xzw0b .image-shape .label,#mermaid-svg-aTBcgmGST27xzw0b .icon-shape .label{text-align:center;}#mermaid-svg-aTBcgmGST27xzw0b .node.clickable{cursor:pointer;}#mermaid-svg-aTBcgmGST27xzw0b .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-aTBcgmGST27xzw0b .arrowheadPath{fill:#333333;}#mermaid-svg-aTBcgmGST27xzw0b .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-aTBcgmGST27xzw0b .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-aTBcgmGST27xzw0b .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-aTBcgmGST27xzw0b .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-aTBcgmGST27xzw0b .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-aTBcgmGST27xzw0b .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-aTBcgmGST27xzw0b .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-aTBcgmGST27xzw0b .cluster text{fill:#333;}#mermaid-svg-aTBcgmGST27xzw0b .cluster span{color:#333;}#mermaid-svg-aTBcgmGST27xzw0b 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-aTBcgmGST27xzw0b .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-aTBcgmGST27xzw0b rect.text{fill:none;stroke-width:0;}#mermaid-svg-aTBcgmGST27xzw0b .icon-shape,#mermaid-svg-aTBcgmGST27xzw0b .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-aTBcgmGST27xzw0b .icon-shape p,#mermaid-svg-aTBcgmGST27xzw0b .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-aTBcgmGST27xzw0b .icon-shape rect,#mermaid-svg-aTBcgmGST27xzw0b .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-aTBcgmGST27xzw0b .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-aTBcgmGST27xzw0b .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-aTBcgmGST27xzw0b :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户操作
读写
通知更新
View(界面)
Controller(逻辑)
Model(数据)

1.2 Android 里的"伪 MVC"

由于 Android 的 XML 布局能力太弱,无法处理业务逻辑,所有的事件(Click、TextChange)都必须回到 ActivityFragment 中处理。

这就导致了:

  • View:XML 布局。
  • ControllerActivity / Fragment
  • Model:数据获取与存储。

Activity 被迫同时承担了 View 和 Controller 的双重角色。

1.3 代码实况:经典的 MVC 写法
kotlin 复制代码
// ❌ 典型的 MVC 写法(也是"屎山"的起点)
class LoginActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_login)

        val etAccount = findViewById<EditText>(R.id.etAccount)
        val etPwd = findViewById<EditText>(R.id.etPwd)
        val btnLogin = findViewById<Button>(R.id.btnLogin)
        val tvTip = findViewById<TextView>(R.id.tvTip)

        // 读取本地缓存
        val sp = getSharedPreferences("config", MODE_PRIVATE)
        val lastAccount = sp.getString("last_account", "")
        etAccount.setText(lastAccount)

        btnLogin.setOnClickListener {
            val account = etAccount.text.toString()
            val pwd = etPwd.text.toString()

            // 1. 校验逻辑(业务)
            if (account.isBlank() || pwd.isBlank()) {
                tvTip.text = "请输入账号密码"
                return@setOnClickListener
            }

            // 2. 更新 UI(View)
            btnLogin.isEnabled = false
            tvTip.text = "登录中..."

            // 3. 网络请求(直接裸线程)
            Thread {
                val result = URL("https://api.xxx.com/login?a=$account&p=$pwd").readText()
                
                // 4. 回到主线程更新 UI
                runOnUiThread {
                    btnLogin.isEnabled = true
                    tvTip.text = "登录成功"
                    // 5. 存储数据
                    sp.edit().putString("last_account", account).apply()
                    // 6. 跳转
                    startActivity(Intent(this, HomeActivity::class.java))
                    finish()
                }
            }.start()
        }
    }
}
1.4 为什么 MVC 会腐烂?
致命伤 后果
生命周期耦合 屏幕旋转或后台回收时,线程还在跑,Activity 已销毁,直接导致 NPE(空指针)。
职责混乱 一个类里既有 TextView 的操作,又有 Thread 的调度,还有 SharedPreferences 的读写。
无法测试 逻辑锁死在 Android 组件中,脱离手机根本跑不起来单元测试。
复用性为零 登录逻辑写死在 Activity 里,其他地方想复用?只能复制粘贴。

2. MVP:第一次尝试"解耦"

为了解决 MVC 的耦合问题,MVP(Model-View-Presenter)登场了。它的核心思想是:把业务逻辑从 Activity 里抽出来,放到 Presenter 中。

2.1 MVP 的结构

#mermaid-svg-Hq3WwueyMT2nNXkm{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-Hq3WwueyMT2nNXkm .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Hq3WwueyMT2nNXkm .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Hq3WwueyMT2nNXkm .error-icon{fill:#552222;}#mermaid-svg-Hq3WwueyMT2nNXkm .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Hq3WwueyMT2nNXkm .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Hq3WwueyMT2nNXkm .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Hq3WwueyMT2nNXkm .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Hq3WwueyMT2nNXkm .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Hq3WwueyMT2nNXkm .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Hq3WwueyMT2nNXkm .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Hq3WwueyMT2nNXkm .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Hq3WwueyMT2nNXkm .marker.cross{stroke:#333333;}#mermaid-svg-Hq3WwueyMT2nNXkm svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Hq3WwueyMT2nNXkm p{margin:0;}#mermaid-svg-Hq3WwueyMT2nNXkm .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Hq3WwueyMT2nNXkm .cluster-label text{fill:#333;}#mermaid-svg-Hq3WwueyMT2nNXkm .cluster-label span{color:#333;}#mermaid-svg-Hq3WwueyMT2nNXkm .cluster-label span p{background-color:transparent;}#mermaid-svg-Hq3WwueyMT2nNXkm .label text,#mermaid-svg-Hq3WwueyMT2nNXkm span{fill:#333;color:#333;}#mermaid-svg-Hq3WwueyMT2nNXkm .node rect,#mermaid-svg-Hq3WwueyMT2nNXkm .node circle,#mermaid-svg-Hq3WwueyMT2nNXkm .node ellipse,#mermaid-svg-Hq3WwueyMT2nNXkm .node polygon,#mermaid-svg-Hq3WwueyMT2nNXkm .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Hq3WwueyMT2nNXkm .rough-node .label text,#mermaid-svg-Hq3WwueyMT2nNXkm .node .label text,#mermaid-svg-Hq3WwueyMT2nNXkm .image-shape .label,#mermaid-svg-Hq3WwueyMT2nNXkm .icon-shape .label{text-anchor:middle;}#mermaid-svg-Hq3WwueyMT2nNXkm .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Hq3WwueyMT2nNXkm .rough-node .label,#mermaid-svg-Hq3WwueyMT2nNXkm .node .label,#mermaid-svg-Hq3WwueyMT2nNXkm .image-shape .label,#mermaid-svg-Hq3WwueyMT2nNXkm .icon-shape .label{text-align:center;}#mermaid-svg-Hq3WwueyMT2nNXkm .node.clickable{cursor:pointer;}#mermaid-svg-Hq3WwueyMT2nNXkm .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Hq3WwueyMT2nNXkm .arrowheadPath{fill:#333333;}#mermaid-svg-Hq3WwueyMT2nNXkm .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Hq3WwueyMT2nNXkm .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Hq3WwueyMT2nNXkm .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Hq3WwueyMT2nNXkm .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Hq3WwueyMT2nNXkm .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Hq3WwueyMT2nNXkm .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Hq3WwueyMT2nNXkm .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Hq3WwueyMT2nNXkm .cluster text{fill:#333;}#mermaid-svg-Hq3WwueyMT2nNXkm .cluster span{color:#333;}#mermaid-svg-Hq3WwueyMT2nNXkm 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-Hq3WwueyMT2nNXkm .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Hq3WwueyMT2nNXkm rect.text{fill:none;stroke-width:0;}#mermaid-svg-Hq3WwueyMT2nNXkm .icon-shape,#mermaid-svg-Hq3WwueyMT2nNXkm .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Hq3WwueyMT2nNXkm .icon-shape p,#mermaid-svg-Hq3WwueyMT2nNXkm .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Hq3WwueyMT2nNXkm .icon-shape rect,#mermaid-svg-Hq3WwueyMT2nNXkm .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Hq3WwueyMT2nNXkm .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Hq3WwueyMT2nNXkm .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Hq3WwueyMT2nNXkm :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户事件
调用
结果
接口回调
View(Activity)\n实现接口
Presenter(逻辑)
Model(数据)

2.2 代码实况:MVP 的标准写法

1. 定义 View 接口(把 Activity 的能力抽象出来)

kotlin 复制代码
interface LoginContract {
    interface View {
        fun showLoading()
        fun hideLoading()
        fun showError(msg: String)
        fun navigateToHome()
        fun isActive(): Boolean // 关键:判断页面是否还活着
    }

    interface Presenter {
        fun attach(view: View)
        fun detach()
        fun login(account: String, pwd: String)
    }
}

2. 实现 Presenter(纯业务逻辑,不碰 UI 控件)

kotlin 复制代码
class LoginPresenter : LoginContract.Presenter {

    private var view: LoginContract.View? = null

    override fun attach(view: LoginContract.View) {
        this.view = view
    }

    override fun detach() {
        this.view = null // 断开引用,防止内存泄漏
    }

    override fun login(account: String, pwd: String) {
        if (account.isBlank() || pwd.isBlank()) {
            if (view?.isActive() == true) {
                view?.showError("账号密码不能为空")
            }
            return
        }

        view?.showLoading()

        // 模拟网络请求(实际项目会用 Coroutine)
        Thread {
            Thread.sleep(1200)
            if (view?.isActive() == true) {
                // 回到主线程
                Handler(Looper.getMainLooper()).post {
                    view?.hideLoading()
                    view?.navigateToHome()
                }
            }
        }.start()
    }
}

3. Activity 作为 View 层

kotlin 复制代码
class LoginActivity : AppCompatActivity(), LoginContract.View {

    private lateinit var presenter: LoginPresenter

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_login)

        presenter = LoginPresenter()
        presenter.attach(this)

        findViewById<Button>(R.id.btnLogin).setOnClickListener {
            val account = findViewById<EditText>(R.id.etAccount).text.toString()
            val pwd = findViewById<EditText>(R.id.etPwd).text.toString()
            presenter.login(account, pwd)
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        presenter.detach() // 必须断开,否则内存泄漏
    }

    // ---- 实现 View 接口 ----
    override fun showLoading() {
        findViewById<ProgressBar>(R.id.progressBar).isVisible = true
    }

    override fun hideLoading() {
        findViewById<ProgressBar>(R.id.progressBar).isVisible = false
    }

    override fun showError(msg: String) {
        Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
    }

    override fun navigateToHome() {
        startActivity(Intent(this, HomeActivity::class.java))
        finish()
    }

    override fun isActive(): Boolean = !isFinishing && !isDestroyed
}
2.3 MVP 的得与失

  • 代码终于分层了,Activity 不再是一坨浆糊。
  • Presenter 是纯 Kotlin/Java 类,可以脱离手机做单元测试。

  • 接口爆炸:一个简单的页面要写 View、Presenter、Contract 三个接口。
  • 内存泄漏风险 :如果忘了调用 detach(),Presenter 持有的 View 引用会导致 Activity 无法回收。
  • 双向依赖:View 调用 Presenter,Presenter 又回调 View,容易形成复杂的调用链。

3. MVVM:正统写法(数据驱动 UI)

MVP 虽然解耦了,但依然需要手动调用接口(view.showLoading())。MVVM(Model-View-ViewModel) 的出现,彻底改变了这一点。

MVVM 的核心思想只有一个:数据驱动 UI。

你不再需要告诉 View "显示 Loading",你只需要改变 ViewModel 里的 isLoading 变量,View 会自动根据这个变量更新自己。

3.1 MVVM 的结构

#mermaid-svg-5jTxQdgBSg5hi6U6{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-5jTxQdgBSg5hi6U6 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-5jTxQdgBSg5hi6U6 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-5jTxQdgBSg5hi6U6 .error-icon{fill:#552222;}#mermaid-svg-5jTxQdgBSg5hi6U6 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-5jTxQdgBSg5hi6U6 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-5jTxQdgBSg5hi6U6 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-5jTxQdgBSg5hi6U6 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-5jTxQdgBSg5hi6U6 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-5jTxQdgBSg5hi6U6 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-5jTxQdgBSg5hi6U6 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-5jTxQdgBSg5hi6U6 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-5jTxQdgBSg5hi6U6 .marker.cross{stroke:#333333;}#mermaid-svg-5jTxQdgBSg5hi6U6 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-5jTxQdgBSg5hi6U6 p{margin:0;}#mermaid-svg-5jTxQdgBSg5hi6U6 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-5jTxQdgBSg5hi6U6 .cluster-label text{fill:#333;}#mermaid-svg-5jTxQdgBSg5hi6U6 .cluster-label span{color:#333;}#mermaid-svg-5jTxQdgBSg5hi6U6 .cluster-label span p{background-color:transparent;}#mermaid-svg-5jTxQdgBSg5hi6U6 .label text,#mermaid-svg-5jTxQdgBSg5hi6U6 span{fill:#333;color:#333;}#mermaid-svg-5jTxQdgBSg5hi6U6 .node rect,#mermaid-svg-5jTxQdgBSg5hi6U6 .node circle,#mermaid-svg-5jTxQdgBSg5hi6U6 .node ellipse,#mermaid-svg-5jTxQdgBSg5hi6U6 .node polygon,#mermaid-svg-5jTxQdgBSg5hi6U6 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-5jTxQdgBSg5hi6U6 .rough-node .label text,#mermaid-svg-5jTxQdgBSg5hi6U6 .node .label text,#mermaid-svg-5jTxQdgBSg5hi6U6 .image-shape .label,#mermaid-svg-5jTxQdgBSg5hi6U6 .icon-shape .label{text-anchor:middle;}#mermaid-svg-5jTxQdgBSg5hi6U6 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-5jTxQdgBSg5hi6U6 .rough-node .label,#mermaid-svg-5jTxQdgBSg5hi6U6 .node .label,#mermaid-svg-5jTxQdgBSg5hi6U6 .image-shape .label,#mermaid-svg-5jTxQdgBSg5hi6U6 .icon-shape .label{text-align:center;}#mermaid-svg-5jTxQdgBSg5hi6U6 .node.clickable{cursor:pointer;}#mermaid-svg-5jTxQdgBSg5hi6U6 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-5jTxQdgBSg5hi6U6 .arrowheadPath{fill:#333333;}#mermaid-svg-5jTxQdgBSg5hi6U6 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-5jTxQdgBSg5hi6U6 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-5jTxQdgBSg5hi6U6 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-5jTxQdgBSg5hi6U6 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-5jTxQdgBSg5hi6U6 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-5jTxQdgBSg5hi6U6 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-5jTxQdgBSg5hi6U6 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-5jTxQdgBSg5hi6U6 .cluster text{fill:#333;}#mermaid-svg-5jTxQdgBSg5hi6U6 .cluster span{color:#333;}#mermaid-svg-5jTxQdgBSg5hi6U6 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-5jTxQdgBSg5hi6U6 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-5jTxQdgBSg5hi6U6 rect.text{fill:none;stroke-width:0;}#mermaid-svg-5jTxQdgBSg5hi6U6 .icon-shape,#mermaid-svg-5jTxQdgBSg5hi6U6 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-5jTxQdgBSg5hi6U6 .icon-shape p,#mermaid-svg-5jTxQdgBSg5hi6U6 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-5jTxQdgBSg5hi6U6 .icon-shape rect,#mermaid-svg-5jTxQdgBSg5hi6U6 .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-5jTxQdgBSg5hi6U6 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-5jTxQdgBSg5hi6U6 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-5jTxQdgBSg5hi6U6 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户事件
数据变化自动通知
调用
View(Activity/Fragment)
ViewModel(持有数据)
Model(数据)

3.2 代码实况:正统 MVVM(无 State 聚合)

注意:这里讲的是纯粹的 MVVM。ViewModel 可以有多个独立的数据状态,不需要强行聚合为一个 UiState

1. ViewModel(持有数据,提供方法)

kotlin 复制代码
class LoginViewModel : ViewModel() {

    // 多个独立的状态(正统 MVVM 允许这样)
    private val _isLoading = MutableLiveData(false)
    val isLoading: LiveData<Boolean> = _isLoading

    private val _error = MutableLiveData<String?>()
    val error: LiveData<String?> = _error

    private val _loginSuccess = SingleLiveEvent<Boolean>()
    val loginSuccess: LiveData<Boolean> = _loginSuccess

    // 业务逻辑
    fun login(account: String, password: String) {
        if (account.isBlank() || password.isBlank()) {
            _error.value = "账号/密码不能为空"
            return
        }

        _isLoading.value = true
        _error.value = null

        // 使用 viewModelScope,页面销毁自动取消
        viewModelScope.launch {
            delay(1200) // 模拟网络
            _isLoading.value = false
            _loginSuccess.value = true
        }
    }
}

2. View 层(只观察,不指挥)

kotlin 复制代码
class LoginActivity : AppCompatActivity() {

    private val viewModel: LoginViewModel by viewModels()
    private lateinit var binding: ActivityLoginBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityLoginBinding.inflate(layoutInflater)
        setContentView(binding.root)

        // 1. 观察数据变化
        viewModel.isLoading.observe(this) { isLoading ->
            binding.progressBar.isVisible = isLoading
            binding.btnLogin.isEnabled = !isLoading
        }

        viewModel.error.observe(this) { error ->
            binding.tvTip.text = error ?: ""
        }

        viewModel.loginSuccess.observe(this) { success ->
            if (success) {
                startActivity(Intent(this, HomeActivity::class.java))
                finish()
            }
        }

        // 2. 发送事件
        binding.btnLogin.setOnClickListener {
            viewModel.login(
                binding.etAccount.text.toString(),
                binding.etPwd.text.toString()
            )
        }
    }
}
3.3 MVVM 为什么是现在的行业标准?
  1. 自动绑定 :不需要 view.showLoading(),数据变了 UI 自动变。
  2. 生命周期安全LiveData / StateFlow 能感知 Activity 的生命周期,页面死了就不会再回调。
  3. 代码简洁:省去了大量的接口定义和回调代码。

4. 避坑指南:MVVM 的常见误区

虽然 MVVM 很好,但在企业项目中,如果不守规矩,依然会写出烂代码。

4.1 误区一:ViewModel 持有 View 引用
kotlin 复制代码
// ❌ 绝对禁止
class BadViewModel : ViewModel() {
    private var activity: Activity? = null // 必泄漏
}

正解:ViewModel 永远只持有数据,不持有任何 Android View 或 Context(Application Context 除外)。

4.2 误区二:在 ViewModel 里直接操作 UI
kotlin 复制代码
// ❌ 绝对禁止
class BadViewModel : ViewModel() {
    fun login() {
        Toast.makeText(getApplication(), "登录成功", Toast.LENGTH_SHORT).show() // ViewModel 不该管 Toast
    }
}

正解:Toast、Dialog、Navigation 都是 View 的行为,ViewModel 只负责告诉 View "发生了什么"(通过 LiveData/Event),View 自己决定怎么展示。

4.3 误区三:状态碎片化导致不一致

当一个页面有很多状态时,如果不加约束,会出现这种情况:

kotlin 复制代码
// ⚠️ 危险信号
class FragmentedViewModel : ViewModel() {
    val loading = MutableLiveData(false)
    val data = MutableLiveData<List<Item>>()
    val error = MutableLiveData<String?>()
    val buttonEnabled = MutableLiveData(true)
    val refreshing = MutableLiveData(false)
    // 当 loading=true 时,buttonEnabled 应该是什么?没人记得,全靠运气。
}

解决方案 :当状态超过 5 个,且彼此有强关联时,请使用 MVI(单向数据流) 来聚合状态。

(注:本篇专注于 MVVM,MVI 将在后续系列中详细展开)


5. 总结:如何选择?

架构 适用场景 不建议场景
MVC 个人 Demo、生命周期极短的脚本 商业项目、多人协作
MVP 老项目维护、对测试要求极高的模块 新项目(已被 MVVM 取代)
MVVM 99% 的商业项目 状态极度复杂的页面(需配合 MVI)

一句话总结

MVC 是青春期的冲动,MVP 是成长的阵痛,MVVM 是成熟的代价。

请在你的新项目中,坚定不移地使用 MVVM


下期预告

系列一:架构思想进阶 | 第2篇:分层架构实战 ------ 四层拆分与单向依赖

(我们将讨论:为什么要把代码拆成 data/domain/presentation,以及如何用 Gradle 强制禁止跨层调用。)

相关推荐
qingy_20462 小时前
【架构师之路】绪论
微服务·云原生·架构
Quz3 小时前
Qt Quick 粒子系统(一):架构总览与四层模型
qt·架构·qml
故渊at3 小时前
系列二:MVVM 深度实战与项目重构 | 第4篇 MVVM 完整架构搭建:从零打造企业级框架(Base 封装、全局状态与生命周期铁律)
重构·架构
●VON3 小时前
AtomGit Flutter鸿蒙客户端:项目架构概览
flutter·华为·架构·harmonyos·鸿蒙
流星白龙3 小时前
【MySQL高阶】22.双写缓冲区,重做日志
android·mysql·adb
世人万千丶3 小时前
鸿蒙PC问题解决:窗口配置错误修复指南
android·学习·华为·开源·harmonyos·鸿蒙·鸿蒙系统
故渊at3 小时前
第一板块:Android 系统基石与运行原理 | 第一篇:Android 系统架构分层与 AOSP 规范
android·系统架构·android系统·aosp
zandy10114 小时前
实时客户预警系统设计:体验家 XMPlus 规则引擎从 0 到 1 的架构思考
架构·规则引擎