应用架构 - MVVM 与 MVI ★
你的重点短板, 中级必考. 面试常问 “你项目用什么架构? 为什么?”, 要答得出演进逻辑和取舍. 组件基础见 Jetpack 架构组件, 完整落地见 App 架构落地案例.
一, 架构演进
| 架构 | 核心 | 痛点 |
|---|---|---|
| MVC | Activity 既是 View 又当 Controller | 职责混乱, Activity 臃肿 (“上帝类”) |
| MVP | Presenter 持有 View 接口, 逻辑抽离 | 接口爆炸, Presenter 持 View 易泄漏, 需手动解绑 |
| MVVM | ViewModel 暴露可观察数据, 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 应按数据 / 业务聚合边界划分, 过细会变成无意义转发层.