Interview#1 历史演进:MVC · MVP · MVVM · MVI架构详解

MVC → MVP → MVVM 的本质,是不断把 Activity 从"上帝类"里解救出来。

Model

Model层:不是简单的Entity类,而是代表一整个Entity / DAO / API / Repository的所有集合。

获取数据流程为什么要这么设计?

  1. 单一职责:一个类只因为一个原因改变,如果不分层,一个类会因为 4 种原因被改变。
  1. 可维护性 :一个如果不分层的Model层只有一个Repository文件,改数据库要动Repository,改接口要动Repository,改缓存策略要动Repository,测试极其困难。
  1. 可测试性:单元测试中这样每层就可以独立测试,不会耦合严重,每次功能测试都要mock一大堆对象。
  1. 可替代性:高层不依赖底层实现,只依赖接口。

分层表面只是"多写代码",但是让每一层"只为一个理由改变"。

这是为了遵循单一职责原则和关注点分离。Entity 只描述数据结构;DAO 负责本地数据库访问;API 负责网络通信;Repository 作为数据层的总入口,封装数据来源决策、缓存策略和错误处理逻辑。分层带来了更好的可维护性、可测试性和可替换性,使得上层业务不依赖具体的数据实现。

View

中间的V在不同架构中都代表View的概念,负责界面展示角色,狭义指代不同的View对象,界面XMLCompose就是典型View角色。

在MVC中,Activity在管理View同时又有业务逻辑;演化到MVP中,Activity彻底变成了单纯的View角色。

MVC

MVC = Model + View + Controller

Controller(控制器,中间调度层),MVC是Android最初的官方形态,,Activity/FragmentController,管理非常多的View。极容易导致臃肿、出现难以维护的问题,往往几千行的Activity就是这么来的。

kotlin 复制代码
class LoginActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_login)
        findViewById<Button>(R.id.btnLogin).setOnClickListener {
            val user = findViewById<EditText>(R.id.etUser).text.toString()
            val pwd = findViewById<EditText>(R.id.etPwd).text.toString()
            // 1. 校验
            if (user.isEmpty()) {
                Toast.makeText(this, "用户名不能为空", SHORT).show()
                return
            }
            // 2. 调 Model
            val result = UserRepository.login(user, pwd)=
            // 3. 更新 View
            if (result) {
                startActivity(Intent(this, MainActivity::class.java))
            } else {
                Toast.makeText(this, "登录失败", SHORT).show()
            }
        }
    }
}

在 Android 早期,官方推荐使用 MVC 架构:XML 作为 View,Activity/Fragment 作为 Controller,Model 负责数据。但由于 Activity 同时承担了 View 和 Controller 的职责,导致代码臃肿、难以测试。随后 Google 推出了 MVP,通过 Presenter 和 View 接口解耦;2017 年后全面转向 MVVM,利用 ViewModel 和 LiveData / StateFlow 实现响应式数据驱动,彻底解决了生命周期和耦合问题。

缺点

Activity同时承担了ViewController的职责,导致代码臃肿、难以测试。

MVP

MVP = Model + View + Presenter

kotlin 复制代码
// View
class LoginActivity : AppCompatActivity(), LoginView {
    private val presenter = LoginPresenter(this)

    fun onClickLogin() {
        presenter.login(username, password)
    }

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

// Presenter
class LoginPresenter(private val view: LoginView) {
    fun login(user: String, pwd: String) {
        if (user.isEmpty()) {
            view.showError("用户名不能为空")
            return
        }
        // 调用 Model
    }
}

MVP中需要做的事:

  1. 新增一个View接口,抽象出若干方法,被Activity实现;

  2. 新增一个Presenter类,Presenter依赖注入View接口对象,即Acticity

  3. Activity中组合一个Presenter对象,所有业务逻辑在Activity中被封装成函数。

用户事件派发下来,这些封装函数直接调用Presenter对象中的View接口方法,接口方法已被实现在Activity中,本质上还是调用Activity

原生MVCActivity既是View又是Controller,业务代码全部写在Activity里,代码强依赖Context、控件、生命周期,单元测试必须跑模拟器 / 真机,测试成本极高。

MVP :所有业务逻辑全部剥离到PresenterPresenter只依赖抽象IView接口,不依赖任何Android框架类。 单元测试时,不用创建Activity,直接手写一个MockIView假实现注入Presenter,就能完整测试登录、列表加载等业务逻辑。

缺点

ViewModel 通过 Presenter 手动对接,接口满天飞,解耦但臃肿,Presenter持有View引用,如果Activity销毁后没处理好,极易导致内存泄漏。

题外:MVP双向循环强引用

调用链:

Activity------>Presenter------>View接口------>Activity

MVP存在双向循环强引用 ,但它不是无法解决的死循环;通过页面销毁时 detachView() 将 Presenter 中的 View 引用置空,手动切断反向引用链,GC 就能正常回收两个对象,彻底解决内存泄漏问题。

MVVM

MVP 通过Presenter持有View接口来实现解耦,ViewPresenter相互引用,通信依赖接口回调;

MVVM 通过数据绑定/响应式流StateFlow/LiveData)实现ViewViewModel的自动同步,View只观察数据,不感知ViewModel的存在。

MVVM解决了MVP接口膨胀和生命周期耦合的问题,是目前 Android 官方推荐的架构。

LiveData 实战

LiveData基于数据订阅模式的界面刷新方案,配合ViewModel使用,是Android Jetpack专属组件。将传统项目改造成MVVM架构就需要:

  1. 定义ViewModel :引入JetPack组件库的ViewModel,然后新建一个ViewModel实现类,该类要定义好行为函数、LiveData对象(常见为MutableLiveData类型)。行为函数内部封装业务逻辑,基本上是对LiveData对象setValue/postValue操作,每一个LiveData对应每一个需要刷新的控件信息单元。
  2. 创建ViewModelActivity/Fragment中创建一个该ViewModel实例,按键逻辑绑定ViewModel中的行为函数。
  3. 观察LiveData :定义注册订阅函数,使用ViewModel对象获取暴露的LiveData的值,调用observe函数进行观察,回调里执行相应的刷新逻辑。在Activity/FragmentonCreate调用注册订阅函数。

View层:Activity

kotlin 复制代码
class EntranceActivity : AppCompatActivity() {
    // ViewBinding 绑定布局
    private lateinit var binding: ActivityEntranceBinding
    // 获取ViewModel(页面重建自动复用,数据不丢失)
    private val loginVm: LoginViewModel by lazy {
        ViewModelProvider(this)[LoginViewModel::class.java]
    }

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

        // 1. 监听ViewModel的LiveData数据变化,自动刷新UI
        observeViewModel()

        // 2. 按钮点击,仅转发事件给ViewModel,无任何业务判断
        binding.btnLogin.setOnClickListener {
            val name = binding.etUsername.text.toString()
            val pwd = binding.etPwd.text.toString()
            loginVm.login(name, pwd)
        }
    }

    // 订阅ViewModel的数据,数据变化自动回调
    private fun observeViewModel() {
        // 加载状态控制进度条显示/隐藏
        loginVm.loading.observe(this) { isLoading ->
            binding.progress.visibility = if (isLoading) android.view.View.VISIBLE else android.view.View.GONE
            binding.btnLogin.isEnabled = !isLoading
        }

        // 吐司提示
        loginVm.toastMsg.observe(this) { msg ->
            Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
        }

        // 登录成功跳转
        loginVm.loginSuccess.observe(this) { user ->
            user ?: return@observe
            Toast.makeText(this, "登录成功:${user.username}", Toast.LENGTH_LONG).show()
            // 跳转主页逻辑...
        }
    }
}

Model层:Repository

kotlin 复制代码
// UserRepository.kt Model仓库
class UserRepository {
    // 模拟登录网络请求
    suspend fun login(username: String, pwd: String): Result<User> {
        // 模拟网络延迟
        kotlinx.coroutines.delay(1000)
        return if (username == "admin" && pwd == "123456") {
            Result.success(User("admin", "abc123xyz"))
        } else {
            Result.failure(Exception("账号或密码错误"))
        }
    }
}

// User.kt Model实体
data class User(
    val username: String,
    val token: String
)

ViewModel层:ViewModel

kotlin 复制代码
class LoginViewModel : ViewModel() {
    // 私有可变LiveData(内部修改)
    private val _loading = MutableLiveData<Boolean>()
    private val _toastMsg = MutableLiveData<String>()
    private val _loginSuccess = MutableLiveData<User?>()

    // 对外暴露只读LiveData(View只能观察,不能修改)
    val loading: LiveData<Boolean> = _loading
    val toastMsg: LiveData<String> = _toastMsg
    val loginSuccess: LiveData<User?> = _loginSuccess

    // 持有Model仓库
    private val userRepo = UserRepository()

    // View层触发登录,业务逻辑写这里
    fun login(username: String, pwd: String) {
        // 输入简单校验
        if (username.isBlank() || pwd.isBlank()) {
            _toastMsg.postValue("账号密码不能为空")
            return
        }

        // 开启加载状态
        _loading.postValue(true)

        // viewModelScope:页面销毁自动取消协程,不会内存泄漏
        viewModelScope.launch {
            val result = userRepo.login(username, pwd)
            _loading.postValue(false)

            result.onSuccess { user ->
                // 登录成功,通知View跳转页面
                _loginSuccess.postValue(user)
            }.onFailure { err ->
                // 登录失败,弹出提示
                _toastMsg.postValue(err.message)
            }
        }
    }

    // 页面销毁时ViewModel自动销毁,无需手动解绑View
}

缺点

LiveData在历史演进逐渐被淘汰,全方面不如使用kotlin Flow来处理数据绑定/响应式流ViewModel依然可以完美搭配kotlin FlowStateFlow,无论选择LiveData还是StateFlow都是MVVM架构思想的体现。

特性 LiveData StateFlow
平台依赖 仅 Android 专属 纯 Kotlin,跨平台 KMM 可用
初始值 可选,可空 强制必须提供初始值
线程更新 主线程 setValue,子线程 postValue 异步 任意线程同步.value 赋值
数据流操作符 极少,功能弱 全套 Flow 操作符,合并 / 防抖 / 过滤
生命周期安全收集 无原生后台暂停 repeatOnLifecycle 自动启停
Compose 适配 兼容但非首选 官方标准方案
单元测试 需要安卓依赖,成本高 纯 JVM 直接测试
去重刷新 需手动判断 自带 distinctUntilChanged

StateFlow 实战

ViewModel层:ViewModel

kotlin 复制代码
// 页面全部UI状态
data class LoginUiState(
    val isLoading: Boolean = false,
    val toastMessage: String? = null,
    val loginUser: User? = null
)

class LoginViewModel : ViewModel() {
    private val repo = UserRepository()

    // 内部可变StateFlow,只能ViewModel内部修改
    private val _uiState = MutableStateFlow(LoginUiState())
    // 对外暴露只读StateFlow,View只能观察,不能修改
    val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()

    fun login(username: String, pwd: String) {
        if (username.isBlank() || pwd.isBlank()) {
            // 更新状态:弹窗提示
            updateState { copy(toastMessage = "账号密码不能为空") }
            return
        }

        // 开启加载
        updateState { copy(isLoading = true) }

        viewModelScope.launch {
            val result = repo.login(username, pwd)
            result.onSuccess { user ->
                // 登录成功,更新用户数据,关闭加载
                updateState {
                    copy(
                        isLoading = false,
                        loginUser = user
                    )
                }
            }.onFailure { error ->
                // 登录失败,弹窗提示,关闭加载
                updateState {
                    copy(
                        isLoading = false,
                        toastMessage = error.message
                    )
                }
            }
        }
    }

    // 清空toast,防止重复弹窗(单次消费事件处理)
    fun clearToast() {
        updateState { copy(toastMessage = null) }
    }

    // 统一更新状态工具方法,简化代码
    private fun updateState(block: LoginUiState.() -> LoginUiState) {
        _uiState.value = block(_uiState.value)
    }
}

这里将LiveData换成MutableStateFlow,前者对私有对象另写getter方法对外暴露,而后者则需要asStateFlow转成StateFlow对象,再对外暴露。

其他地方,将页面信息封装成一个状态类LoginUiState,还将更新状态的工具方法封装成扩展函数updateState,只需要在更新时直接传入copy函数克隆对象即可。不用每次都手动_uiState.value=_uiState.value.copy(isLoading = false)

View层:Activity

kotlin 复制代码
class EntranceActivity : AppCompatActivity() {
    private lateinit var binding: ActivityEntranceBinding
    private val vm: LoginViewModel by lazy {
        ViewModelProvider(this)[LoginViewModel::class.java]
    }

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

        // 订阅StateFlow状态
        observeUiState()

        // 按钮点击仅转发事件给ViewModel,无业务逻辑
        binding.btnLogin.setOnClickListener {
            val name = binding.etUsername.text.toString()
            val pwd = binding.etPwd.text.toString()
            vm.login(name, pwd)
        }
    }

    private fun observeUiState() {
        // lifecycleScope:跟随Activity生命周期
        lifecycleScope.launch {
            // repeatOnLifecycle:仅页面处于 STARTED 状态(前台可见)时接收数据流
            repeatOnLifecycle(androidx.lifecycle.Lifecycle.State.STARTED) {
                vm.uiState.collect { state ->
                    // 每次状态更新都会回调这里,统一刷新UI
                    // 1. 加载状态
                    binding.progress.visibility = if (state.isLoading) android.view.View.VISIBLE else android.view.View.GONE
                    binding.btnLogin.isEnabled = !state.isLoading

                    // 2. Toast弹窗(单次事件,展示后清空)
                    state.toastMessage?.let { msg ->
                        Toast.makeText(this@EntranceActivity, msg, Toast.LENGTH_SHORT).show()
                        vm.clearToast()
                    }

                    // 3. 登录成功
                    state.loginUser?.let { user ->
                        Toast.makeText(this@EntranceActivity, "登录成功:${user.username}", Toast.LENGTH_LONG).show()
                        // 跳转主页逻辑
                    }
                }
            }
        }
    }
}

这里面其他和LiveData使用差别不大,都有订阅函数,重点区别也在这里,lifecycleScope发起了一个协程,repeatOnLifecycle函数同步Activity生命周期,调用StateFlowcollect方法满足条件就会触发数据流回调,collect回调时会返回一个state参数,正是ViewModelMutableStateFlow(LoginUiState())包装起来的LoginUiState,获取这个state的参数做数据更新。

重头戏#1 lifecycleScope

viewModelScope类似,lifecycleScope提供的作用域创建的协程会自动跟随页面生命周期销毁,自动取消协程,杜绝内存泄漏,默认调度器是主线程 Dispatchers.Main.immediate,适合 UI 操作。

作用域 归属 取消时机 适用场景
lifecycleScope Activity/Fragment(页面) 页面 onDestroy 监听 UI 数据流、页面生命周期相关任务
viewModelScope ViewModel ViewModel 销毁(页面彻底回收、系统杀进程) 网络请求、业务计算、数据仓库调用
kotlin 复制代码
// Fragment / Activity 内部 
lifecycleScope.launch { 
    delay(3000) 
    Toast.makeText(this@MainActivity, "弹窗", Toast.LENGTH_SHORT).show() 
}

哪怕3秒没执行完,协程会在Activity onDestroy之后直接被取消。

重头戏#2 repeatOnLifecycle

那么只是想后台不触发更新节省资源呢?这个时候就要依赖和生命周期绑定了。 它专门搭配Flow收集,根据页面生命周期自动暂停 / 恢复数据流

  • lifecycleScope:页面级协程容器,保证页面销毁自动取消所有任务;
  • repeatOnLifecycle:是 lifecycleScope 内部的生命周期控制块,专门限制Flow收集仅在页面可见时运行;

接收者Lambda

这里updateState有参数block有点特别,作为Lambda函数它没有参数,但却block(_uiState.value)直接传入了参数,看起来很怪?其实_uiState.value = block(_uiState.value)只是语法糖,实际上等同于_uiState.value = _uiState.value.block(),这样一看block对上了没有参数,但是block怎么又变成了value的函数?这不是乱套了吗?

因为block: LoginUiState.() -> LoginUiStateblock被声明成了一个带有隐式接收者的函数类型LoginUiState.(),标记这是一个扩展式Lambda,可以解锁特殊用法,与普通kotlin函数语法有所区别,_uiState.value.block()现在不是value自己的函数,而是代表value会作为隐式接收者对象,变成block的函数体上下文对象,就可以省略it,十分神奇。

kotlin 复制代码
接收者Lambda:
block: LoginUiState.() -> LoginUiState 
调用:updateState { copy(isLoading = true) } //lambda内部this=旧state,省略it,直接copy()

普通Lambda:
block: (LoginUiState) -> LoginUiState 
调用:updateState { it.copy(isLoading = true) } //lambda内用it代表传入的旧state,不能直接省略it。

无论普通Lambda还是接收者Lambda,他们封装起来有很多行、使用时又有很多行,看着比只有一行的原始用法复杂太多了:

kotlin 复制代码
// 原始调用
_uiState.value=_uiState.value.copy(isLoading = false, loginUser = user)

// 定义普通Lambda
private fun updateState1(block: (LoginUiState) -> LoginUiState) {
    _uiState.value = block(_uiState.value)
}
// 使用普通Lambda
updateState1 {
    it.copy(
        isLoading = false,
        loginUser = user
    )
}

// 定义接收者Lambda
private fun updateState2(block: LoginUiState.() -> LoginUiState) {
    _uiState.value = block(_uiState.value)
}
// 使用接收者Lambda
updateState2 {
    copy(
        isLoading = false,
        loginUser = user
    )
}

为什么要这么做呢?处处使用原始用法不就好了?其实这里面也有很多讲究。

一、先解决第一个问题:updateState1 vs updateState2,区别只有 it

updateState1:普通 lambda (LoginUiState) -> LoginUiState

kotlin 复制代码
updateState1 { it.copy(isLoading = false, loginUser = user) }

updateState2:带接收者 lambda LoginUiState.() -> LoginUiState

kotlin 复制代码
updateState2 { copy(isLoading = false, loginUser = user) }

肉眼看确实只是少了 it.,语法更干净。这只是表层语法优势,不是核心价值,真正封装的意义不在少打两个字母。

二、第二个关键疑问:原生明明一行搞定,为什么要封装函数?

原生写法:

kotlin 复制代码
_uiState.value = _uiState.value.copy(isLoading = false, loginUser = user)

看起来很短,但是存在四大硬伤,页面逻辑一多就会爆炸:

不封装缺陷 1:重复样板代码无法统一管控

每次修改状态,必须重复书写 _uiState.value = _uiState.value.xxx假设页面有 20 处修改 UI 状态:

  • 原生写法:20 行全部重复这段模板;
  • 封装后:20 处只调用 updateState {}

一旦未来要给所有状态更新加统一逻辑,原生写法会崩溃:举个需求:每次更新状态自动打印新旧状态日志,用于线上 bug 排查。

  • 原生写法:20 处全部手动加打印,漏一处就丢日志,改起来巨麻烦;
  • 封装写法:只改 updateState 这一个方法,全局所有状态更新自动生效:
kotlin 复制代码
private fun updateState(block: LoginUiState.() -> LoginUiState) {
    val old = _uiState.value
    val new = block(old)
    // 统一埋点日志,所有状态变更自动打印
    println("旧状态:$old 新状态:$new")
    _uiState.value = new
}

这是封装最核心的价值:统一收口、统一拦截、统一附加逻辑。

不封装缺陷 2:状态更新逻辑可扩展

后续可以轻松增加通用能力:

  1. 自动去重:新旧状态完全一致时不更新 StateFlow,减少无用 UI 刷新
  2. 全局状态拦截:比如登录失效统一跳转登录页
  3. 埋点、日志、调试工具注入这些通用逻辑只需要写一次,全部状态修改自动生效,原生写法做不到。
不封装缺陷 3:统一代码规范,多人协作统一

项目规定:禁止直接操作 _uiState.value,所有状态修改必须通过 updateState 代码评审一眼就能识别哪里在修改 UI 状态,全局检索 updateState 就能找到所有 UI 变更点;原生散写 _uiState.value 很难全局检索梳理页面数据流。

封装总结

对比三种写法长短,分场景看

单次简单修改(你现在的单行场景),适合原生最短,看起来最清爽封装:多了函数调用大括号,代码行数变多。

大型复杂页面(真实项目常态),页面 30 + 处修改状态、需要统一日志、并发安全、全局拦截:原生写法会产生大量重复垃圾代码,维护成本指数上升;封装写法收益巨大,是工程化取舍:牺牲单行简洁,换取全局可维护性

带接收者 lambda(updateState2)额外小优势

  1. 代码视觉干净,没有到处飘 it.
  2. 如果 UiState 里有扩展函数,内部可以直接调用,不需要 it.
kotlin 复制代码
// 给UiState写扩展函数
fun LoginUiState.clearInput(): LoginUiState {
    return this.copy(username = "", password = "")
}

// 使用带接收者写法,直接调用扩展方法
updateState { clearInput() }

// 普通lambda必须加it
updateState1 { it.clearInput() }
  1. updateState1updateState2 的表层区别确实只是少写 it.,属于语法糖优化,不是封装的核心目的;

  2. 封装 updateState 根本不是为了 "少写代码",而是为了:

    • 统一收口所有状态更新,批量增加通用逻辑(日志、拦截、去重)
    • 解决原生两次读取 value 带来的并发 bug
    • 统一团队编码规范,方便代码检索与维护
  3. 单行简单逻辑原生代码看着舒服,但商业项目复杂页面中,原生写法维护成本极高,工程开发会优先选择封装收口方案,是长期可维护性的取舍。

简单一句话:Demo 简单页面原生写法够用,线上长期维护的商业项目必须封装收口

MVI

相关推荐
杉氧3 小时前
Flutter 像素级还原实战:用 CustomPaint 与 Bezier 曲线手绘精致图针
android·前端·flutter
我命由我123456 小时前
Android 在构建过程中,发现有两个依赖库都包含了相同路径的资源文件
android·java·开发语言·java-ee·kotlin·android-studio·android runtime
Coffeeee6 小时前
谷歌的一个优化建议,让我重新学了一遍Android里面如何正确处理位图
android·google·kotlin
冰暮流星7 小时前
mysql之新建表及对表的查询
android·数据库·mysql
用户2018792831677 小时前
彻底搞懂LayoutInflater、ViewInflater与textViewStyle优先级:XML属性为何能覆盖全局样式?
android
达达尼昂7 小时前
AI Native 工程实践:如何为 Claude 5 设计更有效的上下文
android·人工智能·后端
Cry丶8 小时前
【已解决】adb devices 能识别手机,但 HBuilderX 搜索不到设备的避坑指南
android·adb·uni-app·hbuilderx·真机调试
墨神谕8 小时前
认识一下Unicode(统一码)
android·java·数据库
Sirens.20 小时前
MySQL表设计进阶-约束范式连接索引与事务
android·数据库·mysql