本文译自「Making Android Code Cleaner with Use Cases: A Practical Approach Using Kotlin Coroutines」,原文链接https://proandroiddev.com/making-android-code-cleaner-with-use-cases-a-practical-approach-using-kotlin-coroutines-2700e724c4fd,由Siarhei Krupenich发布于2025年4月11日。
介绍
之前,我们开发了一个 Android 应用,重点关注了整洁架构 (Clean Architecture)、输入/输出 MVVM 拆分和 Repository 模式。这种方法遵循了 Google Android 团队推荐的最佳实践,使代码库具有可扩展性、可维护性和可测试性。
在本文中,我们将深入探讨另一个重要概念——用例 (Use Case),并向你介绍它的用法及其背后的背景。采用这种模式将使你的代码更具可读性和可测试性——这是一个巨大的优势。
使用Interactors带来的问题
过去,我们经常使用 Interactors 作为层与层之间的中间件组件——例如,Presenter 可以使用 Interactors 与领域层(Domain layer)进行通信。这使我们能够将一些逻辑从 Presenter 中移出,并放入单独的可复用组件中。在当时,这是一种可靠的逻辑拆分解决方案,能够保持代码简洁。
我们来看一个简单的例子:
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 |
|
随着 Presenter 的增长,逻辑的复杂性也会随之增加——这通常会导致 Interactor 中方法数量的增加。Presenter 越大,Interactor 也就越庞大。最终,我们会得到一坨塞满状态、方法和变量的屎堆,它们都堆挤在一个地方。
显然,这样的代码库维护起来很困难,测试起来也很困难,甚至更难用合适的单元测试来覆盖。最重要的是,这种架构会陷入反模式的境地,违反 SOLID(尤其是单一职责原则) 和 KISS 等核心原则。
这就是为什么我要强调使用 Interactor 方法时容易遇到的以下坑:
- 一处塞太多东西
当一个类处理所有操作——读取、写入、删除——它最终会做太多事情。这会导致测试更加困难,并且很难在不破坏其他功能的情况下进行更改。
- 功能不明确
像 LoginUser() 这样的用例会清楚地告诉你发生了什么。但是,如果交互器(interactor)很大,就很难区分它的作用——它是关于用户的、设置的还是其他什么的?
- 无法复用
只完成一项工作的用例很容易插入到任何需要的地方。交互器会随着时间的推移而增长,变得过于混乱,无法复用。
- 扩展性差
想象一下,有 10 个功能,每个功能都有自己的交互器,并且包含 5 个以上的方法。这需要记住很多东西,也需要管理很多代码。
- 逻辑混乱
当所有内容都放在一个文件中时,很容易意外地将不该放在一起的内容放在一起——例如,登录逻辑与个人资料更新逻辑就会混杂在一起。
用例(Use Case):从 UML 到 Android
我们刚才讨论的所有问题都可以通过使用用例(Use Case)方法完全解决或至少部分解决。但在深入探讨在Android上的实现之前,让我们先快速了解一下用例在 UML 术语中的含义。在 UML 中,用例是关于一个明确的意图——它代表一个特定的业务逻辑或功能。
查看下面的示例,了解它通常是如何可视化的:
基本上,我们即将实现的用例遵循与 UML 相同的理念:一个意图,一个用例(One Intent, one Use Case)。这个简单的规则帮助我们解决了之前的所有问题——测试变得更简单,代码更具可扩展性,整体也更易于维护。
现在,让我们使用用例方法改进上面的代码片段:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
因此,我们刚刚将 Interactor 拆分成了两个用例。我建议使用 Invoke 操作函数,这样我们就可以将它们的用例名称视为函数。此外,这种方式测试起来也更加容易。
以下 ViewModel 演示了用例的用法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
简单的测试可以写如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
|
如何将其纳入真正的 Repos 应用程序
我的建议是始终从抽象开始。鉴于用例的性质(通常只有一个公共方法),我建议使用运算符函数(例如,invoke 在这里就很有效)。可以实现以下抽象:
1 2 3 4 |
|
它的名称带有前缀Suspend(译注:这里应该是前缀,原文有错误),表示它处理暂停的结果。这种通用方法允许我们为参数和返回类型定义特定的类型。例如,以下特定的用例接口可以进一步使用:
1 2 |
|
根据其名称,它可以用于获取 Repos,并抛出其 Result 包装器。客户端可以以函数式的方式使用它(例如,val repos = getRepos(…))。它的实现如下:
1 2 3 4 5 6 7 8 9 |
|
现在,让我们让任务更具挑战性,并在用例结构中添加一个额外的抽象层。我想要实现仅与存储库交互的用例。这些用例将包含存储库的实例作为泛型中的附加参数类型。让我们看一下以下代码片段:
1 2 3 4 |
|
我修改了execute方法,使其能够从另一个抽象子类中调用。以下是更新后的代码片段:
1 2 3 4 5 6 |
|
每个实现类型R的存储库的子类都将遵守该接口(Contract)。最合适的方法是使用抽象类。让我们实现一个抽象类来实现这一点:
1 2 3 4 5 6 7 8 9 |
|
我们需要做的就是扩展 BaseRepositoryUseCase ,遵循其泛型接口,提供一个输入类、一个输出类以及一个被覆写的Repo实例。以下实现已经足够:
1 2 3 4 5 6 7 |
|
结论
因此,我们探索了从零开始使用用例(Use Case)、捕捉客户意图(Client Intent)的最佳方法。我演示了如何以功能性的方式实现和使用它们,使其易于测试和集成。