系列一:架构思想进阶 | 第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 强制禁止跨层调用。)

相关推荐
美狐美颜sdk3 小时前
直播APP开发技术栈详解:视频美颜SDK、人脸识别与实时渲染
android·人工智能·音视频·美颜sdk·直播美颜sdk
mldong3 小时前
一份 JSON,一条能跑的审批流:把报销流程送上工作流引擎
后端·架构
这个DBA有点耶6 小时前
异构数据集成怎么做?5 种同步方案对比 + 金融级 CDC 实战解析
数据库·oracle·架构
这个DBA有点耶7 小时前
MySQL 8.0.20移除了Block Nested Loop,之前学的JOIN优化知识还适用吗?
数据库·mysql·架构
陆柒1457 小时前
从洋葱模型到 Elpis Core:我对 Node.js 服务端开发的理解
架构
懂软件的胡子个哥7 小时前
微信 API 消息回调怎么设计,才能避免丢消息和重复处理
运维·微信·架构·wechatapi·个人微信号二次开发
奈斯先生Vector8 小时前
当模型版本不断变化,RelayRouter 能否帮助 AI 应用摆脱深度绑定
android·java·人工智能·开源·aigc
TimeFine8 小时前
让强模型做“总工”,让高性价比模型写代码
android
又见情义10 小时前
RK3568 Android 13 屏蔽 healthd 电池日志经验分享
android
co松柏10 小时前
一文吃透 Pi:10w stars 的极简 Agent harness
后端·架构