📱 系列一:架构思想进阶 | 第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
1.2 Android 里的"伪 MVC"
由于 Android 的 XML 布局能力太弱,无法处理业务逻辑,所有的事件(Click、TextChange)都必须回到 Activity 或 Fragment 中处理。
这就导致了:
- View:XML 布局。
- Controller :
Activity/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 的结构
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 的结构
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 为什么是现在的行业标准?
- 自动绑定 :不需要
view.showLoading(),数据变了 UI 自动变。 - 生命周期安全 :
LiveData/StateFlow能感知 Activity 的生命周期,页面死了就不会再回调。 - 代码简洁:省去了大量的接口定义和回调代码。
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 强制禁止跨层调用。)