MVVM 是客户端开发里非常常见的表现层架构模式。很多人刚接触 MVVM 时,容易把它理解成"Activity/Fragment 里放一个 ViewModel",或者"用了 Jetpack ViewModel 就是 MVVM"。这其实只理解到了一半。
MVVM 真正解决的问题是:让界面、状态和业务数据各自负责自己的事情,并用清晰的数据流连接起来。
一套设计良好的 MVVM,应该能回答这些问题:
- View 只负责显示,还是也能写业务逻辑?
- ViewModel 到底保存什么状态?
- Model 是数据库表、接口 DTO,还是 Repository?
- 用户点击按钮后,数据怎么传到业务层?
- 接口返回后,数据怎么回到界面?
- Loading、Error、Empty、Content 怎么表达?
- Toast、跳转、弹窗这种一次性事件放在哪里?
- ViewModel 能不能持有 Context、View、Activity?
- 页面旋转、进程重建、网络失败时状态怎么恢复?
本文会按照"概念 → 设计 → 数据流 → ViewModel → 代码 → 误区"的顺序,把 MVVM 讲透。示例以 Android/Kotlin 为主,但核心思想同样适用于 Flutter、前端、桌面端和其他客户端开发。
版本提示:Android Jetpack、Compose、Lifecycle、协程和 Flow 会持续更新。本文重点讲稳定架构思想,具体依赖版本请以项目当前版本和官方文档为准。
总览
| 模块 | 它是什么 | 主要职责 | 不应该做什么 |
|---|---|---|---|
| View | 页面和 UI 组件 | 渲染 UI、接收用户输入、订阅状态 | 不写复杂业务规则,不直接访问数据库 |
| ViewModel | View 的状态持有者和行为协调者 | 管理 UI State、处理事件、调用用例或仓库 | 不持有 View,不操作具体控件,不直接依赖 Activity |
| Model | 数据与业务来源 | 提供数据、执行业务规则、封装网络/数据库 | 不关心 UI 怎么展示 |
| Repository | 数据仓库 | 屏蔽网络、本地缓存、数据源差异 | 不保存页面临时 UI 状态 |
| UseCase | 业务用例 | 组织一段独立业务规则 | 不处理按钮颜色、输入框焦点 |
| UI State | 页面状态快照 | 描述当前页面应该长什么样 | 不混入一次性导航事件 |
| UI Event | 用户事件 | 表达用户做了什么 | 不直接操作数据源 |
| UI Effect | 一次性效果 | Toast、导航、弹窗、打开系统页面 | 不作为可长期展示的页面状态 |
1. 一句话理解 MVVM
原理
MVVM 全称是 Model-View-ViewModel。
它的核心关系可以理解为:
text
View <-> ViewModel <-> Model
但更推荐用数据流来理解:
text
用户操作
↓
View 把事件交给 ViewModel
↓
ViewModel 调用 Model 层获取或修改数据
↓
Model 返回结果
↓
ViewModel 更新 UI State
↓
View 根据 UI State 重新渲染
MVVM 的重点不是类名,而是职责边界:
- View 负责"看得见的东西";
- ViewModel 负责"这个界面当前处于什么状态,以及用户操作后该怎么推进";
- Model 负责"数据从哪里来、业务规则怎么执行"。
特点
- UI 和业务逻辑分离;
- 页面状态集中管理;
- ViewModel 可以单独测试;
- 数据流更清晰;
- 页面旋转、重建时状态更容易保留;
- 适合与响应式数据流结合,例如 LiveData、StateFlow、Flow、Stream。
使用场景
MVVM 特别适合这些场景:
- 表单页面;
- 登录注册;
- 列表分页;
- 详情页;
- 搜索页;
- 多状态页面;
- 需要缓存和刷新策略的页面;
- Android、Flutter、WPF、前端 SPA 等客户端项目。
2. 为什么需要 MVVM
没有架构时会发生什么
很多项目一开始都是这样写的:
kotlin
class LoginActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
loginButton.setOnClickListener {
val account = accountEditText.text.toString()
val password = passwordEditText.text.toString()
if (account.isBlank()) {
Toast.makeText(this, "请输入账号", Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
progressBar.isVisible = true
api.login(account, password).enqueue(object : Callback<LoginResponse> {
override fun onResponse(
call: Call<LoginResponse>,
response: Response<LoginResponse>,
) {
progressBar.isVisible = false
saveToken(response.body()?.token)
startActivity(Intent(this@LoginActivity, HomeActivity::class.java))
}
override fun onFailure(call: Call<LoginResponse>, t: Throwable) {
progressBar.isVisible = false
Toast.makeText(this@LoginActivity, "登录失败", Toast.LENGTH_SHORT).show()
}
})
}
}
}
这段代码能跑,但问题很多:
- View 直接处理输入校验;
- View 直接调用接口;
- View 直接保存 token;
- View 直接处理 Loading;
- View 直接处理跳转;
- 网络回调和生命周期纠缠在一起;
- 逻辑很难单元测试;
- 需求一复杂,Activity/Fragment 会迅速膨胀。
MVVM 要解决的痛点
| 痛点 | MVVM 的处理方式 |
|---|---|
| Activity/Fragment 太胖 | 把状态和业务协调放到 ViewModel |
| UI 和数据访问耦合 | View 只调用 ViewModel,ViewModel 再调用 Model |
| 状态散落在各处 | 用 UI State 统一描述页面 |
| 接口结果难处理 | Repository 统一封装数据来源 |
| 页面重建状态丢失 | ViewModel 保留页面状态 |
| 逻辑不好测试 | ViewModel 和 UseCase 可脱离 UI 测试 |
| 平台差异影响业务 | Model/Repository 层隔离数据源和平台细节 |
设计目标
MVVM 的设计目标不是"文件更多",而是:
- 页面代码更薄;
- 状态变化更清楚;
- 业务逻辑更容易测试;
- 数据来源可以替换;
- 页面重建不会轻易丢状态;
- 新需求来了能知道应该改哪里。
3. MVVM 的三个核心组成部分
3.1 View:只关心展示和用户输入
View 是什么
在 Android 里,View 可以是:
- Activity;
- Fragment;
- XML 布局;
- Compose Composable;
- 自定义 View。
在 Flutter 里,View 可以是 Widget。
在前端里,View 可以是组件。
不管平台怎么变,View 的职责都差不多。
View 应该做什么
- 根据 UI State 渲染界面;
- 把用户点击、输入、滑动等事件转发给 ViewModel;
- 订阅 ViewModel 暴露的状态;
- 处理与平台 UI 直接相关的事情,例如导航、Toast、权限弹窗;
- 绑定生命周期,避免内存泄漏。
View 不应该做什么
- 不直接写复杂业务规则;
- 不直接请求接口;
- 不直接操作数据库;
- 不持有大量页面状态;
- 不直接拼接复杂数据模型;
- 不在 UI 构建过程中做重计算。
View 的理想形态
View 最好像一个"状态渲染器":
text
输入:UI State
输出:屏幕上看到的界面
事件:用户操作后调用 ViewModel 方法
例如:
kotlin
@Composable
fun LoginRoute(
viewModel: LoginViewModel,
onLoginSuccess: () -> Unit,
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
viewModel.effect.collect { effect ->
when (effect) {
LoginEffect.NavigateHome -> onLoginSuccess()
is LoginEffect.ShowToast -> {
// 在真实项目里可以调用 SnackbarHostState 或平台 Toast
}
}
}
}
LoginScreen(
state = state,
onAccountChange = viewModel::onAccountChange,
onPasswordChange = viewModel::onPasswordChange,
onLoginClick = viewModel::login,
)
}
LoginRoute 负责连接 ViewModel 和页面,真正的 LoginScreen 只负责展示。
3.2 ViewModel:页面状态和行为的中间层
ViewModel 是什么
ViewModel 可以理解为"给 View 使用的模型"。它不是数据库模型,也不是接口模型,而是面向页面展示和交互的状态持有者。
它关心的问题是:
- 当前页面是否加载中?
- 当前输入框内容是什么?
- 按钮能不能点击?
- 当前错误信息是什么?
- 当前列表数据是什么?
- 用户点击后应该调用哪个业务流程?
- 接口成功后页面应该如何更新?
- 接口失败后错误怎么展示?
在 Android Jetpack 中,ViewModel 还有一个很重要的能力:它可以在配置变化中保留状态,例如屏幕旋转后不会因为 Activity 重建就立刻丢失数据。
ViewModel 应该做什么
- 暴露可观察的 UI State;
- 接收 View 发来的用户事件;
- 调用 UseCase 或 Repository;
- 管理请求 Loading、成功、失败;
- 做轻量输入校验和页面级逻辑;
- 把领域数据转换成 UI 需要的数据;
- 发出一次性 UI Effect;
- 使用
viewModelScope管理协程生命周期。
ViewModel 不应该做什么
- 不持有 Activity、Fragment、View;
- 不直接操作控件;
- 不直接弹 Toast;
- 不直接执行 Android 导航;
- 不持有短生命周期的 Context;
- 不写复杂数据库或网络实现;
- 不把所有业务都塞进一个超大类;
- 不处理纯 UI 动画细节。
ViewModel 的理想形态
ViewModel 最好像一个"状态机":
text
输入:用户事件、页面参数、Model 层数据
处理:校验、调用用例、转换结果
输出:UI State 和一次性 Effect
3.3 Model:数据和业务的来源
Model 是什么
MVVM 里的 Model 不一定只是一个 User 数据类。它可以包含:
- Entity;
- DTO;
- Domain Model;
- Repository;
- DataSource;
- UseCase;
- 本地数据库;
- 网络 API;
- 缓存;
- 业务规则。
在现代 Android 架构里,通常会进一步拆成 Data 层和 Domain 层。
text
View
↓
ViewModel
↓
UseCase
↓
Repository
↓
RemoteDataSource / LocalDataSource
Model 应该做什么
- 获取网络数据;
- 读写数据库;
- 处理缓存;
- 封装数据来源差异;
- 执行业务规则;
- 将 DTO 转换为领域模型;
- 向上层提供稳定 API。
Model 不应该做什么
- 不知道按钮是什么颜色;
- 不知道页面是否显示 Loading;
- 不直接调用 Activity;
- 不直接处理 UI 导航;
- 不保存输入框焦点、滚动位置这种页面临时状态。
4. 数据到底怎么传输
4.1 推荐使用单向数据流
MVVM 经常被画成双向绑定:
text
View <-> ViewModel
但在现代客户端开发里,更推荐用单向数据流来理解:
text
ViewState ↓
View → UserEvent → ViewModel → Model
View ← UI State ← ViewModel ← Result
Effect ↓
Toast / Navigation / Dialog
意思是:
- ViewModel 向 View 暴露状态;
- View 根据状态渲染;
- 用户操作产生事件;
- View 把事件交给 ViewModel;
- ViewModel 调用 Model 层;
- Model 返回结果;
- ViewModel 生成新的状态;
- View 再次渲染。
这样数据方向清晰,排查问题时更容易知道哪一步出了问题。
4.2 页面加载的数据流
text
页面创建
↓
View 订阅 ViewModel.uiState
↓
ViewModel.load()
↓
uiState = Loading
↓
Repository.fetch()
↓
接口 / 数据库返回
↓
uiState = Content 或 Error
↓
View 根据状态刷新
对应代码:
kotlin
fun load() {
viewModelScope.launch {
_uiState.value = UserUiState.Loading
val result = userRepository.getUser()
_uiState.value = result.fold(
onSuccess = { user -> UserUiState.Content(user.toUiModel()) },
onFailure = { error -> UserUiState.Error(error.message ?: "加载失败") },
)
}
}
4.3 用户输入的数据流
text
用户输入账号
↓
View.onAccountChange(text)
↓
ViewModel 更新 account
↓
重新计算 loginEnabled
↓
View 收到新状态
↓
按钮启用或禁用
对应代码:
kotlin
fun onAccountChange(value: String) {
_uiState.update { state ->
state.copy(
account = value,
accountError = null,
).recalculate()
}
}
private fun LoginUiState.recalculate(): LoginUiState {
return copy(
loginEnabled = account.isNotBlank() &&
password.length >= 6 &&
!loading,
)
}
4.4 提交操作的数据流
text
用户点击登录
↓
ViewModel 校验输入
↓
uiState.loading = true
↓
调用 LoginUseCase
↓
成功:保存状态,发出 NavigateHome
失败:uiState.errorMessage = xxx
↓
View 渲染 Loading / Error / 跳转
关键点:
- Loading 是状态,应该放进 UI State;
- 错误如果要持续展示,可以放进 UI State;
- 跳转是一次性效果,通常不适合长期放在 UI State;
- ViewModel 不直接跳转,只发出一个 Effect。
4.5 一次性事件的数据流
Toast、导航、打开相册、弹系统权限等,通常是"一次性效果"。
错误示例:
kotlin
data class LoginUiState(
val navigateToHome: Boolean = false,
)
问题是:页面重建或重新订阅时,navigateToHome = true 可能被再次消费,导致重复跳转。
更推荐:
kotlin
sealed interface LoginEffect {
data object NavigateHome : LoginEffect
data class ShowToast(val message: String) : LoginEffect
}
ViewModel 使用 Channel 或 SharedFlow 发出一次性效果:
kotlin
private val _effect = Channel<LoginEffect>(Channel.BUFFERED)
val effect = _effect.receiveAsFlow()
private suspend fun sendEffect(effect: LoginEffect) {
_effect.send(effect)
}
5. ViewModel 应该怎么设计
5.1 先设计 UI State
写 ViewModel 前,先问:这个页面完整渲染需要哪些数据?
例如登录页:
kotlin
data class LoginUiState(
val account: String = "",
val password: String = "",
val accountError: String? = null,
val passwordError: String? = null,
val loading: Boolean = false,
val loginEnabled: Boolean = false,
)
这个状态能描述:
- 输入框内容;
- 错误提示;
- 登录按钮是否可点;
- 是否正在请求。
View 只要拿到这个状态,就知道自己应该怎么显示。
5.2 再设计用户事件
不要一上来就写 doSomething()。先列出用户能做什么。
kotlin
sealed interface LoginAction {
data class AccountChanged(val value: String) : LoginAction
data class PasswordChanged(val value: String) : LoginAction
data object LoginClicked : LoginAction
}
也可以直接暴露方法:
kotlin
fun onAccountChange(value: String)
fun onPasswordChange(value: String)
fun login()
小页面直接方法更清楚;复杂页面用统一 dispatch(action) 更方便沉淀状态机。
5.3 区分 State 和 Effect
| 类型 | 是否可重复渲染 | 示例 |
|---|---|---|
| State | 可以 | Loading、列表数据、错误文案、按钮状态 |
| Effect | 通常不可以 | Toast、导航、打开相册、复制到剪贴板 |
判断标准:
- 屏幕旋转后还应该存在吗?多半是 State;
- 只应该发生一次吗?多半是 Effect;
- 用户返回页面还要看到吗?多半是 State;
- 重复消费会出 bug 吗?多半是 Effect。
5.4 ViewModel 的依赖应该从构造函数传入
推荐:
kotlin
class LoginViewModel(
private val loginUseCase: LoginUseCase,
private val savedStateHandle: SavedStateHandle,
) : ViewModel()
好处:
- 依赖清晰;
- 测试方便;
- 更容易替换实现;
- 不需要在 ViewModel 内部到处 new 对象。
不推荐:
kotlin
class LoginViewModel : ViewModel() {
private val api = LoginApi()
private val database = AppDatabase.getInstance()
}
这会让 ViewModel 难以测试,也会把创建细节和业务逻辑混在一起。
5.5 ViewModel 不要持有 View
错误示例:
kotlin
class LoginViewModel : ViewModel() {
var activity: Activity? = null
fun loginSuccess() {
activity?.startActivity(Intent(activity, HomeActivity::class.java))
}
}
问题:
- 容易内存泄漏;
- 生命周期不清晰;
- ViewModel 变得无法脱离 Android UI 测试;
- 页面跳转逻辑和状态逻辑耦合。
推荐让 ViewModel 发出 Effect,由 View 执行具体 UI 行为。
6. 一套完整的 MVVM 登录页示例
下面用 Android Kotlin + StateFlow 演示一套完整写法。
6.1 UI State
kotlin
data class LoginUiState(
val account: String = "",
val password: String = "",
val accountError: String? = null,
val passwordError: String? = null,
val loading: Boolean = false,
val loginEnabled: Boolean = false,
) {
fun refreshButtonState(): LoginUiState {
return copy(
loginEnabled = account.isNotBlank() &&
password.length >= 6 &&
!loading,
)
}
}
6.2 一次性 Effect
kotlin
sealed interface LoginEffect {
data object NavigateHome : LoginEffect
data class ShowMessage(val message: String) : LoginEffect
}
6.3 UseCase
kotlin
class LoginUseCase(
private val userRepository: UserRepository,
) {
suspend operator fun invoke(
account: String,
password: String,
): Result<User> {
if (account.isBlank()) {
return Result.failure(IllegalArgumentException("请输入账号"))
}
if (password.length < 6) {
return Result.failure(IllegalArgumentException("密码至少 6 位"))
}
return userRepository.login(account, password)
}
}
UseCase 用来表达业务流程。这里校验比较简单,放在 ViewModel 里也可以;当规则变复杂或多个页面复用时,抽成 UseCase 更合适。
6.4 Repository
kotlin
interface UserRepository {
suspend fun login(
account: String,
password: String,
): Result<User>
}
class DefaultUserRepository(
private val remoteDataSource: UserRemoteDataSource,
private val localDataSource: UserLocalDataSource,
) : UserRepository {
override suspend fun login(
account: String,
password: String,
): Result<User> {
return runCatching {
val response = remoteDataSource.login(account, password)
localDataSource.saveToken(response.token)
response.user.toDomain()
}
}
}
Repository 负责屏蔽"数据从哪里来"。ViewModel 不需要知道 token 存在哪里,也不需要知道接口 DTO 长什么样。
6.5 ViewModel
kotlin
class LoginViewModel(
private val loginUseCase: LoginUseCase,
) : ViewModel() {
private val _uiState = MutableStateFlow(LoginUiState())
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
private val _effect = Channel<LoginEffect>(Channel.BUFFERED)
val effect: Flow<LoginEffect> = _effect.receiveAsFlow()
fun onAccountChange(value: String) {
_uiState.update { state ->
state.copy(
account = value,
accountError = null,
).refreshButtonState()
}
}
fun onPasswordChange(value: String) {
_uiState.update { state ->
state.copy(
password = value,
passwordError = null,
).refreshButtonState()
}
}
fun login() {
val current = _uiState.value
if (current.loading) {
return
}
if (!validate(current)) {
return
}
viewModelScope.launch {
_uiState.update { state ->
state.copy(loading = true).refreshButtonState()
}
val result = loginUseCase(
account = current.account,
password = current.password,
)
result
.onSuccess {
_uiState.update { state ->
state.copy(loading = false).refreshButtonState()
}
_effect.send(LoginEffect.NavigateHome)
}
.onFailure { error ->
_uiState.update { state ->
state.copy(loading = false).refreshButtonState()
}
_effect.send(
LoginEffect.ShowMessage(error.message ?: "登录失败"),
)
}
}
}
private fun validate(state: LoginUiState): Boolean {
var valid = true
val accountError = if (state.account.isBlank()) {
valid = false
"请输入账号"
} else {
null
}
val passwordError = if (state.password.length < 6) {
valid = false
"密码至少 6 位"
} else {
null
}
if (!valid) {
_uiState.update {
it.copy(
accountError = accountError,
passwordError = passwordError,
).refreshButtonState()
}
}
return valid
}
}
这段 ViewModel 做了几件事:
- 保存登录页 UI State;
- 接收账号和密码变化;
- 计算按钮是否可点击;
- 点击登录时先防重复提交;
- 做输入校验;
- 调用 LoginUseCase;
- 请求中更新 Loading;
- 成功后发出导航 Effect;
- 失败后发出消息 Effect。
它没有直接操作输入框、按钮、Toast、Activity,也没有直接创建 API 或数据库。
6.6 Compose View
kotlin
@Composable
fun LoginRoute(
viewModel: LoginViewModel,
onLoginSuccess: () -> Unit,
showMessage: (String) -> Unit,
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(viewModel) {
viewModel.effect.collect { effect ->
when (effect) {
LoginEffect.NavigateHome -> onLoginSuccess()
is LoginEffect.ShowMessage -> showMessage(effect.message)
}
}
}
LoginScreen(
state = state,
onAccountChange = viewModel::onAccountChange,
onPasswordChange = viewModel::onPasswordChange,
onLoginClick = viewModel::login,
)
}
@Composable
fun LoginScreen(
state: LoginUiState,
onAccountChange: (String) -> Unit,
onPasswordChange: (String) -> Unit,
onLoginClick: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
) {
OutlinedTextField(
value = state.account,
onValueChange = onAccountChange,
label = { Text("账号") },
isError = state.accountError != null,
supportingText = {
state.accountError?.let { Text(it) }
},
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.password,
onValueChange = onPasswordChange,
label = { Text("密码") },
isError = state.passwordError != null,
supportingText = {
state.passwordError?.let { Text(it) }
},
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = onLoginClick,
enabled = state.loginEnabled,
modifier = Modifier.fillMaxWidth(),
) {
if (state.loading) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
)
} else {
Text("登录")
}
}
}
}
这里的 View 非常薄:
- 它订阅
uiState; - 它收集
effect; - 它把用户输入转给 ViewModel;
- 它只根据状态决定怎么显示。
7. 如果是 XML + LiveData,MVVM 怎么写
不是所有项目都在用 Compose。传统 XML 项目里,MVVM 依然成立。
ViewModel
kotlin
class ProfileViewModel(
private val userRepository: UserRepository,
) : ViewModel() {
private val _uiState = MutableLiveData<ProfileUiState>()
val uiState: LiveData<ProfileUiState> = _uiState
fun loadProfile() {
viewModelScope.launch {
_uiState.value = ProfileUiState.Loading
val result = userRepository.getProfile()
_uiState.value = result.fold(
onSuccess = { user -> ProfileUiState.Content(user) },
onFailure = { error ->
ProfileUiState.Error(error.message ?: "加载失败")
},
)
}
}
}
View
kotlin
class ProfileFragment : Fragment(R.layout.fragment_profile) {
private val viewModel: ProfileViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewModel.uiState.observe(viewLifecycleOwner) { state ->
render(state)
}
viewModel.loadProfile()
}
private fun render(state: ProfileUiState) {
when (state) {
ProfileUiState.Loading -> showLoading()
is ProfileUiState.Content -> showContent(state.user)
is ProfileUiState.Error -> showError(state.message)
}
}
}
关键点:
- LiveData 对外只暴露不可变类型;
- Fragment 使用
viewLifecycleOwner观察; - Fragment 不直接调用 Repository;
- ViewModel 不持有 Fragment。
8. UI State 应该怎么建模
方式一:单一 data class
适合表单、搜索、详情页这类状态组合较多的页面。
kotlin
data class SearchUiState(
val keyword: String = "",
val loading: Boolean = false,
val results: List<SearchItemUiModel> = emptyList(),
val errorMessage: String? = null,
val hasMore: Boolean = true,
)
优点:
- 一个对象描述完整页面;
- 更新时用
copy; - Compose/Flutter 等声明式 UI 很好使用;
- 状态可序列化、可打印、可测试。
缺点:
- 状态字段很多时容易变大;
- 互斥状态需要额外约束。
方式二:sealed class 表达互斥状态
适合 Loading、Empty、Error、Content 互斥明显的页面。
kotlin
sealed interface ArticleUiState {
data object Loading : ArticleUiState
data object Empty : ArticleUiState
data class Error(val message: String) : ArticleUiState
data class Content(val articles: List<ArticleUiModel>) : ArticleUiState
}
优点:
- 状态互斥更清楚;
- 不会出现
loading = true同时error != null的混乱组合; when分支能被编译器检查。
缺点:
- 表单类页面不一定适合;
- 局部状态变化可能需要更多样板代码。
选择建议
| 页面类型 | 推荐建模 |
|---|---|
| 登录、注册、表单 | data class |
| 详情页 | data class 或 sealed class |
| 简单列表 | sealed class |
| 复杂列表筛选 | data class |
| 强互斥页面状态 | sealed class |
| 多 Tab 页面 | 每个 Tab 单独状态,再组合 |
9. ViewModel 和 Repository 怎么分工
ViewModel 关心页面
例如:
- 是否显示 Loading;
- 是否显示错误;
- 是否禁用按钮;
- 是否打开详情页;
- 搜索关键词是什么;
- 当前页码是多少;
- 列表是否正在刷新。
Repository 关心数据
例如:
- 数据来自网络还是缓存;
- 接口失败是否读取本地数据;
- token 如何保存;
- DTO 如何转 Domain Model;
- 数据库如何查询;
- 多个数据源如何合并。
错误边界
错误可以分层处理:
text
RemoteDataSource
处理 HTTP、JSON、底层异常
Repository
转换成业务结果
UseCase
应用业务规则
ViewModel
转成 UI 可展示状态
View
展示错误文案或重试入口
例子:
kotlin
class GetArticleListUseCase(
private val articleRepository: ArticleRepository,
) {
suspend operator fun invoke(): Result<List<Article>> {
return articleRepository.getArticles()
.map { articles ->
articles.filter { it.visible }
}
}
}
ViewModel 调用:
kotlin
fun loadArticles() {
viewModelScope.launch {
_uiState.value = ArticleUiState.Loading
_uiState.value = getArticleListUseCase().fold(
onSuccess = { articles ->
if (articles.isEmpty()) {
ArticleUiState.Empty
} else {
ArticleUiState.Content(articles.map { it.toUiModel() })
}
},
onFailure = { error ->
ArticleUiState.Error(error.message ?: "加载失败")
},
)
}
}
这样 Repository 不需要知道 Empty 页面怎么展示,ViewModel 也不需要知道接口细节。
10. ViewModel 里的数据转换
为什么需要 UI Model
接口返回的 DTO 往往不适合直接给 View 用:
kotlin
data class UserDto(
val id: String?,
val nick_name: String?,
val avatar: String?,
val vip: Int?,
)
View 更希望拿到稳定、干净、可展示的数据:
kotlin
data class UserUiModel(
val id: String,
val name: String,
val avatarUrl: String,
val vipLabel: String,
)
转换:
kotlin
fun User.toUiModel(): UserUiModel {
return UserUiModel(
id = id,
name = name.ifBlank { "未命名用户" },
avatarUrl = avatarUrl ?: "",
vipLabel = if (vip) "VIP" else "普通用户",
)
}
转换放在哪里
| 转换类型 | 推荐位置 |
|---|---|
| DTO -> Entity | DataSource 或 Repository |
| DTO -> Domain Model | Repository |
| Domain Model -> UI Model | ViewModel 或 mapper |
| UI 输入 -> 请求参数 | ViewModel 或 UseCase |
| 错误码 -> UI 文案 | ViewModel 或统一 ErrorMapper |
注意
ViewModel 可以做"页面展示所需的转换",但不要把复杂业务计算都堆进去。复杂规则应该下沉到 UseCase 或 Domain 层。
11. ViewModel 生命周期怎么理解
Android Jetpack ViewModel
在 Android 中,ViewModel 的生命周期和 ViewModelStoreOwner 绑定,常见 owner 包括 Activity、Fragment、Navigation back stack entry。
它的关键特性:
- 配置变化时保留,例如屏幕旋转;
- owner 真正结束时才清除;
- 可以用
viewModelScope启动协程; onCleared()中可以清理长生命周期资源;- 不应该持有短生命周期 View 对象。
常见作用域
| 作用域 | 生命周期 | 适合保存 |
|---|---|---|
| Activity ViewModel | Activity 范围 | 多 Fragment 共享状态 |
| Fragment ViewModel | 单个 Fragment 范围 | 单页面状态 |
| Navigation Graph ViewModel | 导航图范围 | 一组页面共享状态 |
| Composable ViewModel | 路由入口范围 | Compose 页面状态 |
onCleared()
kotlin
override fun onCleared() {
super.onCleared()
// 清理非 viewModelScope 管理的资源
}
如果协程是在 viewModelScope 中启动,ViewModel 清理时会自动取消。
但如果你自己创建了其他资源,例如长连接、外部监听、非协程订阅,仍然要主动释放。
SavedStateHandle
SavedStateHandle 适合保存小而关键的状态,例如页面参数、搜索关键词、当前 tab。
kotlin
class SearchViewModel(
private val savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val keywordKey = "keyword"
val keyword: StateFlow<String> =
savedStateHandle.getStateFlow(keywordKey, "")
fun onKeywordChange(value: String) {
savedStateHandle[keywordKey] = value
}
}
不要把大列表、大对象、Bitmap、复杂缓存全部塞进 SavedStateHandle。它适合保存恢复页面所需的最小信息。
12. MVVM 中的双向绑定怎么理解
传统理解
早期 MVVM 经常强调双向绑定:
text
View 改变 -> ViewModel 更新
ViewModel 改变 -> View 自动刷新
例如输入框内容和 ViewModel 字段自动同步。
现代实践
在现代 Android、Flutter、React 这类声明式 UI 中,更推荐显式事件和单向状态:
text
value = state.account
onValueChange = viewModel::onAccountChange
看起来多写了一点,但好处是:
- 数据来源明确;
- 用户事件可追踪;
- 更容易打日志;
- 更容易测试;
- 不容易出现隐式循环更新。
Compose 示例
kotlin
OutlinedTextField(
value = state.account,
onValueChange = viewModel::onAccountChange,
label = { Text("账号") },
)
这就是"状态下发、事件上行":
state.account从 ViewModel 下发给 View;onAccountChange从 View 上传事件给 ViewModel。
13. 常见误区
误区 1:用了 ViewModel 类就是 MVVM
不是。
如果 ViewModel 只是把 API 调用挪了个文件,但 View 仍然直接拼业务、直接改全局状态、直接判断复杂规则,那么架构并没有真正清晰。
判断标准:
- View 是否足够薄;
- 状态是否集中;
- 数据流是否明确;
- Model 层是否隔离了数据来源;
- ViewModel 是否能单独测试。
误区 2:ViewModel 越大越好
ViewModel 不是万能垃圾桶。
如果一个 ViewModel 里有:
- 几千行代码;
- 多个完全不同业务;
- 大量网络细节;
- 数据库 SQL;
- 复杂算法;
- 平台权限处理;
- UI 动画细节;
- 多页面共享的全局状态;
就说明它已经失控了。
处理方式:
- 抽 UseCase;
- 抽 Repository;
- 抽 Mapper;
- 拆分子页面 ViewModel;
- 把纯 UI 状态留在组件内部;
- 把跨页面状态放到合适的共享作用域。
误区 3:ViewModel 可以持有 Context
原则上不要持有 Activity、Fragment、View 这类短生命周期对象。
如果必须使用 Context,优先考虑:
- 是否可以把逻辑放到 View;
- 是否可以传入 Application Context;
- 是否可以抽成接口;
- 是否可以由 Repository 或平台服务处理。
例如字符串资源可以在 View 层转换,也可以通过 ResourceProvider 抽象。
kotlin
interface ResourceProvider {
fun getString(id: Int): String
}
误区 4:把一次性事件放进 State
导航、Toast、打开相册这类事件只应该消费一次。
如果放进 State,页面重建时可能重复执行。更好的方式是 Effect。
误区 5:Repository 返回 UI 文案
Repository 不应该关心"页面展示什么文案"。
它可以返回业务错误:
kotlin
sealed interface LoginError {
data object InvalidPassword : LoginError
data object NetworkUnavailable : LoginError
data object Unknown : LoginError
}
ViewModel 再把业务错误转换成 UI 文案:
kotlin
fun LoginError.toMessage(): String {
return when (this) {
LoginError.InvalidPassword -> "账号或密码错误"
LoginError.NetworkUnavailable -> "网络不可用,请稍后重试"
LoginError.Unknown -> "登录失败"
}
}
误区 6:状态太碎
不要为每个字段都暴露一个可观察对象:
kotlin
val loading: LiveData<Boolean>
val title: LiveData<String>
val list: LiveData<List<Item>>
val error: LiveData<String?>
这样状态组合容易不一致。更推荐暴露一个完整的 UI State:
kotlin
val uiState: StateFlow<ArticleUiState>
误区 7:在 build 或 render 中做重计算
View 渲染函数可能频繁调用。不要在里面做大 JSON 解析、复杂排序、大量过滤。
这些工作应该放到 ViewModel、UseCase 或后台线程中完成,然后把结果作为 UI State 提供给 View。
14. MVVM 和 MVC、MVP、MVI 的区别
简单对比
| 模式 | 核心角色 | View 和业务的关系 | 状态特点 |
|---|---|---|---|
| MVC | Model、View、Controller | Controller 处理输入,View 可能较重 | 容易分散 |
| MVP | Model、View、Presenter | Presenter 主动调用 View 接口 | View 被动,Presenter 易变大 |
| MVVM | Model、View、ViewModel | View 观察状态,事件交给 ViewModel | 状态集中,可观察 |
| MVI | Model、View、Intent | 单向数据流,Intent 触发 Reduce | 状态机更严格 |
MVVM 和 MVI 的关系
很多现代 MVVM 已经吸收了 MVI 的优点:
- 单向数据流;
- UI State;
- User Intent / Action;
- Reducer 思想;
- Effect 单独处理。
所以不要过度纠结名字。真正重要的是状态是否清楚,数据流是否稳定,职责是否分离。
15. 目录结构怎么设计
按功能模块组织
推荐:
text
feature/
└── login/
├── data/
│ ├── LoginApi.kt
│ ├── LoginDto.kt
│ └── DefaultLoginRepository.kt
├── domain/
│ ├── LoginUseCase.kt
│ └── UserRepository.kt
└── presentation/
├── LoginEffect.kt
├── LoginScreen.kt
├── LoginUiState.kt
└── LoginViewModel.kt
优点:
- 登录相关代码聚在一起;
- 删除功能时容易;
- 模块边界清楚;
- 适合组件化。
按技术层组织
text
data/
domain/
presentation/
这种方式适合小项目或学习项目,但项目变大后,跨目录找一个功能会比较累。
选择建议
| 项目规模 | 推荐方式 |
|---|---|
| Demo、小工具 | 按层或简单目录都可以 |
| 中型业务项目 | 按 feature,再分 layer |
| 大型组件化项目 | feature module + layer |
| 多端共享业务 | domain/data 可共享,presentation 分端 |
16. ViewModel 怎么测试
为什么 ViewModel 好测试
因为 ViewModel 不依赖真实 View,不直接操作控件。只要把 Repository 或 UseCase 替换成假的实现,就可以验证状态变化。
测试什么
| 场景 | 验证点 |
|---|---|
| 初始状态 | 默认 UI State 是否正确 |
| 输入账号 | account 是否更新,错误是否清除 |
| 输入密码 | password 是否更新,按钮状态是否变化 |
| 登录成功 | Loading 变化,是否发出 NavigateHome |
| 登录失败 | Loading 变化,是否发出错误 Effect |
| 重复点击 | 是否防止重复请求 |
| 页面恢复 | SavedStateHandle 是否恢复关键字段 |
测试示例
kotlin
class FakeLoginUseCase(
private val result: Result<User>,
) {
suspend operator fun invoke(
account: String,
password: String,
): Result<User> {
return result
}
}
伪代码:
kotlin
@Test
fun loginSuccessShouldNavigateHome() = runTest {
val viewModel = LoginViewModel(
loginUseCase = successLoginUseCase,
)
viewModel.onAccountChange("ada")
viewModel.onPasswordChange("123456")
viewModel.login()
assertFalse(viewModel.uiState.value.loading)
assertEquals(LoginEffect.NavigateHome, viewModel.effect.first())
}
真实项目里可以结合 Turbine 测试 Flow,结合 kotlinx-coroutines-test 控制协程调度。
17. Flutter 里怎么理解 MVVM
虽然 Android Jetpack 有明确的 ViewModel 类,但 MVVM 不是 Android 专属。
在 Flutter 里可以这样映射:
| MVVM 角色 | Flutter 常见写法 |
|---|---|
| View | Widget |
| ViewModel | ChangeNotifier、StateNotifier、Cubit、Bloc、自定义 Controller |
| Model | Repository、Service、DTO、Entity |
| UI State | immutable state class |
| UI Event | Widget 回调 |
| Effect | Navigator、SnackBar、Dialog 触发事件 |
简单示意:
dart
class LoginState {
const LoginState({
this.account = '',
this.password = '',
this.loading = false,
this.errorMessage,
});
final String account;
final String password;
final bool loading;
final String? errorMessage;
LoginState copyWith({
String? account,
String? password,
bool? loading,
String? errorMessage,
}) {
return LoginState(
account: account ?? this.account,
password: password ?? this.password,
loading: loading ?? this.loading,
errorMessage: errorMessage,
);
}
}
不管你用哪种状态管理库,核心都是一样的:
- Widget 不直接请求接口;
- ViewModel/Controller 维护状态;
- Repository 提供数据;
- UI 根据状态刷新;
- 用户事件通过回调进入 ViewModel。
18. 一套可直接套用的设计流程
第一步:画出页面状态
先写 UI State:
kotlin
data class PageUiState(
val loading: Boolean = false,
val data: List<ItemUiModel> = emptyList(),
val errorMessage: String? = null,
)
第二步:列出用户事件
kotlin
sealed interface PageAction {
data object Refresh : PageAction
data object LoadMore : PageAction
data class ItemClicked(val id: String) : PageAction
}
第三步:定义一次性效果
kotlin
sealed interface PageEffect {
data class OpenDetail(val id: String) : PageEffect
data class ShowMessage(val message: String) : PageEffect
}
第四步:定义数据接口
kotlin
interface ItemRepository {
suspend fun getItems(page: Int): Result<List<Item>>
}
第五步:实现 ViewModel
kotlin
class PageViewModel(
private val itemRepository: ItemRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(PageUiState())
val uiState = _uiState.asStateFlow()
private val _effect = Channel<PageEffect>(Channel.BUFFERED)
val effect = _effect.receiveAsFlow()
fun refresh() {
viewModelScope.launch {
_uiState.update { it.copy(loading = true, errorMessage = null) }
itemRepository.getItems(page = 1)
.onSuccess { items ->
_uiState.update {
it.copy(
loading = false,
data = items.map { item -> item.toUiModel() },
)
}
}
.onFailure { error ->
_uiState.update {
it.copy(
loading = false,
errorMessage = error.message ?: "加载失败",
)
}
}
}
}
fun openDetail(id: String) {
viewModelScope.launch {
_effect.send(PageEffect.OpenDetail(id))
}
}
}
第六步:让 View 只做渲染和转发
kotlin
@Composable
fun PageRoute(viewModel: PageViewModel) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
PageScreen(
state = state,
onRefresh = viewModel::refresh,
onItemClick = viewModel::openDetail,
)
}
19. 排查 MVVM 设计是否健康
检查 View
| 问题 | 如果答案是"是",说明什么 |
|---|---|
| View 是否直接请求接口 | View 过重 |
| View 是否直接读写数据库 | 数据层边界不清 |
| View 是否有大量 if/else 业务判断 | 业务逻辑泄漏 |
| View 是否保存复杂页面状态 | 状态分散 |
| View 是否直接拼接 DTO 字段 | 缺少 UI Model |
检查 ViewModel
| 问题 | 如果答案是"是",说明什么 |
|---|---|
| ViewModel 是否持有 Activity/Fragment/View | 有泄漏风险 |
| ViewModel 是否超过几百行还持续增长 | 职责过多 |
| ViewModel 是否直接创建 API/Database | 依赖不可替换 |
| ViewModel 是否包含大量平台权限代码 | 平台职责混入 |
| ViewModel 是否暴露 MutableStateFlow | 外部可随意修改状态 |
| ViewModel 是否把导航放在 State 里 | 可能重复消费 |
| ViewModel 是否做重型计算 | 可能造成卡顿 |
检查 Model
| 问题 | 如果答案是"是",说明什么 |
|---|---|
| Repository 是否返回 UI 文案 | UI 逻辑下沉过深 |
| Repository 是否知道页面 Loading | 层级反向依赖 |
| DataSource 是否暴露给 ViewModel | Repository 边界失效 |
| DTO 是否直接传到 View | 数据模型污染 UI |
| 缓存策略是否散落多处 | 数据治理混乱 |
健康的信号
- ViewModel 构造函数能清楚看出依赖;
- ViewModel 对外只暴露不可变状态;
- View 层大部分代码是渲染;
- Repository 可以用 Fake 实现替换;
- UI State 能完整描述页面;
- 单元测试能覆盖关键交互;
- 新增错误状态不用大改 UI 结构;
- 一次性事件不会重复消费。
20. 面试或技术分享怎么回答
问:你怎么理解 MVVM?
可以这样回答:
MVVM 是一种表现层架构模式,用来拆分 View、ViewModel 和 Model 的职责。View 负责渲染界面和接收用户输入;ViewModel 负责持有 UI 状态、处理用户事件、调用业务或数据层,并把结果转换成页面可展示的状态;Model 负责数据来源和业务规则。一个好的 MVVM 通常会配合单向数据流,View 订阅 State,用户操作转成 Event 交给 ViewModel,ViewModel 调用 Repository 或 UseCase,最后更新 State 或发出一次性 Effect。
问:ViewModel 是什么?
可以这样回答:
ViewModel 是面向 View 的状态持有者和行为协调者。它不是数据库模型,也不是简单的网络请求封装。它应该暴露 UI State,接收用户事件,调用 UseCase 或 Repository,处理 Loading、Success、Error,并发出导航、Toast 这类一次性 Effect。在 Android 中,Jetpack ViewModel 还能在配置变化时保留状态,并通过 viewModelScope 管理协程生命周期。
问:MVVM 中数据怎么传输?
可以这样回答:
我更倾向于用单向数据流理解 MVVM。ViewModel 暴露 State 给 View,View 根据 State 渲染;用户点击或输入后,View 把事件交给 ViewModel;ViewModel 调用 Model 层获取或修改数据;Model 返回结果后,ViewModel 更新新的 State,View 再重新渲染。Toast、导航这种一次性行为不放进 State,而是用 Effect 单独处理。
问:ViewModel 能不能持有 Context?
可以这样回答:
一般不建议 ViewModel 持有 Activity、Fragment、View 这类短生命周期对象,否则容易泄漏,也会让 ViewModel 难以测试。如果确实需要资源或平台能力,可以考虑放到 View 层处理,或者通过 Application Context、ResourceProvider、Repository、UseCase 等抽象间接提供。
21. 总结
理解 MVVM,不要停留在"创建一个 ViewModel 文件"。
真正的 MVVM 要看职责和数据流:
- View 负责展示和事件转发;
- ViewModel 负责页面状态、用户事件、业务协调和 UI 数据转换;
- Model 负责数据来源、缓存和业务规则;
- Repository 屏蔽网络和数据库差异;
- UseCase 承载可复用的业务流程;
- UI State 描述页面当前样子;
- UI Event 描述用户做了什么;
- UI Effect 处理一次性行为;
- 数据最好按"状态下发、事件上行"的方向流动。
如果一个页面的 View 很薄,ViewModel 状态清晰,Repository 可替换,关键逻辑能测试,状态变化有迹可循,那么这套 MVVM 就是健康的。
一句话:MVVM 的核心不是分层本身,而是让界面变化、用户行为和业务数据之间的关系变得清楚、稳定、可维护。