Jetpack Compose 中ViewModel的最佳实践

本文译自「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(不分离输入和输出)乍一看似乎没什么问题。但随着应用规模的增长,情况可能会变得混乱。原因如下:

  1. UI 和逻辑过于混杂
  • ViewModel 同时处理业务逻辑和 UI 更新,使它们紧密相连。
  • 如果需要更改 UI,通常也需要修改 ViewModel,这不应该发生。
  • 💡 例如:你的 UI 可能在一个地方处理验证、数据转换和加载状态。
  1. 测试难度加大
  • 功能过多的 ViewModel 会使测试编写变得复杂。
  • 不同时处理数据和状态,就无法轻松测试 UI 行为。
  • 💡 例如:即使是简单的 UI 测试也会变得棘手,因为 ViewModel 控制着一切。
  1. 更改 UI 变得令人头疼
  • 如果你的 ViewModel 没有正确分离,更改一个 UI 元素就会影响所有 UI 元素。
  • 💡 示例:将 TextView 替换为 RecyclerView 会迫使你修改 ViewModel,即使它不应该关心 UI 细节。
  1. ViewModel 变得过大
  • 随着时间的推移,ViewModel 会变得庞大且难以管理。
  • 它们会同时处理用户输入、API 调用和状态更新。💡 💡 示例:包含数百行代码的 ViewModel 难以阅读、调试或更新。
  1. 逻辑难以复用
  • 如果 ViewModel 混合了输入处理(按钮点击)和输出逻辑(数据格式化),那么复用其中的部分内容会变得非常麻烦。
  • 💡 示例:你想在另一个页面上复用某些业务逻辑,但它与特定于 UI 的代码纠缠在一起。
  1. UI 状态管理变得混乱
  • 在 ViewModel 内部处理加载、成功和错误状态会让事情变得混乱。
  • 💡 示例:处理失败的网络请求并显示错误消息不应与其他逻辑混淆。
  1. 大型应用中难以扩展
  • 如果多个页面共享一个 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

欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!

保护原创,请勿转载!

相关推荐
李新_9 小时前
我们使用了哪些Flutter 三方库(二)
android·flutter·ios
二流小码农10 小时前
鸿蒙开发:hvigorw,编译构建,实现命令打包
android·ios·harmonyos
龙之叶11 小时前
使用NMEA Tools生成GPS轨迹图
android
雨白12 小时前
ListView 使用详解:从入门、自定义到性能优化
android
百里东风12 小时前
STM32CubeDAC及DMA配置
android·stm32·嵌入式硬件
getapi12 小时前
flutter开发安卓APP适配不同尺寸的手机屏幕
android·flutter·智能手机
bytebeats12 小时前
移动开发中WebView使用的过去现在和未来
android·webview
恋猫de小郭12 小时前
腾讯 ovCompose 开源,Kuikly 鸿蒙和 Compose DSL 开源,腾讯的“双”鸿蒙方案发布
android·前端·flutter
Chenyu_31013 小时前
05.MySQL表的约束
android·开发语言·网络·数据库·网络协议·mysql·php
我的蒲公英13 小时前
2025年了,别再用微信群发APK了:内测分发的正确打开方式
android·安全·ios