MVVM 的理解与设计

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 使用 ChannelSharedFlow 发出一次性效果:

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 的核心不是分层本身,而是让界面变化、用户行为和业务数据之间的关系变得清楚、稳定、可维护。

参考资料

相关推荐
码农阿豪2 小时前
我把 OpenClaw 装进旧安卓手机后,又折腾了 3 天:飞书、ADB、SSH 全部打通
android·智能手机·飞书
有什么事3 小时前
把安卓搬上云端:云手机系统改造与内核优化拆解
android·智能手机
方白羽4 小时前
性能分析:Android Studio Profiler
android·性能优化·app
没文化的阿浩4 小时前
【MySQL】用户管理
android·mysql·adb
FlightYe5 小时前
音视频修炼之基础理论(五):AAC格式与解析
android·linux·c++·音视频·aac
法欧特斯卡雷特6 小时前
Kotlin 2.4.20 现已发布,新特性多不多?
android·开源·全栈
摇滚侠6 小时前
《SpringBoot 3:入门与应用实战》第 12 章 JDBC 与事务 使用 JdbcTemplate 阅读笔记 31
android·spring boot·笔记
Zeaon7 小时前
别再手写筛选栏了:一个可组合的 Flutter 选择组件(5 入口 × 7 委托)
android·flutter
Michaelwubo7 小时前
mysql8 主从 HA切换
android·adb