应用架构 - MVVM 与 MVI ★
你的重点短板,中级必考。 面试常问“你项目用什么架构?为什么?“,要答得出演进逻辑和取舍。
一、架构演进
| 架构 | 核心 | 痛点 |
|---|---|---|
| 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 就是来解决这个的。
三、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 / SideEffect:一次性事件(导航、Toast),用 Channel/SharedFlow 表达(不放进 State,避免重放)。
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。
高频面试题
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? State 是持久状态,旋转重建后会重新渲染,若把事件放进 State 会重复触发(重复弹 Toast/重复导航)。应用 Channel/SharedFlow(replay=0)表达一次性 Effect。
Q7:你项目用什么架构?为什么这么选?(开放题) 结合实际答:中小页面 MVVM 足够轻量;复杂交互/状态多的页面用 MVI 保证可预测性。强调“按复杂度选型“,并说明分层(UI/Domain/Data)、单一数据源、依赖注入带来的可测试性收益。
进阶补充:端到端架构、MVI 状态机与 Offline-first
推荐端到端分层
ui(Screen/Fragment/Composable)
-> ViewModel(State/Effect)
-> UseCase(业务规则)
-> Repository(数据协调)
-> Local/Remote DataSource(Room/Retrofit)
依赖方向只能向内/向下的抽象走,UI 不直接依赖 Retrofit/Room。
MVI Reducer
MVI 的核心是事件驱动状态变化:
data class LoginState(val loading: Boolean = false, val error: String? = null)
fun reduce(state: LoginState, event: LoginEvent): LoginState = when (event) {
LoginEvent.Submit -> state.copy(loading = true, error = null)
is LoginEvent.Failed -> state.copy(loading = false, error = event.message)
}
一次性 Effect
Toast、导航、弹窗不适合放进持久 State,通常用 Effect/Channel/SharedFlow 处理。
Offline-first
离线优先通常以本地数据库为 single source of truth,网络同步更新本地,UI 观察本地。难点是冲突解决、同步状态、失败重试。
何时 MVVM 足够,何时 MVI 过重
简单表单/详情页 MVVM 足够;复杂交互、多状态、多事件流页面适合 MVI。不要为了追新把所有页面都写成复杂 reducer。
**追问:**Repository 是不是越多越好?不是。Repository 应按数据/业务聚合边界划分,过细会变成无意义转发层。