本文译自「MVVM Inputs/Outputs: Best Practices and Implementation in Jetpack Compose」,原文链接https://medium.com/@siarhei.krupenich/mvvm-inputs-outputs-best-practices-and-implementation-in-jetpack-compose-18966d4d914e, 由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 被重新创建。我们来看下面的例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
上面的代码片段演示了一个简单的 ViewModel,它只有一个状态和一个由 init 触发的方法。loadData() 方法启动状态更新过程,该过程由协程 StateFlow 管理。
一个好的做法是将所有可能的页面状态合并到一个密封类中。这种方法能够以结构化的单方法风格处理 UI 状态变化,从而使你的代码更具可读性和可维护性。
以下示例说明了这一概念:
1 2 3 4 5 6 7 |
|
现在,让我们看一下以下代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
|
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 交互,而是通过输入和输出接口强制进行结构化访问,从而明确依赖关系并减少紧密耦合。
使用此模式的示例:
1 2 3 4 5 |
|
这种结构化方法提高了代码的清晰度、可测试性和可维护性,使 ViewModel 更加模块化和可扩展。
我们来画个图,直观地了解一下它的工作原理:
该图展示了该模式。ViewModel 实现了 ScreenViewModel 接口,该接口进一步细分为两个独立的接口——一个用于处理输入(操作),另一个用于提供输出(数据)。这种设置如同契约,确保了清晰的结构和分离。ViewModel 本身仍然是一个实例,避免在其层面直接操作。所有操作都通过输入和输出接口进行,从而强化了单一职责原则。最后,View 仅与 ScreenViewModel 接口交互,在抽象层进行操作。此外,View 还可以进一步细分为输入和输出接口,从而允许以简洁、模块化的方式访问方法和数据。
开撸吧!
首先,应该重构 ViewModel,将其封装在一个接口中,并将其功能分离到专用的输入和输出接口中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
|
接下来,我们实现与 ViewModel 交互的视图。以下代码片段提供了一个使用 Jetpack Compose 的示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
|
使用输入/输出式ViewModel 的优势
通过将 ViewModel 构建为输入和输出接口,你可以创建更简洁、更高效的架构。其优势如下:
✅ 清晰的关注点分离(SoC - Separation of Concerns)
输入处理用户操作(例如,按钮点击、文本输入),而输出管理 UI 状态和数据。这使得你的代码库更加结构化,更易于导航。
✅ 更轻松的测试
通过清晰的分离,你可以分别测试输入(用户交互)和输出(状态更新),从而使单元测试更加专注和可靠。
✅ 更好的可重用性和可扩展性
输入和输出可以在多个页面或功能之间重复使用,而无需重复逻辑,从而帮助你的应用在扩展过程中避免不必要的复杂性。
✅ 简化的状态管理
将 UI 状态(加载、成功、错误)保留在输出中,可以防止 ViewModel 被无关的逻辑淹没,从而使状态处理更加直观。
结论
我们探索了另一个可以无缝集成到你项目中的强大工具。通过采用这种方法,你可以增强应用的可扩展性,保持代码库简洁,并提高测试效率。这是一种简单而有效的方法,可以提高可维护性,并让你的开发流程面向未来。
欢迎查看包含现成解决方案的代码库:https://github.com/sergeykrupenich/TestRepo/tree/inputs-outputs。
文章更新:有人指出,最好避免在 ViewModel 的 init 块中加载数据。相反,一种更灵活的方法是使用 Composable 中的 LaunchedEffect() 延迟触发数据加载。这可以确保 ViewModel 不会过早获取数据,并更好地与 Compose 的生命周期保持一致。(译注:关于副作用函数可参考之前的文章降Compose十八掌之『龙战于野』| Side Effects)