Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

应用架构 - MVVM 与 MVI ★

你的重点短板, 中级必考. 面试常问 “你项目用什么架构? 为什么?”, 要答得出演进逻辑和取舍. 组件基础见 Jetpack 架构组件, 完整落地见 App 架构落地案例.

一, 架构演进

架构核心痛点
MVCActivity 既是 View 又当 Controller职责混乱, Activity 臃肿 (“上帝类”)
MVPPresenter 持有 View 接口, 逻辑抽离接口爆炸, Presenter 持 View 易泄漏, 需手动解绑
MVVMViewModel 暴露可观察数据, View 订阅数据流方向多, 状态分散难追踪
MVI单一 State + 单向数据流模板代码多, 小页面偏重

核心趋势:职责分离 → 解耦 View 与逻辑 → 数据驱动 UI → 单向数据流.

二, MVVM

  • View(Activity/Fragment/Composable):只负责展示和转发用户操作, 订阅 ViewModel 的数据.
  • ViewModel: 持有 UI 状态和业务逻辑入口, 不引用 View, 通过 LiveData/StateFlow 暴露数据.
  • Model: 数据层 (Repository + 数据源).
  • 数据绑定: View 观察 ViewModel 的可观察数据, 数据变 UI 自动更新.
class UserViewModel(private val repo: UserRepo) : ViewModel() {
    private val _user = MutableStateFlow<User?>(null)
    val user: StateFlow<User?> = _user.asStateFlow()
    fun load(id: String) = viewModelScope.launch {
        _user.value = repo.getUser(id)
    }
}

MVVM 的问题: 多个 LiveData/StateFlow 分散表达状态 (loading/data/error 各一个), 状态可能不一致, 难追踪 “当前完整 UI 状态”.MVI 就是来解决这个的.

三, MVP 的 View 解绑与异步边界

MVP 中 Presenter 可以持有 View 接口, 但必须在 View 销毁时释放引用. 否则异步任务完成后可能回调已销毁的 Activity 或 Fragment, 既造成错误 UI 更新, 也可能延长 View 的存活时间.

下例是最小示意代码, 用于说明生命周期回调和异步竞态, 不是完整的生产实现. onStop() 是否解绑要按页面是否会在后台继续展示结果决定; 对不应跨出页面生命周期的工作, 应优先取消任务.

interface ProfileView {
    fun showProfile(profile: Profile)
    fun showError(message: String)
}

class ProfilePresenter(
    private val repository: ProfileRepository,
    private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main.immediate,
    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
    // This scope is created for each attached View and closed in detachView().
    private var scope: CoroutineScope? = null
    private var view: ProfileView? = null
    private var loadJob: Job? = null

    fun attachView(view: ProfileView) {
        scope?.cancel()
        scope = CoroutineScope(SupervisorJob() + mainDispatcher)
        this.view = view
    }

    fun loadProfile(id: String) {
        val activeScope = scope ?: return
        loadJob?.cancel()
        loadJob = activeScope.launch {
            try {
                val profile = withContext(ioDispatcher) {
                    repository.loadProfile(id)
                }
                view?.showProfile(profile)
            } catch (error: CancellationException) {
                throw error
            } catch (error: Exception) {
                view?.showError(error.message ?: "加载失败")
            }
        }
    }

    fun detachView() {
        view = null
        loadJob?.cancel()
        loadJob = null
        scope?.cancel()
        scope = null
    }
}

class ProfileFragment : Fragment(), ProfileView {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        presenter.attachView(this)
    }

    override fun onDestroyView() {
        presenter.detachView()
        super.onDestroyView()
    }

    override fun showProfile(profile: Profile) {
        // Render the profile in the current Fragment View.
    }

    override fun showError(message: String) {
        // Render an error only while the Fragment View exists.
    }
}

示例中的 scope 由 Presenter 所有, 每次 attachView() 都基于 mainDispatcher 创建一个绑定 View 生命周期的 scope; 默认值是 Dispatchers.Main.immediate, 因而所有 View 回调在主线程. detachView() 会取消并清空 scope, 所以同一个 Fragment 实例在 onDestroyView() 后再次 onViewCreated() 时可重新绑定; 未绑定 View 时 loadProfile() 直接返回. Repository 调用默认切到 Dispatchers.IO, 返回后自动回到 Main. Dispatcher 可注入, 使测试以确定性 dispatcher 驱动; 生产注入值仍必须保证 UI 更新在 Main. view?.showProfile(...) 只是在异步返回时避免调用已解绑的 View, 不能替代任务管理: 长期任务即使不更新 UI, 仍可能持续占用网络, CPU 或持有其他资源. CancellationException 必须重抛; 业务或预期运行失败才按 Exception 处理, 不要捕获 Throwable. 按工作所有权选择处理方式:

方式解决的问题仍需注意的边界
仅 detachView()清除 Presenter 到 View 的引用, 避免回调已销毁 View任务仍在运行, 其资源和其他引用仍可能泄漏
detachView() + 取消任务页面离开后不再需要结果的请求可停止取消需要由协程或请求实现配合, 并处理取消后的状态
由 ViewModel 持有状态配置变更后 UI 可重新订阅状态, ViewModel 不引用 View仍应按 viewModelScope 和业务所有权决定任务何时结束

Fragment 的 View 生命周期与 Fragment 实例不同, 因此在 onDestroyView() 解绑比等到 onDestroy() 更符合 View 引用的实际存活期. 生命周期语境见 Android 四大组件与基础, 协程取消与结构化并发边界见 多线程并发专题.

MVP Presenter 的最小测试策略

MVP 的可测性来自边界设计: Presenter 只依赖 View 接口与 Repository 接口, 不持有 Android 框架对象, 可在纯 JVM 上注入 fake/mock 观察回调, 并注入 TestDispatcher 驱动虚拟时间. 测试的重点是解绑与取消: 至少覆盖成功回调, 预期失败回调, 以及 detachView() 后任务被取消且不再回调旧 View.

具体测试代码, 分层和工具见 见 16 的「协程与 Flow 测试」与「Fake 优先于过度 Mock」小节, 本节只保留可测性原理, 不重复其配置细节.

四, MVI (Model-View-Intent)

核心思想:单一数据源 + 单向数据流 (UDF).

  • State: 用一个不可变对象描述整个 UI 状态 (data class UiState(val isLoading, val data, val error)).
  • Intent(也叫 Event/Action):用户意图 (点击, 刷新), 是进入系统的唯一入口.
  • 单向流: Intent → ViewModel 处理 → 产出新 State → View 渲染. 状态只能由 ViewModel 产出, View 不能直接改.
  • Effect / 动作: 先区分可靠性. 用户点击触发的导航由 UI 直接处理; 必须保证处理的业务结果进入 State; 只有允许丢失的纯 UI 瞬时效果才使用独立事件流.
data class UiState(
    val isLoading: Boolean = false,
    val items: List<Item> = emptyList(),
    val error: String? = null,
)
sealed interface UiIntent {
    data object Refresh : UiIntent
    data class Click(val id: String) : UiIntent
}
class MyViewModel : ViewModel() {
    private val _state = MutableStateFlow(UiState())
    val state: StateFlow<UiState> = _state.asStateFlow()
    fun onIntent(intent: UiIntent) = when (intent) {
        UiIntent.Refresh -> refresh()
        is UiIntent.Click -> openDetail(intent.id)
    }
}

五, 官方推荐分层架构

Google 推荐三层 (配合单 Activity + Jetpack), 但它是可按复杂度裁剪的参考架构, 不是所有页面都必须机械套满三层:

  • UI 层: Composable/Fragment + ViewModel + UiState. 只做展示和事件转发.
  • Domain 层 (可选): UseCase 封装单一业务用例, 复用复杂逻辑, 降低 ViewModel 体积.
  • Data 层: Repository (对外唯一数据入口)+ DataSource (网络 / 本地). Repository 决定数据来源, 缓存策略, 做单一数据源 (SSOT).

依赖方向单向向下: UI → Domain → Data, 上层依赖下层抽象 (依赖倒置).

六, 可测试性

  • ViewModel 不依赖 Android 框架 (不持 Context/View), 可纯 JUnit 测试.
  • Repository/UseCase 通过接口注入 (Hilt), 测试时替换为 fake/mock.
  • 协程测试用 runTest + TestDispatcher, Flow 测试用 Turbine.

七, 同一页面的 MVVM/MVI 对比与选型

以 “商品列表刷新” 为例, 二者都可以使用 StateFlow 和 Repository, 区别在状态组织和事件入口, 不是 “一个先进, 一个落后”.

维度MVVM 写法MVI 写法
输入viewModel.refresh() 等语义方法onIntent(Refresh) 统一入口
输出可有 items/loading/error 多个流, 建议收敛为页面 state一个不可变 ProductState
适用简单详情, 表单和低交互页面多事件, 多加载阶段, 可回放状态机
代价过度分散时状态组合困难Intent/reducer 模板与状态设计成本

上下文片段: 下例是 MVI reducer 的可单元测试核心, Product, Repository 和协程启动代码省略. 输入 Refresh 时预期 loading 为 true 且清除旧错误; 成功事件后预期替换 items, 关闭 loading. 它不是完整 Android 页面.

data class ProductState(val loading: Boolean = false, val items: List<Product> = emptyList(), val error: String? = null)
sealed interface ProductEvent { data object Refresh : ProductEvent; data class Loaded(val items: List<Product>) : ProductEvent; data class Failed(val message: String) : ProductEvent }
fun reduce(old: ProductState, event: ProductEvent): ProductState = when (event) {
    ProductEvent.Refresh -> old.copy(loading = true, error = null)
    is ProductEvent.Loaded -> old.copy(loading = false, items = event.items)
    is ProductEvent.Failed -> old.copy(loading = false, error = event.message)
}

UDF trace: UI click/refresh -> Intent/Event -> ViewModel reducer + use case -> new immutable UiState -> UI render; 数据访问为 ViewModel -> Repository -> local/remote data.

选型顺序: 先画页面状态, 事件和失败恢复; 状态少且调用链短时使用收敛状态的 MVVM; 存在并发事件, 分页, 步骤流或需严格审计转移时用 reducer 风格 MVI; 若 reducer 只做转发, 阅读成本高, 则回退到 MVVM. 对风控 / 设备指纹这类需要排查与审计的场景, MVI 的单一 State 可序列化回放, 便于复现问题链路和审计每次状态转移, 是选型时的一个加分论点. 两者都应保持 UI 不直接访问数据源, 业务状态可恢复, 依赖可替换. 完整 UI 到 DataSource, 分页和离线实现见 App 架构落地案例.


高频面试题

Q1: MVP 和 MVVM 区别? MVP: Presenter 持有 View 接口, 双向手动调用, 需解绑防泄漏, 接口多. MVVM: ViewModel 不持有 View, 通过可观察数据 (LiveData/StateFlow) 单向通知, View 订阅, 解耦更彻底.

Q2: MVVM 和 MVI 区别? MVI 解决什么? MVVM 状态分散在多个 LiveData/StateFlow, 可能不一致, 难追踪. MVI 用单一不可变 State 描述整个 UI, 单向数据流, Intent 作唯一入口, 状态可预测, 易调试, 易回放. 代价是模板代码多.

Q3: 为什么 ViewModel 里不能直接更新 UI? ViewModel 不应感知 View (否则耦合 + 泄漏 + 不可测).它只产出状态, 由 View 订阅后自行渲染, 保持单向数据流.

Q4: Repository 模式的作用? 作为数据层唯一入口, 对上层屏蔽数据来源 (网络/缓存/数据库), 实现单一数据源, 缓存策略, 离线支持, 使 ViewModel 不关心数据从哪来.

Q5: UseCase (Interactor) 有必要吗? 非必须. 当业务逻辑复杂, 需被多个 ViewModel 复用, 或 ViewModel 过于臃肿时引入, 封装单一业务用例, 提升复用和可测试性. 简单页面可省略.

Q6: Toast / 导航应该放进 State 还是事件流? 不能按控件类型简单二分. 用户点击产生的导航由 UI 直接处理; 必须保证处理的业务结果进入可恢复 State, UI 处理后回调确认; 只有明确允许丢失的瞬时效果才使用 Channel/SharedFlow. replay=0 在无 collector 时会丢值, 不能提供恰好一次保证.

Q7: 你项目用什么架构? 为什么这么选?(开放题) 结合实际答: 中小页面 MVVM 足够轻量; 复杂交互/状态多的页面用 MVI 保证可预测性. 强调 “按复杂度选型”, 并说明分层 (UI/Domain/Data), 单一数据源, 依赖注入带来的可测试性收益.

易错点 / 追问

  • MVP 等到 onDestroy() 才解绑: View 生命周期在 onDestroyView() 已结束, 之后回调会更新已销毁的 View; 解绑时机匹配 View 引用实际存活期.
  • 捕获 CancellationException 不重抛: 破坏协程取消; 业务失败才按 Exception 处理, 不要捕获 Throwable.
  • 状态分散在多个 LiveData/StateFlow: loading/data/error 各自为流容易不一致; 页面状态应收敛为单一 state, 再决定是否需要 MVI.
  • MVI reducer 只做转发不归约: 模板成本高于收益; 状态少且调用链短时回退到收敛状态的 MVVM.
  • 用 SharedFlow (replay=0) 传递必须可靠的事件: 无 collector 时丢值; 必须处理的业务结果进 State, UI 处理后回调确认.
  • UI 层直接访问 Repository/DataSource, 或 Repository 按方法而非业务聚合切分: 破坏单向数据流和可测性, 过细会退化成无意义转发层.

落地边界

本篇止于定义, 同页对照, UDF 与选型. UI 到 Repository/DataSource 的完整链路, Offline-first, 分页失败恢复及 requestId 幂等, 统一见 App 架构落地案例, 避免同一案例在两篇重复维护.

追问: Repository 是不是越多越好? 不是. Repository 应按数据 / 业务聚合边界划分, 过细会变成无意义转发层.