本文译自「MVVM Inputs/Outputs: Best Practices and Implementation in Jetpack Compose」,原文链接medium.com/@siarhei.kr..., 由Siarhei Krupenich发布于2025年3月16日。
译注: 因为文章重点讨论的是ViewModel的实现方式,并不涉及平台特性,所以完全适用于跨平台的Compose Multiplatform。

简介
在我之前的文章中,我探讨了"清晰架构"(Clean Architecture)作为一种实用的 Android 开发方法。这种架构解决方案侧重于将逻辑组件划分为不同的层,每一层负责各自的任务。
本文以此为基础,通过一个真实的应用示例介绍另一种最佳实践。我们将重点介绍使用 ViewModel 时的一个常见问题,概述一个结构化的解决方案,并深入探讨其背后的理论。此外,我将演示如何使用 Jetpack Compose 有效地实现这种方法,并提供各种示例。所有代码片段都假设读者理解使用 Hilt 的依赖注入 (DI - Dependency Injection)。
ViewModel概述
MVVM 的核心思想是通过将 UI 逻辑移入状态来将其与视图分离。这确保了视图在保持逻辑井然有序的同时保持简洁。首先,这种方法符合单一职责原则,从而增强了可测试性和可扩展性。此外,它还解决了一个典型的 Android 挑战------在生命周期事件(例如配置更改)期间处理 UI 状态。
为了实现这一点,我们使用 ViewModel 来管理并保留其状态,即使关联的 Activity 被重新创建。我们来看下面的例子:
Kotlin
// ViewModel 例子
@HiltViewModel
internal class ScreenViewModel @Inject constructor(
private val getData: GetUiDataUseCase,
) : ViewModel() {
private val _state = MutableStateFlow<State>(State.Loading)
val state: StateFlow<State> = _state.asStateFlow()
fun loadData(isRefreshing: Boolean) {
updateState(isRefreshing)
}
private fun updateState(isRefreshing: Boolean) = viewModelScope.launch {
_state.value = State.Loading
_state.value = getData(isRefreshing)
}
}
上面的代码片段演示了一个简单的 ViewModel,它只有一个状态和一个由 init 触发的方法。loadData() 方法启动状态更新过程,该过程由协程 StateFlow 管理。
一个好的做法是将所有可能的页面状态合并到一个密封类中。这种方法能够以结构化的单方法风格处理 UI 状态变化,从而使你的代码更具可读性和可维护性。
以下示例说明了这一概念:
Kotlin
// State
internal sealed interface State {
data class Success(val repos: List<DataUi>): State
data object Empty: State
data object Loading: State
data class Error(val message: String?): State
}
现在,让我们看一下以下代码:
Kotlin
// Composable View that uses the ViewModel:
@Composable
fun Screen() {
val viewModel: ScreenViewModel = hiltViewModel()
val state by viewModel.state.collectAsState()
LaunchedEffect(Unit) {
viewModel.loadRepos(true)
}
RepoState(repoState, viewModel)
}
@Composable
private fun RepoState(state: State, viewModel: ScreenViewModel) {
when (state) {
is State.Success -> LazyColumn(modifier = Modifier.fillMaxSize()) {
items(state.data.count()) { index ->
Item(state.data[index])
}
}
State.Empty -> EmptyState(
stateMessage = "Empty message",
emptyButtonAction = {
viewModel.loadData(true)
}
)
is State.Error -> RepoEmptyState(
stateMessage = state.message,
emptyButtonAction = {
viewModel.loadData(true)
}
)
State.Loading -> LoadingState()
}
}
Screen() 可组合函数从 StateFlow 中观察状态,并在状态发生变化时进行更新。根据具体状态(Data、Empty、Error 或 Loading),将使用单函数方法触发相应的可组合函数进行处理。
总体而言,一切看起来都很稳定------状态在 ViewModel 中管理,并且视图(Activity 的一部分)可以安全地重新创建而不会丢失数据。此外,这种方法还可以更轻松地编写状态处理、ViewModel 函数调用和 UI 外观的单元测试。
常规ViewModel的不足之处
纵观当前的实现,首先突出的问题是 ViewModel 可能会不堪重负。随着应用的增长,我们将多个状态和逻辑打包在一个 ViewModel 中处理,这可能会违反单一职责原则。
另一个关键问题是 UI 和逻辑之间的紧密耦合。直接操作 ViewModel 的实例会使 UI 更加依赖于其具体实现,从而降低灵活性和可复用性。
为什么常规 ViewModel 会成为问题
使用常规 ViewModel(不分离输入和输出)乍一看似乎没什么问题。但随着应用规模的增长,情况可能会变得混乱。原因如下:
- UI 和逻辑过于混杂
- ViewModel 同时处理业务逻辑和 UI 更新,使它们紧密相连。
- 如果需要更改 UI,通常也需要修改 ViewModel,这不应该发生。
- 💡 例如:你的 UI 可能在一个地方处理验证、数据转换和加载状态。
- 测试难度加大
- 功能过多的 ViewModel 会使测试编写变得复杂。
- 不同时处理数据和状态,就无法轻松测试 UI 行为。
- 💡 例如:即使是简单的 UI 测试也会变得棘手,因为 ViewModel 控制着一切。
- 更改 UI 变得令人头疼
- 如果你的 ViewModel 没有正确分离,更改一个 UI 元素就会影响所有 UI 元素。
- 💡 示例:将 TextView 替换为 RecyclerView 会迫使你修改 ViewModel,即使它不应该关心 UI 细节。
- ViewModel 变得过大
- 随着时间的推移,ViewModel 会变得庞大且难以管理。
- 它们会同时处理用户输入、API 调用和状态更新。💡 💡 示例:包含数百行代码的 ViewModel 难以阅读、调试或更新。
- 逻辑难以复用
- 如果 ViewModel 混合了输入处理(按钮点击)和输出逻辑(数据格式化),那么复用其中的部分内容会变得非常麻烦。
- 💡 示例:你想在另一个页面上复用某些业务逻辑,但它与特定于 UI 的代码纠缠在一起。
- UI 状态管理变得混乱
- 在 ViewModel 内部处理加载、成功和错误状态会让事情变得混乱。
- 💡 示例:处理失败的网络请求并显示错误消息不应与其他逻辑混淆。
- 大型应用中难以扩展
- 如果多个页面共享一个 ViewModel,它就会超载。
- 在一个 ViewModel 中管理许多不同的 UI 状态会导致混乱。
- 💡 示例:管理 10 个以上页面的 ViewModel 很快就会成为维护的噩梦。
灵丹妙药:输入/输出式ViewModel
我们讨论的许多问题都可以通过使用输入/输出 ViewModel 方法得到最小化,甚至完全解决。此方法将 ViewModel 中的"输入"流和"输出"流分离。
- 输出处理 UI 的更新(例如,公开状态)。
- 输入接收来自 UI 的消息(例如,用户交互)。
例如,在典型的 ViewModel 中,StateFlow 代表"输出"流,因为它向 UI 提供状态更新。相反,像 reloadData(refreshing: Boolean) 这样的方法充当"输入"流,处理 UI 触发的操作。
此模式不是直接与 ViewModel 交互,而是通过输入和输出接口强制进行结构化访问,从而明确依赖关系并减少紧密耦合。
使用此模式的示例:
Kotlin
// 取代viewModel.reloadData(refreshing = true)
input.reloadData(refreshing = true)
// 取代val dataState by viewModel.data.collectAsState()
val dataState by output.data.collectAsState()
这种结构化方法提高了代码的清晰度、可测试性和可维护性,使 ViewModel 更加模块化和可扩展。
我们来画个图,直观地了解一下它的工作原理:

该图展示了该模式。ViewModel 实现了 ScreenViewModel 接口,该接口进一步细分为两个独立的接口------一个用于处理输入(操作),另一个用于提供输出(数据)。这种设置如同契约,确保了清晰的结构和分离。ViewModel 本身仍然是一个实例,避免在其层面直接操作。所有操作都通过输入和输出接口进行,从而强化了单一职责原则。最后,View 仅与 ScreenViewModel 接口交互,在抽象层进行操作。此外,View 还可以进一步细分为输入和输出接口,从而允许以简洁、模块化的方式访问方法和数据。
开撸吧!
首先,应该重构 ViewModel,将其封装在一个接口中,并将其功能分离到专用的输入和输出接口中。
Kotlin
// ViewModel
internal interface ScreenViewModel {
interface Input {
fun loadData(isRefreshing: Boolean = false)
}
interface Output {
val state: StateFlow<State>
}
@HiltViewModel
class ViewModel @Inject constructor(
private val getData: GetUiDataUseCase,
) : BaseViewModel(), Input, Output {
val input: Input = this
val output: Output = this
private val _state = MutableStateFlow<State>(State.Loading)
override val state: StateFlow<State> = _state.asStateFlow()
override fun loadData(isRefreshing: Boolean) {
updateState(isRefreshing)
}
private fun updateState(isRefreshing: Boolean) = viewModelScope.launch {
_state.value = State.Loading
_state.value = getData(isRefreshing)
}
}
}
接下来,我们实现与 ViewModel 交互的视图。以下代码片段提供了一个使用 Jetpack Compose 的示例:
Kotlin
@Composable
fun Screen() {
val viewModel: ReposScreenViewModel.ViewModel = hiltViewModel()
val state by viewModel.output.repoState.collectAsState()
LaunchedEffect(Unit) {
viewModel.input.loadData(true)
}
UIState(state, viewModel)
}
// UiState使用接口 ScreenViewModel.Input来操作ViewModel的输入
@Composable
private fun UIState(state: State, input: ScreenViewModel.Input) {
when (state) {
is State.Success -> LazyColumn(modifier = Modifier.fillMaxSize()) {
items(state.data.count()) { index ->
UIItem(state.data[index])
}
}
State.Empty -> EmptyState(
emptyButtonAction = {
// 取代 viewModel.loadData(true)
input.loadData(true)
}
)
is RepoState.Error -> EmptyState(isError = true)
RepoState.Loading -> LoadingState()
}
}
使用输入/输出式ViewModel 的优势
通过将 ViewModel 构建为输入和输出接口,你可以创建更简洁、更高效的架构。其优势如下:
✅ 清晰的关注点分离(SoC - Separation of Concerns)
输入处理用户操作(例如,按钮点击、文本输入),而输出管理 UI 状态和数据。这使得你的代码库更加结构化,更易于导航。
✅ 更轻松的测试
通过清晰的分离,你可以分别测试输入(用户交互)和输出(状态更新),从而使单元测试更加专注和可靠。
✅ 更好的可重用性和可扩展性
输入和输出可以在多个页面或功能之间重复使用,而无需重复逻辑,从而帮助你的应用在扩展过程中避免不必要的复杂性。
✅ 简化的状态管理
将 UI 状态(加载、成功、错误)保留在输出中,可以防止 ViewModel 被无关的逻辑淹没,从而使状态处理更加直观。
结论
我们探索了另一个可以无缝集成到你项目中的强大工具。通过采用这种方法,你可以增强应用的可扩展性,保持代码库简洁,并提高测试效率。这是一种简单而有效的方法,可以提高可维护性,并让你的开发流程面向未来。
欢迎查看包含现成解决方案的代码库:github.com/sergeykrupe...。
文章更新:有人指出,最好避免在 ViewModel 的 init 块中加载数据。相反,一种更灵活的方法是使用 Composable 中的 LaunchedEffect() 延迟触发数据加载。这可以确保 ViewModel 不会过早获取数据,并更好地与 Compose 的生命周期保持一致。(译注:关于副作用函数可参考之前的文章降Compose十八掌之『龙战于野』| Side Effects)
欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!
保护原创,请勿转载!