安卓架构整洁指南-全-
安卓架构整洁指南(全)
原文:
zh.annas-archive.org/md5/dd4210727bd0bdc71245db60950ac866译者:飞龙
前言
随着应用程序代码库的增加,开发者维护现有功能并引入新功能变得越来越困难。在这本清洁架构的书中,您将学习如何识别何时以及如何出现这个问题,以及如何构建代码以克服它。
本书首先解释了清洁架构原则和 Android 架构组件,然后探讨了涉及的工具、框架和库。您将学习如何在数据和领域层中构建您的应用程序,每个层中包含的技术,以及每个层在保持应用程序清洁方面所起的作用。您将了解如何将代码组织到这两个层中,以及组装它们所涉及的组件。最后,我们将介绍表示层以及可以应用于实现解耦和可测试代码库的图案。
在本书结束时,您将能够按照清洁架构原则构建应用程序,并拥有维护和测试应用程序所需的知识。
本书面向对象
本书面向希望了解如何管理其应用程序复杂性的 Android 开发者,并且对于寻找清洁架构和集成各种 Android 技术的指南的中级或高级 Android 开发者来说,本书也极具推荐价值。熟悉 Android 应用开发基础的新开发者也会发现本书很有用。
本书涵盖内容
第一章,开始使用清洁架构,首先介绍了 Android 应用程序在业务逻辑结构方面的演变,以及这些方法引起的问题。然后,它将过渡到如何应用某些模式来解决这些问题,揭示其他一系列问题。最后,将介绍清洁架构的概念,以及如何使用其原则来解决之前提出的一些问题。
第二章,深入数据源,介绍了可用于实现数据层以及书中后续将使用的工具和框架,例如 Kotlin 流和协程、Retrofit、Room 和 DataStore。
第三章,理解 Android 上的数据表示,介绍了可用于实现表示层的 Android 工具和框架,并将详细说明和扩展书中后续将使用的工具,例如 Android ViewModel 和 Jetpack Compose。
第四章,管理 Android 应用程序中的依赖项,提供了依赖注入的快速概述及其工作原理。它简要探讨了 Android 开发中可用的某些依赖注入工具,并以 Hilt 依赖注入框架结束,因为它将在本书的许多练习中使用,并对它进行了更详细的解释。
第五章,构建 Android 应用程序的领域层,描述了如何构建领域层以及该层包含哪些组件。您将了解实体和用例或交互器,以及它们在设计应用程序架构时扮演的角色。
第六章,组装仓库,涵盖了数据层及其在管理应用程序数据时的职责,以及它如何使用仓库模式来实现这一点。
第七章,构建数据源,继续探索数据层以及可以在 Android 中定义的一些数据源示例。您将了解如何使用远程数据源从各种服务器加载数据,以及本地数据源,如 Room 和 DataStore。
第八章,实现 MVVM 架构,介绍了 MVVM 架构模式及其如何在应用程序的表现层中使用。您将学习如何使用 Android ViewModel 和 LiveData 构建 MVVM 应用程序并将用例集成到 ViewModel 中。
第九章,实现 MVI 架构,介绍了 MVI 架构模式及其如何在应用程序的表现层中使用。您将学习如何使用 Kotlin 流和 Android ViewModel 来实现 MVI 模式。
第十章,整合一切,通过分析实现这些概念的应用程序示例,探讨了干净架构的好处,然后添加了 Espresso 和 Jetpack Compose 的仪器测试。UI 测试的引入是一个很好的例子,说明了我们如何在不修改应用程序代码的情况下,为了测试目的注入和更改应用程序中的某些行为。
为了充分利用本书
您需要在您的计算机上安装 Android Studio IDE(版本 Arctic Fox 2020.3.1 Patch 3 或更高版本)并安装 Java 8。使用 Java 11 等较新版本的 Java 可能会在构建某些练习时导致错误。在尝试本书中提供的练习之前,建议您了解如何在模拟器或设备上触发构建以及从 Android Studio 中进行 Gradle 同步。

您可以通过优化数据加载方式、引入内存缓存或集成新的网络调用以获取用户额外的数据来扩展本书的最终练习。您还可以通过添加与数据列表的交互、打开新屏幕并断言正确数据显示来改进仪器测试。
如果您正在使用本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将帮助您避免与代码复制和粘贴相关的任何潜在错误。
下载示例代码文件
本书代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Clean-Android-Architecture。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们!
代码实战
本书代码实战视频可在bit.ly/3LqAa30查看
下载彩色图像
我们还提供了一份包含本书中使用的截图和图表的彩色图像 PDF 文件。您可以从这里下载:static.packt-cdn.com/downloads/9781803234588_ColorImages.pdf
使用的约定
本书使用了许多文本约定。
文本中的代码: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“在resources文件夹中,创建一个名为mockito-extensions的子文件夹。在这个文件夹中,创建一个名为org.mockito.plugins.MockMaker的文件,并在该文件中添加文本mock-maker-inline。”
代码块设置如下:
data class User(
val id: String,
val firstName: String,
val lastName: String,
val email: String
) {
fun getFullName() = "$firstName $lastName"
}
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
…
@Composable
fun Screen(viewModel: MainViewModel = viewModel(factory = MainViewModelFactory())) {
viewModel.uiStateLiveData.observeAsState().value?.let {
UserList(uiState = it)
}
}
…
粗体: 表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以粗体显示。以下是一个示例:“在 Android Studio 中使用Empty Compose Activity创建一个新项目。”
小贴士或重要注意事项
看起来像这样。
联系我们
我们始终欢迎读者的反馈。
一般反馈: 如果您对本书的任何方面有疑问,请通过 customercare@packtpub.com 发送电子邮件,并在邮件主题中提及本书标题。
勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将非常感激您能向我们报告。请访问www.packtpub.com/support/errata并填写表格。
盗版:如果您在互联网上发现任何形式的我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过版权@packtpub.com 与我们联系,并附上材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
分享您的想法
一旦您阅读了《Clean Android Architecture》,我们非常期待听到您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。
您的评论对我们和科技社区都非常重要,它将帮助我们确保我们提供的是高质量的内容。
第一部分 – 简介
在本部分,你将熟悉整洁架构的概念及其提供的原则。本部分还探讨了书中后续部分使用的工具、框架和库。
本部分包括以下章节:
-
第一章, 开始使用整洁架构
-
第二章, 深入数据源解析
-
第三章, 理解在 Android 上展示数据
-
第四章, 管理 Android 应用程序中的依赖关系
第一章:第一章:开始使用清洁架构
在本章中,我们将回顾过去如何实现一个功能,同时分析该方法可能存在的问题和问题。然后,我们将探讨软件开发的一些关键设计原则,并将这些原则应用于我们的遗留示例。之后,我们将介绍 Android 平台的演变以及出现的各种库和框架。我们还将看到它们如何在遵守各种软件设计原则的同时进行集成。
之后,我们将介绍清洁架构,以便我们知道我们的系统需要改进什么,以及作为开发者,我们必须提出哪些问题,以便我们可以创建一个健壮、可扩展、可维护和可测试的应用程序。
本章我们将涵盖以下主要主题:
-
遗留应用程序的架构
-
软件设计原则
-
探索 Android 的演变
-
进入清洁架构
到本章结束时,您将了解 Android 开发的演变、其架构和设计概念,以及清洁架构的概念以及如何用它来构建灵活、可维护和可测试的应用程序。
技术要求
对于本章,您需要 Android Studio Arctic Fox 2020.3.1 补丁 3。
以下是本章的硬件要求:
-
Windows:
-
64 位 Microsoft® Windows® 8/10
-
x86_64 CPU 架构;第二代 Intel Core 或更新的处理器,或支持 Windows Hypervisor 的 AMD CPU
-
至少 8 GB 的 RAM 或更多
-
至少 8 GB 的可用磁盘空间(IDE + Android SDK + Android 模拟器)
-
最小屏幕分辨率 1,280 x 800
-
-
Mac:
-
macOS® 10.14 (Mojave) 或更高版本
-
基于 ARM 的芯片,或支持 Hypervisor.Framework 的第二代 Intel Core 或更新的处理器
-
至少 8 GB 的 RAM 或更多
-
至少 8 GB 的可用磁盘空间(IDE + Android SDK + Android 模拟器)
-
最小屏幕分辨率 1,280 x 800
-
-
Linux:
-
支持 Gnome、KDE 或 Unity DE 的任何 64 位 Linux 发行版;GNU C Library (glibc) 2.31 或更高版本
-
x86_64 CPU 架构;第二代 Intel Core 或更新的处理器,或支持 AMD 虚拟化 (AMD-V) 和 SSSE3 的 AMD 处理器
-
至少 8 GB 的 RAM 或更多
-
至少 8 GB 的可用磁盘空间(IDE + Android SDK + Android 模拟器)
-
最小屏幕分辨率 1,280 x 800
-
遗留应用程序的架构
在本节中,我们将回顾 Android 应用程序过去是如何构建的,以及开发者在使用该方法时遇到的困难。
在我们开始分析一个较老的应用程序之前,我们必须区分应用程序的架构和设计。借用建筑行业的术语,我们可以将架构定义为建筑结构的计划;设计则是指创建建筑每个部分的计划。将这一概念转化为软件工程领域,我们可以认为应用程序或系统的架构是定义一个计划,该计划将包含业务和技术需求,而软件设计则涉及将所有组件、模块和框架整合到这个计划中。在一个理想的世界里,你希望以识别你房屋架构的方式识别应用程序的架构。
现在,让我们看看 Android 应用程序的四个主要组件:
-
活动:这些代表与用户交互的入口点。
-
服务:这些代表应用程序在后台运行的原因入口点,例如大型下载或音频播放。
-
广播接收器:这些允许系统以各种原因与应用程序交互。
-
内容提供者:这些代表应用程序管理应用数据的方式。
使用和依赖这些组件给开发者带来了挑战,因为应用程序的架构变得依赖于 Android 框架,尤其是在实现单元测试时。为了理解为什么这是一个问题,让我们看看一些较老的应用程序代码的例子。假设你被要求从一个后端服务获取一些数据。这些数据将通过 HTTP 连接以 JSON 的形式提供。
看到一个像BaseRequest.java这样的类并不罕见,它会执行请求并依赖于JsonMapper.java这种形式的抽象来将数据从String转换为普通 Java 对象(POJO)。以下代码展示了如何实现获取数据的一个示例:
public class BaseRequest<O> {
private final JsonMapper<O> mapper;
protected BaseRequest(JsonMapper<O> mapper) {
this.mapper = mapper;
}
public O execute() {
try {
URL url = new URL("schema://host.com/path");
HttpURLConnection urlConnection =
(HttpURLConnection) url.openConnection();
int code = urlConnection.getResponseCode();
StringBuilder sb = new StringBuilder();
BufferedReader rd = new BufferedReader(new
InputStreamReader(urlConnection.
getInputStream()));
String line;
while ((line = rd.readLine()) != null) {
sb.append(line);
}
return mapper.convert(new JSONObject
(sb.toString()));
} catch (Exception e) {
…
} finally {
if (urlConnection != null) {
urlConnection.disconnect();
}
}
return null;
}
}
在execute方法中,我们会使用HttpURLConnection连接到后端服务并检索数据。然后,我们会将其读取到一个String中,接着将其转换为JSONObject,然后传递给JsonMapper以转换为 POJO。
JsonMapper.java接口可能看起来像这样:
interface JsonMapper<T> {
T convert(JSONObject jsonObject) throws JSONException;
}
此接口代表了将JSONObject转换为任何 POJO 的抽象。
泛型使用允许我们将这种逻辑应用于任何 POJO。在我们的案例中,POJO 应该看起来像ConcreteData.java:
public class ConcreteData {
private final String field1;
private final String field2;
public ConcreteData(String field1, String field2) {
this.field1 = field1;
this.field2 = field2;
}
public String getField1() {
return field1;
}
public String getField2() {
return field2;
}
}
ConcreteData类将负责存储我们从后端服务接收到的数据。在这种情况下,我们只有两个String实例变量。
现在,我们需要创建一个具体的JsonMapper.java,它将负责将JSONObject转换为ConcreteData:
public class ConcreteMapper implements JsonMapper<ConcreteData> {
@Override
public ConcreteData convert(JSONObject jsonObject) {
return new ConcreteData(jsonObject.optString
("field1"), jsonObject.optString("field2"));
}
}
convert方法创建一个新的ConcreteData对象,从JSONObject对象中提取数据,并填充field1和field2的值。
接下来,我们必须创建一个扩展BaseRequest并使用ConcreteMapper的ConcreteRequest.java:
public class ConcreteRequest extends BaseRequest<ConcreteData> {
public ConcreteRequest() {
super(new ConcreteMapper());
}
}
这个类将从BaseRequest继承execute方法,并提供一个新的ConcreteMapper对象,以便我们可以将后端数据转换为ConcreteData。
最后,我们可以在我们的Activity中使用这个方法来执行请求并更新我们的AsyncTask类,该类提供了一套在单独的线程上执行工作并在主线程上处理结果的方法。然而,使用内部AsyncTask类可能会创建上下文泄露的风险(如果由于任何原因,Activity对象被销毁,那么在AsyncTask运行时垃圾收集器将无法收集Activity对象,因为Activity依赖于AsyncTask)。为了避免这种情况,建议的方法是为我们的Activity创建一个WeakReference。这样,如果Activity对象被用户或系统销毁,其引用可以被垃圾收集器收集。
现在,让我们看看我们的MainActivity的代码:
public class MainActivity extends Activity {
private TextView textView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
this.textView = findViewById(R.id.text_view);
new LoadConcreteDataTask(this).execute();
}
private void update(ConcreteData concreteData) {
textView.setText(concreteData.getField1());
}
}
这个类负责加载 UI 并启动LoadConcreteDataTask。然后,LoadConcreteDataTask将调用update方法来在用户界面中显示数据。
LoadConcreteDataTask必须是MainActivity的内部类:
public class MainActivity extends Activity {
…
private static class LoadConcreteDataTask extends
AsyncTask<Void, Void, ConcreteData> {
private final WeakReference<MainActivity>
mainActivityWeakReference;
private LoadConcreteDataTask(MainActivity
mainActivity) {
this.mainActivityWeakReference = new
WeakReference<>(mainActivity);
}
@Override
protected ConcreteData doInBackground(Void...
voids) {
return new ConcreteRequest().execute();
}
@Override
protected void onPostExecute(ConcreteData
concreteData) {
super.onPostExecute(concreteData);
MainActivity mainActivity =
mainActivityWeakReference.get();
if (mainActivity != null) {
mainActivity.update(concreteData);
}
}
}
}
在LoadConcreteDataTask中,我们利用doInBackground方法,它在单独的线程上执行以加载数据,然后在onPostExecute方法中更新我们的 UI。我们还持有MainActivity的WeakReference,以便在销毁时可以安全地垃圾回收。这也意味着在更新用户界面之前,我们需要检查引用是否仍然存在。
上述代码的类图如下所示:

图 1.1 – 一个较老 Android 应用的类图
在这里,我们可以看到依赖关系如何从MainActivity移动到ConcreteRequest类,其中MainActivity和LoadConcreteDataTask之间存在一个例外,这两个类相互依赖。这是一个问题,因为类之间耦合在一起,对其中一个的更改意味着对另一个的更改。在本章的后面部分,我们将探讨一些可以帮助我们避免此类依赖关系的原则。
既然我们已经了解了遗留应用程序的样子,让我们看看如果我们遵循这条路径可能会遇到哪些问题。
遗留分析
在本节中,我们将分析遗留应用程序中的一些问题。
让我们提出以下问题:
-
我们可以单元测试什么?
-
如果不是显示
ConcreteData中field1的值,而是需要显示field1+field2,会发生什么? -
当这个特定屏幕的需求发生变化并且需要从另一个端点检索数据时会发生什么?
-
如果我们需要引入缓存或 SQLite 持久性会发生什么?
-
如果另一个活动需要这个特定的用例会发生什么?
让我们来回答这些问题:
-
androidTest和test目录。从理论上讲,我们可以编写我们的单元测试,以便它们可以在模拟器上运行,但这需要更多的时间和稳定性。现在,我们可以使用 Firebase Test Lab 等技术将这些类型的测试在云中执行,但这不可避免地会花费我们金钱,并且避免此类成本符合我们的利益。实际上,我们只剩下一种选择,那就是尽可能多地使用本地单元测试而不是受仪器化的测试。为了解决这个问题,我们需要将我们使用的 Android 组件与 Java 组件分开。 -
MainActivity或在ConcreteData类中添加一个返回连接结果的方法。但任何一种方法都会带来不利因素。如果我们把连接移动到MainActivity,我们将把可以单元测试的逻辑放入一个很难测试且不应该进行单元测试的类中。如果我们创建一个在ConcreteData中连接的方法,我们可能会给这个类赋予它不应该有的责任,因为它更多地与 UI 相关,而不是 JSON 本身的实际表示。如果将来网络方面是由另一个团队开发的,你将需要依赖那个特定的团队来创建这个更新。 -
AsyncTask或在同一个LoadConcreteData类中执行两个请求然后更新 UI。如果我们创建一个单独的AsyncTask,那么我们将需要让活动负责管理结果并平衡两个AsyncTask,这再次产生了关于测试的问题。如果我们在一个AsyncTask中执行请求,那么AsyncTask的责任增加,我们可能想要避免这种情况。 -
LoadConcreteDataTask。在这里,我们遇到了与前面问题相同的问题。如果我们使用了请求类,我们最终将承担比处理数据库调用更多的责任来处理 HTTP 连接。如果我们使用LoadConcreteDataTask,我们将使第五个问题的答案变得更加困难。 -
LoadConcreteDataTask类。现在,让我们想象另一个具有完全不同 UI 和对数据进行不同解释的活动将依赖于相同的用例。一个解决方案是将LoadConcreteDataTask复制到新的活动中。这不是一个好主意,因为需求的变化将迫使开发者更改所有任务。更好的方法是创建一个新的抽象,这将消除LoadConcreteDataTask和Activity之间的依赖关系。这将使我们能够为两个活动重用相同的类。假设活动需要为每种解释提供不同类型的数据。在这里,我们可以遵循JsonMapper的例子,创建一个接口,将ConcreteData转换为通用类型,为每个活动提供两种实现,并创建必要的 POJO 以进行转换。
可以在这里提出的一个问题是,“将业务逻辑导出到另一个项目需要多少工作量?”这是一个重要的问题,因为它突出了我们应该如何构建我们的代码,以便其他人可以重用它,而不会给他们集成带来痛苦。如果我们回答这个问题,我们必须首先问,“业务逻辑在哪里?”答案可能是 LoadConcreteDataTask。我们能将其导出并发布到其他开发者可以获取的地方吗?
答案是否定的,因为它依赖于 MainActivity。这个问题突出了定义架构的一个重要方面,即确定组件的边界。组件可以被定义为最小的可交付代码片段。在我们的情况下,它相当于一个模块。现在,假设我们处于可以交付我们的 LoadConcreteDataTask 的位置。一个后续问题是,“数据是否托管在相同的服务上?”接着是,“它是否是相同的 JSON 格式?”在这里,我们需要在 LoadConcreteDataTask 和 BaseRequest 之间划清界限,并消除对数据检索方式的这种依赖。
提出并回答这些问题的原因是,所有这些场景都已经在过去发生过,它们很可能在应用程序的生命周期中再次发生。作为开发者,我们倾向于根据时间限制、我们所在团队施加的严谨性、我们通过不断挑战自己快速交付东西的雄心,以及我们的经验或团队的经验,以不同的方式回答这些问题。我们有机会选择一个不那么理想的解决方案,或者陷入一个我们必须在煎锅和火之间做出选择的情况,这代表了一个问题。有时,从我们的日常工作中退一步,问自己这些问题,进行心智实验,看看我们的代码可能如何结束在这些场景中,并评估如果现在发生或 1 或 2 年后会发生什么,这是有益的。
许多 Android 开发者发现自己处于的一个常见场景是,由于测试需要花费太多时间,并且需要尽快上市,导致缺乏对测试的投资。在这些案例中,随着时间的推移,应用程序变得越来越难以维护,因此需要雇佣更多的开发者来保持与较少开发者时的相同团队生产力。当代码以需要单元测试的概念编写时,我们编写代码的方式就会变得更加严谨和可维护。我们开始跟踪我们如何创建实例,区分我们可以测试和不能测试的事物,我们应用创建型设计模式,并且我们还缩短了我们类中方法的长度,以及其他一些事情。
现在我们已经了解了过去应用程序是如何编写的,以及由于采取的方法(例如,由于依赖 Android 框架而导致的测试性和可维护性问题)所引起的问题。接下来,我们将探讨一些将证明对我们编写应用程序有用的设计原则。
软件设计原则
在本节中,我们将分析一套被全球开发者采用的、用于改进他们系统的设计原则,这些原则也可以应用于 Android 开发。我们将主要关注由罗伯特·马丁(也称为 Uncle Bob)为类和组件定义的原则,因为它们非常适合 Android 开发。
根据上一节中的示例,我们了解到我们的代码库应该是可维护的、可理解的和灵活的。有一套软件设计原则,当我们在开发类或组件时,可以寻求帮助。将组件视为可以作为系统一部分发布的最小代码量。在 Android 中,你可以将它们视为独立的模块。它们不一定需要是模块,但可以组织成仿佛它们是模块的样子。
SOLID 原则
这些是一些最知名的设计原则。这个名字是一个缩写,代表了一组由罗伯特·马丁收集的设计原则。这些原则如下:
-
单一职责原则
-
开放封闭原则
-
李斯克替换原则
-
接口隔离原则
-
依赖倒置原则
让我们详细看看这些原则:
-
使用
BaseRequest类来改变 HTTP 请求的执行方式。假设我们现在有两个不同的 AsyncTasks 将会加载数据。这两个 AsyncTasks 都将受到BaseRequest类变化的影响。一个解决方案是为每个特定的用例委托请求的执行给不同的类。这也会允许开发者在不更改相同源文件的情况下,工作于与后端通信相关的不同特性。 -
开放-封闭原则:这表示一个类应该对扩展开放,对修改封闭。回顾我们的例子,这个原则会回答“如果一个活动需要这个特定的用例会发生什么?”这个问题。我们讨论的如何回答这个问题的抽象将作为实现此原则的好例子。
-
Bird和一个名为Duck的子类。如果你在代码中使用Bird的引用,并将这些用法替换为Duck,那么你的代码应该保持不变。一个违反此原则的著名例子是有一个名为Rectangle的类,它有两个名为width和height的成员,以及一个名为Square的子类。实际上,正方形是矩形的一种,但我们对正方形的建模不会是矩形,因为Square中的规则意味着宽度和高度将始终必须相同。如果你要交换这两个依赖项,那么你的代码就会出错。 -
OnClickListener、OnLongClickListener和OnTouchListener。 -
依赖倒置原则:这表示我们应该依赖于抽象而不是具体实现。这里的想法是尽可能多地依赖于抽象类和接口。考虑到我们很多时候都依赖于具体实现,这可能会非常困难。在这里,我们应该确定代码中那些经常开发和需要变更的部分,并在我们的代码和这些类之间引入抽象层。一种保护方法是通过依赖注入框架,如 Dagger 和 Hilt,这些框架生成工厂来创建易变组件。
SOLID 原则在面向对象编程(OOP)领域被广泛应用,以创建灵活的应用程序,能够融入新的功能和需求。以下原则是对 SOLID 原则的扩展。
组件内聚原则
我们可以通过组件中类的归属程度或哪些类属于某个特定组件来定义内聚性。在过去,组件是根据上下文组装的,没有任何特定的指导原则。这会导致问题,例如,组件依赖项的变化会触发此组件依赖项的变化,而这对依赖项没有任何相关性。
以下三个原则如下:
-
重用/发布等价原则(REP):这表示我们应该将可以一起发布的类分组到组件中。在 Android 开发中,这会转化为确保你创建的每个模块都能够被其他开发者发布和使用。
-
共同封闭原则(CCP):这表示组件应该只有一个改变的理由。这个原则是组件单职责原则的应用。
-
通用重用原则(CRP):这一原则指出,一个组件应该只包含应该一起使用的类。这代表了你的组件的接口分离原则。在 Android 中,这意味着你应该确保你的 Android 模块的用户依赖于该模块中的所有类,而不仅仅是其中的一些。
当这些原则被整合时,它们之间可能会产生冲突。REP 和 CCP 倾向于使组件更大,而 CRP 则倾向于使它们更小。理念是始终匹配当前的应用需求,并在这些原则之间找到平衡点。之后,你应该持续监控新需求如何影响这一平衡点。
现在我们已经看到如何通过组件内聚原则将 SOLID 应用于构建特定的组件,接下来让我们学习如何管理一组组件。
组件耦合原则
这些原则处理的是如何在 Android 应用程序中管理组件之间的关系。在 Android 中,这可以通过如何管理不同模块之间的 Gradle 依赖来表示。原则如下:
- 无环依赖原则:这一原则指出,我们应该避免组件之间的循环依赖。将这一原则应用于 Android,意味着我们的模块之间的依赖不应该形成循环(例如,模块 A 依赖于模块 B,而模块 B 又依赖于模块 A)。幸运的是,当前构建系统强制执行了这一规则,不允许循环依赖。解决这一问题的方法之一是创建一个新的模块,在其中应用依赖倒置原则,并使其中一个模块依赖于抽象,在第二个模块中创建实现。如果这不可能实现,我们可以创建一个新的模块,它可以依赖于现有的两个模块。以下图表中可以看到这一示例:
![图 1.2 – 循环模块依赖]

图 1.2 – 循环模块依赖
-
稳定依赖原则:这一原则指出,稳定性较低的模块应该依赖于稳定性较高的模块。一个组件的稳定性定义为输出依赖(对其他组件的依赖)与总依赖数的比率。这个数字越接近 0,组件就越稳定。这意味着稳定的组件应该避免因变更而引起潜在问题,因为这将对依赖于稳定组件的组件造成问题。避免稳定组件和易变组件之间依赖的一种解决方案是使用抽象组件。这些组件将只包含抽象。
-
稳定抽象原则:这一原则指出,可能发生变化的组件应该更加具体,而稳定的组件应该更加抽象。这一原则代表了开放封闭原则的应用。我们希望我们的高级架构决策足够灵活,以便在不修改现有源代码的情况下进行更改。我们可以通过使用抽象类来实现这一点。组件的抽象程度定义为组件内部抽象类和接口的数量与组件中类总数的比率。该值越接近 1,组件的抽象程度就越高。稳定性和抽象性都为 0 的组件代表了一个痛苦区域,因为它很难更改。稳定性和抽象性都为 1 的组件被称为无用区域,因为我们有一个没有实现的独立组件。目标是尽可能多地让组件处于 0 稳定性和 1 抽象性或 1 稳定性和 0 抽象性的范围内。
因此,我们已经探讨了应该帮助我们解决在开发应用程序时遇到的一些关键设计原则。SOLID 原则告诉我们如何将代码结构化为类,而组件内聚原则和组件耦合原则则告诉我们如何将类结构化为独立的模块,以及如何建立这些模块之间的关系。在下一节中,我们将看到这些原则如何导致 Android 平台的演变,以及应用程序现在可能的样子。
探索 Android 的演变
在本节中,我们将探讨对 Android 框架及其支持库的关键发布和变更,这些变更塑造了应用程序的开发,以及应用程序如何因为这些变更而发展演变。
我们首先查看了一个较老 Android 应用程序中的代码示例,然后讨论我们应该融入我们工作的设计原则。现在,让我们看看 Android 框架是如何演变的,以及我们最初的一些问题是如何得到解答的。我们将分析一些可以融入 Android 应用程序的新库、框架和技术。
碎片
碎片的引入旨在解决开发者面临的重要问题——即活动代码会变得太大且难以管理。它们是在 Android Honeycomb 上发布的,这是一个仅针对平板电脑的 Android 版本。引入碎片还旨在解决在横屏和竖屏活动中显示不同内容的问题。碎片旨在控制活动用户界面的部分。
另一项由片段带来的改进是能够在运行时更改和替换片段。甚至还有一个专门的后退栈用于片段,活动将负责这些片段。这带来了一些成本:片段的生命周期比活动的生命周期更加复杂,其中会有片段的视图被销毁,但片段本身并未被销毁。另一个成本是两个片段之间的通信。如果你需要更新由 Fragment1 处理的用户界面,因为 Fragment2 发生了变化,那么你需要通过活动进行通信。这意味着每次一个片段需要被不同的活动重用时,活动就必须适应这种情况:
![Figure 1.3 – Activity and fragment life cycle]
![Figure 1.03_B18320.jpg]
![Figure 1.3 – Activity and fragment life cycle]
在前面的图中,我们可以看到活动生命周期和片段生命周期的区别。我们可以观察到片段在onCreateView方法和onDestroyView方法之间有自己的内部生命周期,用于管理它们显示的视图。这通常是为什么在许多应用程序中,你会看到这些方法被用来加载数据,并在另一端取消订阅可能触发用户界面变化的任何操作。
Gradle 构建系统
初始时,Android 开发使用 Eclipse IDE 和 Ant 作为其构建系统。这为应用程序带来了一些限制。当时,如 flavors 这样的功能是不可用的。Android Studio 的发布,以及 Gradle 构建系统的引入,提供了新的机会和功能。这使我们能够编写额外的脚本,并轻松集成插件和工具,例如应用程序的性能监控、Google Play 服务、Firebase Crashlytics 等。这通常是通过".gradle"文件完成的。这些文件是用一种叫做 Groovy 的语言编写的。另一个添加的改进是使用".gradle.kts"扩展,其中我们可以使用 Kotlin 语言提供相同的配置。以下代码显示了模块的build.gradle文件看起来像什么:
plugins {
id 'com.android.application'
}
android {
compileSdk 31
defaultConfig {
minSdk 21
targetSdk 31
versionCode 1
versionName "1.0"
}
buildTypes {
release {
}
}
compileOptions {
}
}
dependencies {
implementation ""
}
在plugins部分,我们可以定义外部插件,这些插件将提供某些方法和脚本,我们的项目可以使用。例如,包括注解处理插件、Parcelize插件和 Room 插件。在这种情况下,com.android.application插件为我们提供了android配置,然后我们可以使用它来指定应用程序版本、我们希望应用程序能够访问的 Android 版本、各种编译选项以及应用程序应该如何为最终用户构建的配置。在dependencies部分,我们指定要添加到项目中的外部库。
网络连接
许多流行的网络库已经出现,主要是在开源社区中。在 Google Play 中的应用程序中,很大一部分依赖于 HTTP 通信,其中很大一部分使用 JSON 数据。随着网络库的加入,JSON 序列化/反序列化到 POJOs 也被采纳。这意味着对开发者来说,与后端的通信被简化了——我们不再需要关心实际的通信是如何进行的;我们只需指向我们想要的数据来源,并提供进行此通信所需的模型。库将处理其余部分。一些最受欢迎的库包括 Volley 和 Retrofit。在对象序列化方面,我们有如 Moshi 和 GSON 这样的库。
谦逊的物体
由于活动和片段难以进行单元测试,它们内部的代码需要分成可测试部分和不可测试部分。由于这种必要性,出现了两种模式:模型视图演示者(MVP)和模型视图视图模型(MVVM)。有时,这些模式被称为架构模式。这不应该与整个应用程序的架构相混淆。想法是将活动和片段转变为没有逻辑的谦逊对象,保留对用户界面对象的引用,并将逻辑转移到演示者和视图模型中,我们可以为它们编写单元测试。我们将在第八章**,实现 MVVM 架构*中更多地关注每个模式的特定细节。
功能性范式
正如面向对象的语言已经从函数式编程中采纳了范式,Android 开发世界也以 RxJava 的形式采纳了这种范式。函数式编程基于这样的前提:程序是由函数的组合而不是像 Java 中的命令式语句构建的。RxJava 是一个库,允许开发者实现事件驱动应用程序。它提供了可观察对象(用于发射数据)和订阅者(用于订阅该数据)。这个库对开发者有吸引力的是它处理线程的方式。假设你想要在一个单独的线程上执行操作,然后你想要转换你的数据——在这里你需要做的就是调用你想要的数据,应用映射函数,然后订阅以获取最终结果。额外的优势是你可以链式调用不同的操作,让它们被处理,并获取所有操作的结果。所有这些都消除了创建和管理不同的 AsyncTasks 或线程的需求。
Kotlin 的采用
RxJava 引入了函数式编程的一些方面。其采用和过渡到 Kotlin 编程语言又增加了其他方面。其中最重要的一个是可变性的概念。在 Java 中,所有变量都是可变的,除非通过 final 关键字声明为不可变。在 Kotlin 中,所有变量都必须声明其可变性。这为什么很重要?因为多线程。如果你有一个应用程序,其中多个线程同时执行并且它们都与同一个对象交互,你最终会陷入同时修改相同值或创建死锁的情况,其中一个线程会等待另一个线程释放资源,但第二个线程需要访问第一个线程当前持有的资源。这种引入有助于开发者追求更高的不可变性,这将增加线程安全性,因为不可变变量是线程安全的。Lambda 代表了 Kotlin 的另一个伟大特性,它允许在处理回调时减少样板代码。采用 Kotlin 的其他好处包括,你可以通过引入数据类来移除样板代码,数据类代表 POJOs,以及引入密封类,这允许开发者定义类似枚举的结构,可以携带数据。
依赖注入
依赖注入代表了对象调用和对象创建的解耦。为什么这很重要?主要是因为测试。为具有依赖注入的类编写单元测试比添加额外责任更容易,例如为该类中的所有依赖项创建新实例。另一个好处是在我们依赖于抽象的情况下。如果我们依赖于一个抽象,我们可以根据不同的情况轻松地在不同的实现之间切换。已经出现了几个库来解决这个问题:Dagger、Koin 和 Hilt。Dagger 更像是一个通用库,不仅适用于 Android,也适用于其他基于 Java 的平台。它旨在使用组件和模块来管理我们的依赖项。组件负责管理依赖项的方式,而模块负责提供适当的依赖项。它依赖于注解处理器,这些处理器生成负责管理我们依赖项的必要代码。Koin 被称为服务定位器库。它保存所有依赖项的集合,当需要特定的依赖项时,它会查找并提供它。Koin 是一个特定于 Android 的库,它提供了注入特定 Android 依赖项的支持。Hilt 是这些库中最新的,它是建立在 Dagger 之上的。它移除了 Dagger 所需的样板代码,并提供了对 Android 依赖项的支持。
Android 架构组件
这通过一系列帮助开发者使他们的应用可扩展、可测试和可维护的库来表示。这些库影响处理活动、片段生命周期、数据持久化、后台工作和 UI 的组件。在这里,我们看到了生命周期所有者(如活动和片段)的概念引入,Android ViewModel 和 LiveData。这些是为了解决开发者在使用系统销毁和重新创建生命周期所有者时管理生命周期所有者状态的问题。它将过去由生命周期所有者处理并委托给 Android ViewModel 的逻辑放在了其中。Android ViewModel 和 LiveData 的组合帮助开发者实现了 MVVM 模式,这也是生命周期感知的。这意味着开发者不再需要在生命周期所有者被销毁时停止后台任务。
Room 的引入意味着开发者不再需要与 SQLite 框架进行交互,这导致了大量样板代码的编写,用于定义表和各种查询。开发者不再需要处理 SQLite 交互及其带来的众多依赖;相反,他们可以专注于创建自己的模型,并提供需要查询、删除、更新和删除的抽象;Room 将负责实际的实现。DataStore 对 SharedPreferences 的作用类似于 Room 对 SQLite 的作用。这是在我们想要以键值对的形式存储数据而不是使用整个表时的情况。DataStore 提供了两种存储数据的方式:安全类型数据和无类型安全数据。
随着这些新持久化库的添加,采用了 Repository 模式。这个模式背后的想法是创建一个将与我们应用中所有数据源进行交互的类。例如,让我们假设我们有一些数据需要从后端获取,然后可能需要存储在本地,以便用户可以离线查看。我们的仓库将负责从网络类获取数据,然后使用持久化类进行存储。仓库将位于本地和远程类以及希望访问这些数据的类之间。
关于 UI,我们现在可以访问视图绑定和数据绑定。这两者都处理活动(activities)和片段(fragments)如何处理在 XML 布局文件中声明的视图。视图绑定为我们在 XML 中定义的每个视图生成引用。这解决了开发者过去可能会遇到的问题,即从 XML 文件中删除了一个视图,但由于另一个文件中存在同名视图,应用程序仍然可以运行。这过去会导致崩溃,因为 findViewById 函数会返回 null。使用视图绑定,我们在编译时就知道我们的层次结构中有哪些视图,以及没有哪些视图。数据绑定允许我们将视图绑定到数据源。例如,我们可以将 XML 文件中的 TextView 直接绑定到源代码中的一个字段。这种方法通常与 MVVM 模式配合良好,其中 ViewModel 更新由 XML 中的视图绑定的某些字段。这将更新视图将显示的内容,而无需与活动交互。
协程和流
协程是 Kotlin 语言的一个特性。协程背后的想法是以非常简化的方式异步执行数据。我们不再需要创建线程或 AsyncTasks(已被弃用)来管理并发,因为这一切都在底层管理。其他特性包括它不受特定线程的约束,并且可以被挂起和恢复。流是协程的扩展,我们可以有多次数据发射,例如 RxJava,提供类似的好处。
Jetpack Compose
这允许开发者通过组合函数直接在 Kotlin 中构建 UI,而不需要使用 XML 文件。这减少了构建 UI 需要编写的代码量。提供了与其他 Android 架构组件库的兼容性,使得更容易集成到您的应用程序中。以下是一个 Compose 的示例:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ExampleTheme {
Surface {
ExampleScreen()
}
}
}
}
}
@Composable
fun ExampleScreen() {
Column(modifier = Modifier.padding(16.dp)) {
TextField(
value = "",
onValueChange = {
// Handle text change
},
label = { Text("Input") }
)
Text(text = "Example text")
Button(onClick = {
// Handle button click
}) {
Text(text = "Button")
}
}
}
在这个示例中,我们可以看到一个包含输入字段、显示 Example Text 的文本和一些带有 Button 文本的按钮的屏幕。屏幕布局被定义为带有 @Compose 注解的函数。然后通过 setContent 方法将这些内容设置在活动中,其中提供了一个主题。我们将在本书的后面部分进一步介绍 Jetpack Compose 的工作原理。
现在,让我们看看在将示例代码通过上述一些 Android 框架和更新转换后,遗留应用程序架构部分的代码将如何看起来。我们所有的代码现在都将迁移到 Kotlin。我们将使用 Retrofit 和 Moshi 库进行网络和 JSON 序列化,以及 Hilt 进行依赖注入,以及 ViewModel、LiveData 和 Compose 进行 UI 层。我们将在接下来的章节中讨论这些库的工作原理。
ConcreteData 类将看起来像这样:
@JsonClass(generateAdapter = true)
data class ConcreteData(
@Json(name = "field1") val field1: String,
@Json(name = "field1") val field2: String
)
ConcreteData类现在是一个 Kotlin 数据类,并使用 Moshi 库进行 JSON 转换。接下来,让我们看看当我们使用 Retrofit 等工具处理 HTTP 通信时,我们的 HTTP 请求将是什么样子:
interface ConcreteDataService {
@GET("/path")
suspend fun getConcreteData(): ConcreteData
}
由于我们使用 Retrofit 和 OkHttp,我们只需要定义我们想要连接的端点模板和我们想要的数据;库将处理其余部分。suspend关键字对于 Kotlin flows 来说将非常有用。
现在,让我们定义一个负责在单独线程上调用此 HTTP 调用的存储库类:
class ConcreteDataRepository @Inject constructor(private val concreteDataService: ConcreteDataService) {
fun getConcreteData(): Flow<ConcreteData> {
return flow {
val fooList = concreteDataService.
getConcreteData()
emit(fooList)
}.flowOn(Dispatchers.IO)
}
}
ConcreteDataRepository将依赖于ConcreteDataService,它将调用以获取数据。它将负责通过使用 Kotlin flows 在单独的线程上检索数据。构造函数将使用@Inject注解,因为我们使用 Hilt,它将ConcreteDataService注入到ConcreteDataRepository中。
现在,让我们创建一个ViewModel,它将依赖于存储库来加载适当的数据:
@HiltViewModel
class MainViewModel @Inject constructor(private val concreteDataRepository: ConcreteDataRepository) :
ViewModel() {
private val _concreteData = MutableLiveData
<ConcreteData>()
val concreteData: LiveData<ConcreteData> get() =
_concreteData
fun loadConcreteData() {
viewModelScope.launch {
concreteDataRepository.getConcreteData()
.collect { data ->
_concreteData.postValue(data)
}
}
}
}
MainViewModel将使用ConcreteDataRepository检索数据,订阅结果,并在LiveData中发布结果,MainActivity将订阅此LiveData。
现在,让我们创建MainActivity:
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Screen()
}
}
}
@Composable
fun Screen(mainViewModel: MainViewModel = viewModel()){
mainViewModel.loadConcreteData()
UpdateText()
}
@Composable
fun UpdateText(mainViewModel: MainViewModel = viewModel()) {
val concreteData by mainViewModel.concreteData.
observeAsState(ConcreteData("test", "test"))
MessageView(text = concreteData.field1)
}
@Composable
fun MessageView(text: String) {
Text(text = text)
}
MainActivity现在使用 Jetpack Compose 编写。它将在屏幕创建时触发数据加载,然后订阅ViewModel的LiveData,当数据加载时,将在屏幕上更新文本。
由于我们使用 Hilt 进行依赖注入,因此我们需要在模块中定义我们的外部依赖项,如下所示:
@Module
@InstallIn(SingletonComponent::class)
class ApplicationModule {
@Singleton
@Provides
fun provideHttpClient(): OkHttpClient {
return OkHttpClient
.Builder()
.readTimeout(15, TimeUnit.SECONDS)
.connectTimeout(15, TimeUnit.SECONDS)
.build()
}
}
首先,我们必须提供OkHttp客户端,它用于发出 HTTP 请求。
接下来,我们需要提供 JSON 序列化:
@Module
@InstallIn(SingletonComponent::class)
class ApplicationModule {
…
@Singleton
@Provides
fun provideConverterFactory(): MoshiConverterFactory = MoshiConverterFactory.create()
}
我们使用 Moshi 库进行 JSON 序列化,因此我们需要提供一个工厂,该工厂将被 Retrofit 用于 JSON 转换。
接下来,我们需要提供一个 Retrofit 对象:
@Module
@InstallIn(SingletonComponent::class)
class ApplicationModule {
…
@Singleton
@Provides
fun provideRetrofit(
okHttpClient: OkHttpClient,
gsonConverterFactory: MoshiConverterFactory
): Retrofit {
return Retrofit.Builder()
.baseUrl("schema://host.com")
.client(okHttpClient)
.addConverterFactory(gsonConverterFactory)
.build()
}
}
Retrofit 对象需要一个基础 URL,它将作为我们后端服务的宿主,OkHttpClient,以及之前提供的 JSON 转换器工厂。
最后,我们需要提供之前定义的模板:
@Module
@InstallIn(SingletonComponent::class)
class ApplicationModule {
@Singleton
@Provides
fun provideConcreteDataService(retrofit: Retrofit):
ConcreteDataService =
retrofit.create(ConcreteDataService::class.java)
}
在这里,我们将使用 Retrofit 创建ConcreteDataService的实例,该实例将通过 Hilt 注入到ConcreteDataRepository中。
最后,我们需要在Application类中初始化 Hilt:
@HiltAndroidApp
class MyApplication : Application()
这段代码代表了 Android 开发在时间上 10 年的跳跃。回到我们在遗留分析部分提出的初始示例问题,我们可以看到我们回答了很多。如果我们想在应用中引入持久性,我们现在有一个可以为我们管理这一点的仓库。我们还拥有许多可以单独进行单元测试的类,因为 Hilt 的引入以及我们将 Android 框架依赖项分离开来。我们还引入了流程,这允许我们在需要连接到多个源并更轻松地处理多线程时操纵和处理数据。Kotlin 和 Retrofit 的引入也使我们能够减少代码量。如果我们绘制这个图,它看起来会如下所示:

图 1.4 – 一个较新 Android 应用的类图
在这里,我们可以看到类之间的依赖关系是从一个方向到另一个方向,这是另一个积极的方面。Retrofit 的引入在处理 HTTP 请求时为我们节省了很多麻烦。但是,关于如何处理 ConcreteData 的问题仍然存在。我们可以看到它从 ConcreteDataService 流向 MainActivity。想象一下,如果我们想从不同的 URL 提供数据,并且使用不同的 POJO 表示,这意味着所有类都必须进行更改以适应这一点。这违反了单一职责原则,因为 ConcreteData 类被用来服务于我们应用中的多个角色。在下一节中,我们将尝试寻求解决这个问题的方法,并讨论如何正确地构建我们的类和组件。
通过以上内容,我们已经探讨了 Android 平台和工具的演变,使用最新工具和库的应用可能的样子,以及这种演变如何解决了开发者过去遇到的一些问题。然而,我们仍未解决所有问题。在下一节中,我们将讨论清洁架构的概念以及我们如何利用它使我们的应用更加灵活,并能更好地适应变化。
进入清洁架构
在本节中,我们将讨论清洁架构的概念、它解决的问题以及如何将其应用于 Android 应用。
架构可以看作是构建一个能够解决业务和技术需求的高层次解决方案。目标应该是尽可能长时间地保留尽可能多的选项。从 Android 开发的角度来看,我们已经看到平台发展壮大,为了平衡平台新增的功能和我们的应用程序及其维护的新变化,我们需要为我们的应用程序提供一个非常好的基础,以便它能够适应变化。Android 开发中架构的常见方法是将应用程序分为三个层次——用户界面、领域和数据层。这里的问题是领域层依赖于数据层,因此当数据层发生变化时,领域层也需要进行相应的更改。
清洁架构代表了多种架构的集成,这些架构提供了对框架、用户界面和数据库的独立性,同时也能进行测试。其形状类似于洋葱,其中依赖关系指向内层。这些层如下:
-
实体层:这一层是最内层,由持有数据或业务关键功能的对象表示。
-
用例层:这一层实现了系统的业务逻辑。
-
接口适配层:这一层负责在框架和驱动程序与用例之间转换数据。这一层将包含诸如 ViewModels 和演示者等组件,以及各种转换器,这些转换器负责将网络和持久性相关的数据转换为实体。
-
框架和驱动层:这一层是最外层,由活动、片段、网络组件和持久性组件等组成。
让我们考虑一个场景:你最近被一家初创公司雇佣为他们的首位 Android 工程师。你已经得到了一个关于你被要求开发的应用程序应该做什么的基本想法,但并没有什么具体的内容;用户界面尚未确定,负责后端的团队本身也是新手,他们那边也没有什么具体的内容。你所知道的是一组用例,这些用例指定了应用程序的功能:登录系统、加载任务列表并添加新任务、删除任务和编辑现有任务。产品负责人告诉你,你应该使用模拟数据来工作,这样他们可以感受到产品的感觉,并与用户界面和用户体验团队讨论改进和修改。
你在这里面临一个选择:你可以尽可能快地构建产品所有者请求的产品,然后不断地重构代码以适应每个新的集成和需求的变化,或者你可以花更多的时间,考虑到未来可能对你的方法产生的影响。如果你选择第一种方法,那么你可能会发现自己处于许多开发者都曾遇到的情况,那就是回去正确地更改事物。让我们假设你选择了第二种方法。那么你需要做什么呢?你可以开始将你的代码解耦成单独的层。你知道 UI 会变化,所以你需要将其隔离,以便当它发生变化时,变化只会局限于那个特定的部分。通常,UI 被称为表示层。
接下来,你想要解耦业务逻辑。这是处理你的应用程序将使用的数据的具体事情。这通常在领域层完成。最后,你想要解耦数据的加载和存储方式。这将是你处理集成库(如 Room 和 Retrofit)的部分,通常被称为数据层。因为需求尚未确定,你还想要解耦你想要处理用例的方式,以便如果用例发生变化,你可以保护其他部分不受该变化的影响。如果你旋转图 1.4中的类图,你会看到对这个示例的分层方法。
正如我们之前提到的,ConcreteData出现在我们示例中的所有类中并不是一个好主意。这是因为,最终,我们选择 Retrofit 和 Moshi 的事实不应该影响到应用程序的其他部分。如果情况相反,活动或ViewModel会做同样的事情。最终,我们选择实现 UI 或我们应该使用哪个网络库代表细节。我们的领域层不应该受到这些选择中的任何影响。
我们在这里所做的是在我们系统的组件之间建立边界,以便一个组件的变化不会影响到另一个组件的变化。在 Android 中,即使我们使用了最新的库和框架,我们也应该确保我们的领域仍然受到这些框架变化的保护。回到启动示例,假设你已经选择了解耦你的组件并选择合适的边界,经过多次演示和迭代后,你的公司决定雇佣额外的开发者来开发新的、独立的功能。如果这些开发者遵循你设定的指南,他们可以以最小的重叠程度进行工作。
Android 开发文档的建议是利用模块化。其中一个论点是它提高了构建速度,因为当你在一个特定的模块上工作时,构建应用程序时不会重建其他模块 – 相反,它会缓存它们。将你的应用程序拆分为多个模块还有另一个目的。
让我们回到启动阶段。一切都很顺利,人们都很喜欢你的产品,所以你的公司决定向其他业务开放你的 API,以便它们可以集成到自己的系统中。你的公司还希望提供一个 Android 库,以便业务更容易访问你的 API。你已经在应用程序中集成了这个逻辑;你只需要导出它。你想要导出哪些功能?全部?没有?他们想要本地持久化数据吗?他们想要一些 UI 还是不要?如果你的模块是以适当的边界拆分的,那么你将能够容纳所有这些功能。我们想要的是一个可以轻松插入和拔出的系统。
将我们之前的示例过渡到这种方法,我们会有如下内容。ConcreteData 类和 ConcreteDataService 将保持不变:
@JsonClass(generateAdapter = true)
data class ConcreteData(
@Json(name = "field1") val field1: String,
@Json(name = "field1") val field2: String
)
interface ConcreteDataService {
@GET("/path")
suspend fun getConcreteData(): ConcreteData
}
现在,我们需要隔离 Retrofit 库并为它创建接口适配器。但要做到这一点,我们需要定义我们的实体:
data class ConcreteEntity(
val field1: String,
val field2: String
)
它看起来像是 ConcreteData 的重复,但这是一种虚假重复的情况。实际上,随着事物的演变,这两个类可能包含不同的数据,因此它们需要被分离。
为了隔离 Retrofit 调用,我们需要反转我们仓库的依赖。所以,让我们创建一个新的接口,它将返回 ConcreteEntity:
interface ConcreteDataSource {
suspend fun getConcreteEntity(): ConcreteEntity
}
在我们的实现中,我们将调用 Retrofit 服务接口:
class ConcreteDataSourceImpl(private val concreteDataService: ConcreteDataService) :
ConcreteDataSource {
override suspend fun getConcreteEntity():
ConcreteEntity {
val concreteData = concreteDataService.
getConcreteData()
return ConcreteEntity(concreteData.field1,
concreteData.field2)
}
}
在这里,我们已经调用了 ConcreteDataService 并将网络模型转换为实体。
现在,我们的仓库将变成以下形式:
class ConcreteDataRepository @Inject constructor(private val concreteDataSource: ConcreteDataSource) {
suspend fun getConcreteEntity(): ConcreteEntity {
return concreteDataSource.getConcreteEntity()
}
ConcreteDataRepository 将依赖于 ConcreteDataSource 以避免对网络层的依赖。
现在,我们需要构建用例来检索 ConcreteEntity:
class ConcreteDataUseCase @Inject constructor(private val concreteDataRepository: ConcreteDataRepository) {
fun getConcreteEntity(): Flow<ConcreteEntity> {
return flow {
val fooList = concreteDataRepository.
getConcreteEntity()
emit(fooList)
}.flowOn(Dispatchers.IO)
}
}
ConcreteDataUseCase 将依赖于 ConcreteDataRepository 来检索数据并使用 Kotlin 流发射数据。
现在,MainViewModel 需要被修改以调用用例。为此,它将使用 ConcreteEntity 中的 field1 对象:
@HiltViewModel
class MainViewModel @Inject constructor(private val concreteDataUseCase: ConcreteDataUseCase) :
ViewModel() {
private val _textData = MutableLiveData<String>()
val textData: LiveData<String> get() = _textData
fun loadConcreteData() {
viewModelScope.launch {
concreteDataUseCase.getConcreteEntity()
.collect { data ->
_textData.postValue(data.field1)
}
}
}
}
MainViewModel 现在将依赖于 ConcreteDataUseCase 并检索 ConcreteEntity,其中它将提取 field1。然后这将设置在 LiveData 中。
MainActivity 将被更新以使用来自 MainViewModel 的 textData 对象:
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Screen()
}
}
}
@Composable
fun Screen(mainViewModel: MainViewModel = viewModel()){
mainViewModel.loadConcreteData()
UpdateText()
}
@Composable
fun UpdateText(mainViewModel: MainViewModel = viewModel()) {
val text by mainViewModel.textData.
observeAsState("test")
MessageView(text = text)
}
@Composable
fun MessageView(text: String) {
Text(text = text)
}
这样,MainActivity 就被更新为使用 LiveData,它发射一个 String 而不是 ConcreteData 对象。
最后,Hilt 模块将被更新如下:
@Module
@InstallIn(SingletonComponent::class)
class ApplicationModule {
…
@Singleton
@Provides
fun provideHttpClient(): OkHttpClient {
return OkHttpClient
.Builder()
.readTimeout(15, TimeUnit.SECONDS)
.connectTimeout(15, TimeUnit.SECONDS)
.build()
}
@Singleton
@Provides
fun provideConverterFactory(): MoshiConverterFactory =
MoshiConverterFactory.create()
@Singleton
@Provides
fun provideRetrofit(
okHttpClient: OkHttpClient,
gsonConverterFactory: MoshiConverterFactory
): Retrofit {
return Retrofit.Builder()
.baseUrl("schema://host.com")
.client(okHttpClient)
.addConverterFactory(gsonConverterFactory)
.build()
}
@Singleton
@Provides
fun provideCurrencyService(retrofit: Retrofit):
ConcreteDataService =
retrofit.create(ConcreteDataService::class.java)
@Singleton
@Provides
fun provideConcreteDataSource(concreteDataService:
ConcreteDataService): ConcreteDataSource =
ConcreteDataSourceImpl(concreteDataService)
}
在这里,我们可以看到 ConcreteDataUseCase 只调用了 ConcreteDataRepository,而 ConcreteDataRepository 又只调用了 ConcreteDataSource。你可能想知道为什么这个样板代码是必要的。在这种情况下,我们有一点点虚假的重复。随着代码的增长,ConcreteDataRepository 可能会连接到其他数据源,而 ConcreteDataUseCase 可能需要连接到多个存储库来合并数据。对于 ConcreteData 和 ConcreteEntity 也是如此。这种方法的另一个好处是在开发过程中强制执行更多的严谨性,并创造了一致性。
让我们看一下下面的图表,看看它与 图 1.4 的比较:

图 1.5 – 清洁架构
如果我们看最上面的一行,我们会看到用例和实体。我们还可以看到依赖关系是从底部的类到顶部的类,这与这里从外部层到内部层的依赖关系相似。你可能注意到的不同之处在于,我们的示例没有提到模块的使用。在本书的后面部分,我们将探讨如何将清洁架构应用于多个模块以及如何管理它们。
现在,我们回到了初创公司,你开始着手开发应用程序,其中你定义了一些实体和用例,并放置了一个简单的用户界面。产品负责人要求你明天提供一个带有一些模拟数据的演示。你能做什么?你可以创建一个新的数据源实现,并插入一些你可以用来满足演示条件的模拟对象。你展示了应用程序的演示,并收到了一些关于你用户界面的反馈。这意味着你可以更改你的活动和片段以适当地渲染数据,而这不会影响任何其他组件。如果用例发生变化会怎样?在这种情况下,这将传播到其他层。但这取决于变化,但这种情况是可以预料的。
摘要
在本章中,我们探讨了 Android 应用过去的样子以及开发者当时可能会遇到的所有问题。我们研究了最重要的软件设计原则,例如 SOLID,以更好地理解如何改进我们的代码以及这些原则如何帮助 Android 平台发展。我们还探讨了随着新软件范式的引入而采用的新编程语言,事件库和框架的添加,引入架构组件以帮助开发者编写更可测试的应用程序,以及构建用户界面的新方法。最后,我们介绍了清洁架构,它帮助我们构建可维护、可测试且更独立的应用程序。我们通过一个小示例来观察所有这些变化,从它们可能在 2010 年的样子到它们现在的样子。
在下一章中,我们将深入探讨在 Android 上加载、存储和管理数据的库。我们将结合它们使用干净的架构来构建一个应用程序。
第二章:第二章:深入数据源
在本章中,我们将研究一些用于在 Android 上检索和管理数据的流行库和框架,以及如何在不阻塞应用程序主线程的情况下完成这些操作。我们将首先概述在 Android 应用程序中如何处理多线程以及我们现在有哪些易于处理这项技术的技术。然后,我们将继续实现使用 Retrofit 和 OkHttp 等库从互联网加载数据,之后我们将探讨如何使用 Room 和 DataStore 等库在设备上持久化数据。
本章将涵盖以下主要内容:
-
理解 Kotlin 协程和流
-
使用 OkHttp 和 Retrofit 进行网络操作
-
使用 Room 库进行数据持久化
-
理解和使用 DataStore 库
在本章结束时,你将熟悉如何在 Android 应用程序中加载数据、管理数据和持久化数据。
技术要求
本章有以下硬件和软件要求:
- Android Studio Arctic Fox 2020.3.1 Patch 3
本章的代码文件可以在此处找到:
github.com/PacktPublishing/Clean-Android-Architecture/tree/main/Chapter2
查看以下视频以查看代码的实际应用:bit.ly/38uecPi
理解 Kotlin 协程和 Flows
在本节中,我们将探讨在 Android 生态系统中线程的工作方式以及应用程序必须做什么以确保长时间运行的操作不会阻止用户使用应用程序。然后,我们将探讨我们有哪些可用的选项来在后台执行操作,重点关注协程。最后,我们将回顾 Kotlin 流,我们可以使用它以响应式和函数式方法处理异步工作。
Android 应用程序通常在用户的设备上以单个进程运行。当操作系统启动应用程序的进程时,它将为执行进程分配内存资源。此进程启动时,将有一个执行线程在其中运行。这个线程被称为“主线程”或“用户界面(UI)线程”。在 Android 中,这个概念非常重要,因为它处理用户交互的线程。这给开发者带来了一些限制,如下所述:
-
主线程不得被长时间运行或输入/输出(I/O)操作阻塞。
-
所有 UI 更新都必须在主线程上完成。
理念是,即使应用程序在进行一些工作,用户仍然应该尽可能多地与应用程序交互。每次我们想要从或向互联网、本地存储、内容提供者等加载数据或保存数据时,我们应该使用另一个线程或使用多个线程。设备处理器处理多个线程的方式是为每个线程分配一个核心。当线程数量多于核心数量时,它将在每个线程的每条指令之间跳来跳去。同时执行太多线程最终会创建一个糟糕的用户体验(UX),因为处理器现在需要在主线程和其他同时执行的线程之间跳转,因此我们需要注意同时执行多少个线程。
在 Java 中,可以使用Thread类创建线程;然而,为每个异步操作创建一个新的线程是一个非常资源密集的操作。Java 还提供了ThreadPool或Executor的概念。这些通常管理一组固定数量的线程,这些线程将被重用于不同的操作。由于 Android 对在主线程上更新 UI 的限制,引入了Handler和Looper类,通过这些类,你可以将后台线程上执行的操作的结果提交回主线程。这里提供了一个例子:
class MyClass {
fun asyncSum(a: Int, b: Int, callback: (Int) -> Unit) {
val handler = Handler(Looper.getMainLooper())
Thread(Runnable {
val result = a + b
handler.post(Runnable {
callback(result)
})
}).start()
}
}
在前面的代码片段中,两个数字的求和将在一个新的线程上执行,然后使用连接到主Looper对象的Handler对象将结果发送回去,主Looper对象本身将循环主线程。
Handler和Looper的重复使用催生了AsyncTask,它提供了在后台线程上移动必要操作并在主线程上接收结果的可能性。AsyncTask与前面的例子工作原理相同,只是它不是为每个新操作创建一个新的线程,而是默认使用相同的线程(尽管这后来变得可配置),这意味着如果有两个AsyncTask实例同时执行,一个会在另一个之后等待。相同的求和操作的例子可能看起来像这样:
fun asyncSum(a: Int, b: Int, callback: (Int) -> Unit) {
object : AsyncTask<Nothing, Nothing, Int>() {
override fun doInBackground(vararg params:
Nothing?): Int {
return a+b
}
override fun onPostExecute(result: Int) {
super.onPostExecute(result)
callback(result)
}
}.execute()
}
在前面的例子中,求和操作是在doInBackground方法中完成的,这个方法在一个单独的线程上执行,而onPostExecute方法将在主线程上执行。
现在让我们想象一下,我们想要将这些求和操作串联起来并多次应用,如下所示:
fun asyncComplicatedSum(a: Int, b: Int, c: Int) {
asyncSum(a, b) { tempSum ->
asyncSum(tempSum, c) { finalSum ->
Log.d(this.javaClass.name, "Final sum
$finalSum")
}
}
}
在前面的例子中,我们尝试将两个数字相加,并将结果加到数字c上。正如你所看到的,我们需要使用回调并等待a和b完成,然后对a+b的结果和数字c应用相同的函数。
让我们想象一下,当需要处理从多个数据源加载数据、合并它们、处理错误以及如果用户离开当前活动或片段则停止异步执行时,一个应用程序可能看起来像什么。RxJava 库试图通过事件驱动的方法来解决所有这些问题。它引入了可以观察、转换、与其他数据流合并并在不同线程上执行的数据流和流的概念。在 RxJava 中,两个数字的和可能看起来像这样:
fun asyncSum(a: Int, b: Int): Single<Int> {
return Single.create<Int> {
it.onSuccess(a + b)
}.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
}
在前面的例子中,我们创建了一个Single实例,这是一个只发射一个值(对于发射多个值,我们有Flowable和Observable选项)的流。发射的值是两个数字的和。使用subscribeOn是为了在 RxJava 内部管理的 I/O 线程上执行上游(求和),而使用observeOn是为了让所有下游(所有后续的命令)在主线程上获取结果。
如果我们想要链式多个求和,那么我们会有如下所示的内容:
fun asyncComplicatedSum(a: Int, b: Int, c: Int) {
val disposable = asyncSum(a, b)
.flatMap {
asyncSum(it, c)
}
.subscribe ({
Log.d(this.javaClass.name, "Final sum $it")
},{
Log.d(this.javaClass.name, "Something went
wrong")
})
}
在前面的例子中,执行了a和b的和,然后通过flatMap操作符,我们将c添加到这个结果中。使用subscribe方法是为了触发求和并监听结果。这是因为使用的Single实例是一个冷观察者;它只有在调用subscribe时才会执行。还有热观察者的概念,无论是否有订阅者都会发射。subscribe操作符的结果将返回一个Disposable实例,它提供了一个dispose方法,可以在我们想要停止监听流中的数据时调用。这在我们的活动和片段被销毁,我们不想更新 UI 以避免上下文泄露的情况下非常有用。
Kotlin 协程
到目前为止,我们已经分析了围绕 Java 和 Android 框架的技术。随着 Kotlin 的采用,出现了其他处理多线程且特定于 Kotlin 的技术。其中之一是协程的概念。协程简化了我们编写异步代码的方式。我们不需要处理回调,协程引入了作用域的概念,我们可以指定代码块将在哪个线程上执行。作用域还可以连接到生命周期感知组件,帮助我们在我们生命周期感知组件终止时取消订阅异步工作的结果。让我们看看以下关于相同求和的协程示例:
suspend fun asyncSum(a: Int, b: Int): Int {
return withContext(Dispatchers.IO) {
a + b
}
}
在前面的示例中,withContext 方法将在 I/O 分发器管理的线程中执行其内部的代码块。与此分发器相关联的线程数量由 Kotlin 框架内部管理,并与设备处理器的核心数相关联。这通常意味着当多个异步操作同时执行时,我们不必担心应用程序的性能。在示例中还有另一个有趣的事情需要注意,那就是 suspend 关键字的使用。这是为了提醒调用此方法的调用者,它将在单独的线程上使用协程执行。
现在,让我们看看当我们想要调用此方法时,事情会是什么样子。看一下以下代码片段:
class MyClass : CoroutineScope {
override val coroutineContext: CoroutineContext
get() = Dispatchers.Main + job
private lateinit var job: Job
fun asyncComplicatedSum(a: Int, b: Int, c: Int) {
launch {
try {
val tempSum = asyncSum(a, b)
val finalSum = asyncSum(tempSum, c)
Log.d(this.javaClass.name, "Final sum
$finalSum")
} catch (e: Exception) {
Log.d(this.javaClass.name, "Something went
wrong")
}
}
}
fun create() {
job = Job()
}
fun destroy() {
job.cancel()
}
}
在 asyncComplicatedSum 中,我们使用了 launch 方法。此方法与在此类中定义的 CoroutineContext 对象相关联。上下文是通过使用 Main 分发器结合与该对象的生命周期相关联的 Job 对象来定义的。如果在等待求和结果时调用 destroy 方法,则求和的执行将停止,我们将停止获取求和的结果。代码将在 I/O 线程上执行每个求和,如果作业仍然存活,则将在主线程上执行日志语句。
在 Android 中,我们已经有几个 CoroutineScope 对象已经定义并关联到我们的生命周期感知类。其中一个与我们相关的是为 ViewModels 定义的。这可以在 org.jetbrains.kotlinx:kotlinx-coroutines-android 库中找到,看起来可能如下所示:
class MyViewModel: ViewModel() {
init {
viewModelScope.launch { }
}
}
viewModelScope 是为 ViewModel 实例创建的 Kotlin 扩展,如果 ViewModel 实例处于活动状态,则将执行。如果在 ViewModel 实例上调用 onCleared,则它将停止监听 launch 块中剩余要执行的代码。
在本节中,我们分析了 Kotlin 协程的工作原理以及我们如何使用它们在 Android 应用程序中处理异步操作。在下一节中,我们将创建一个 Android 应用程序,该程序将使用 Kotlin 协程进行简单的异步操作。
练习 02.01 – 使用 Kotlin 协程
创建一个应用程序,该程序将显示两个输入字段、一个文本字段和一个按钮。输入字段仅限于数字,当用户按下按钮时,文本字段将在 5 秒后显示两个数字的和。求和和等待将使用协程实现。
要完成练习,你需要构建以下内容:
-
一个将执行两个数字加法的类
-
一个将调用加法的
ViewModel类 -
使用 Compose 的 UI 将使用以下函数:
@Composable fun Calculator( a: String, onAChanged: (String) -> Unit, b: String, onBChanged: (String) -> Unit, result: String, onButtonClick: () -> Unit ) { Column(modifier = Modifier.padding(16.dp)) { OutlinedTextField( value = a, onValueChange = onAChanged, keyboardOptions = KeyboardOptions (keyboardType = KeyboardType.Number), label = { Text("a") } ) OutlinedTextField( value = b, onValueChange = onBChanged, keyboardOptions = KeyboardOptions (keyboardType = KeyboardType.Number), label = { Text("b") } ) Text(text = result) Button(onClick = onButtonClick) { Text(text = "Calculate") } } }
按照以下步骤完成练习:
-
在 Android Studio 中使用 Empty Compose Activity 创建一个新的项目。
-
在
build.gradle文件的顶层,定义 Compose 库版本如下:buildscript { ext { compose_version = '1.0.5' } … } -
在
app/build.gradle文件中,我们需要添加以下依赖项:dependencies { implementation 'androidx.core:core-ktx:1.7.0' implementation 'androidx.appcompat:appcompat:1.4.0' implementation 'com.google.android.material:material:1.4.0' implementation "androidx.compose.ui:ui:$compose_version" implementation "androidx.compose.material:material:$compose_version" implementation "androidx.compose.ui:ui-tooling-preview:$compose_version" implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.0' implementation 'androidx.activity:activity-compose:1.4.0' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0' implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0" implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.4.0" testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version" testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.0" debugImplementation "androidx.compose.ui:ui-tooling:$compose_version" } -
首先,创建一个
NumberAdder类,并定义一个add操作和一个延迟,如下所示:private const val DELAY = 5000 class NumberAdder( private val dispatcher: CoroutineDispatcher = Dispatchers.IO, private val delay: Int = DELAY ) { suspend fun add(a: Int, b: Int): Int { return withContext(dispatcher) { delay(delay.toLong()) a + b } } }
在这个类中,我们将在执行两个数字的求和之前添加一个 5 秒的延迟。这是为了更突出异步操作。CoroutineDispatcher和我们要延迟的量将通过构造函数注入。这是因为我们想要对这个类进行单元测试。
-
接下来,我们需要对这个类进行单元测试。在我们编写测试之前,创建一个测试规则,以便我们可以为协程重用它,如下所示:
class DispatcherTestRule : TestRule { @ExperimentalCoroutinesApi val testDispatcher = TestCoroutineDispatcher() @ExperimentalCoroutinesApi override fun apply(base: Statement?, description: Description?): Statement { try { Dispatchers.setMain(testDispatcher) base?.evaluate() } catch (e: Exception) { } finally { Dispatchers.resetMain() testDispatcher.cleanupTestCoroutines() } return base!! } }
在这个类中,我们创建了一个TestCoroutineDispatcher实例,稍后将其注入到单元测试中,以便测试可以以同步方式执行求和操作。@ExperimentalCoroutinesApi表明TestCoroutineDispatcher的使用仍然处于实验状态,将来将被移至稳定版本。
-
现在,以
NumberAdderTest的形式编写类的单元测试,如下所示:class NumberAdderTest { @get:Rule val dispatcherTestRule = DispatcherTestRule() @ExperimentalCoroutinesApi @Test fun testAdd() = runBlockingTest { val adder = NumberAdder(dispatcherTestRule. testDispatcher, 0) assertEquals(5, adder.add(1, 4)) } }
在这里,我们将我们在DispatcherTestRule中创建的testDispatcher对象注入到NumberAdder中,然后调用add函数。整个测试是在一个特殊的CoroutineScope块runBlockingTest中执行的,这将确保所有启动的协程必须完成。
-
接下来,继续创建一个
ViewModel类,如下所示:class MainViewModel(private val adder: NumberAdder = NumberAdder()) : ViewModel() { var resultState by mutableStateOf("0") private set fun add(a: String, b: String) { viewModelScope.launch { val result = adder.add(a.toInt(), b.toInt()) resultState = result.toString() } } }
在这里,我们使用一个 Compose 状态来保留加法的结果,以及一个将触发加法操作到viewModelScope的方法。
-
在创建完
ViewModel类之后,继续创建一个活动类,如下所示:class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { Exercise201Theme { Surface { Screen() } } } } }
在这里,我们使用内容初始化我们的活动。Exercise201Theme应替换为 Android Studio 在创建项目时生成的主题。通常,这应该在一个Theme文件中,并且应该是一个带有应用名称后跟Theme后缀的@Composable函数。如果不可用,可以为了练习的目的使用MaterialTheme。
-
接下来,创建一个
Screen函数,如下所示:@Composable fun Screen(viewModel: MainViewModel = viewModel()) { var a by remember { mutableStateOf("") } var b by remember { mutableStateOf("") } Calculator( a = a, onAChanged = { a = it }, b = b, onBChanged = { b = it }, result = viewModel.resultState, onButtonClick = { viewModel.add(a, b) }) }
在这个方法中,我们为文本字段定义变量,然后传递从 ViewModel 得到的数字相加的结果,最后调用 ViewModel 执行加法操作。
- 最后,将练习定义中的
Calculator函数添加到MainActivity文件中。
如果我们运行前面的示例,我们应该看到我们的 UI 元素,在输入数字并点击按钮后,我们将得到结果。需要注意的是,当add方法执行时,用户将能够与 UI 交互,并且点击不同的数字将分别在每次按钮按下后 5 秒后得到结果。
使用协程可以提高 Android 应用程序的质量,尤其是在与ViewModel类和生命周期感知组件的 Android 扩展结合使用时。协程简化了我们编写的异步操作代码,并且添加suspend关键字可以增强处理这些操作时的严谨性。
Kotlin 流
协程为处理异步操作提供了一个很好的解决方案;然而,它们并没有像 RxJava 那样提供处理多个数据流的好能力。Flows 是协程的扩展,旨在解决这个问题。在处理流时,需要考虑三个实体,如下所述:
-
生产者:这个实体负责发出数据。
-
中间者:这个实体负责数据的转换或操作。
-
消费者:这个实体消费流中的数据。
让我们看看以下示例,如何使用 Kotlin 流来添加两个数字,以及它可能的样子:
fun asyncSum(a: Int, b: Int): Flow<Int> {
return flow {
this.emit(a + b)
}.flowOn(Dispatchers.IO)
}
在这里,我们创建了一个Flow对象,它将在流上发出a + b的结果。flowOn方法将上游的执行移动到 I/O 线程。在这里,我们注意到与 RxJava 在Flows工作概念上的相似性,但我们还注意到它因为使用了Dispatchers而是一个协程的扩展。现在让我们看看消费者侧的流是如何看的,如下所示:
class MyClass : CoroutineScope {
override val coroutineContext: CoroutineContext
get() = Dispatchers.Main + job
private lateinit var job: Job
@FlowPreview
fun asyncComplicatedSum(a: Int, b: Int, c: Int) {
launch {
asyncSum(a, b)
.flatMapConcat {
asyncSum(it, c)
}
.catch {
Log.d(this.javaClass.name, "Something
went wrong")
}
.collect {
Log.d(this.javaClass.name, "Final sum
$it")
}
}
}
}
在这里,我们也注意到它与 RxJava 的相似之处——即当我们尝试通过catch方法操纵流以执行对数字c的加法操作时,以及当处理由于catch方法引起的错误时。然而,collect方法更接近协程,并且需要使用CoroutineScope或声明调用方法为挂起方法。
Flows 为特定用例提供了一些专门的类:StateFlow和SharedFlow。StateFlow类很有用,因为它会在订阅者订阅时提供存储的最后一个值,就像LiveData的工作方式一样。Flows 也可以是冷流和热流,而SharedFlow是热流的专门实现。如果内存中有任何消费者,SharedFlow将发出项目。当消费者订阅SharedFlow时,它也会向消费者发出存储的最后一个值,就像StateFlow一样。
在本节中,我们探讨了 Kotlin 流及其在处理异步操作时提供的优势。接下来,我们将通过一个简单的练习来看看如何在 Android 应用程序中使用 Kotlin 流。
练习 02.02 – 使用 Kotlin 流
修改应用程序,使其从练习 02.01开始,将两个数字的加法返回一个 Flow 而不是挂起函数。
要完成练习,你需要做以下事情:
-
将
NumberAdder中的add函数重写为返回一个 Flow。 -
修改
ViewModel调用add函数的方式。
按照以下步骤完成练习:
-
将
NumberAdder中的add函数修改为返回一个 Flow,如下所示:private const val DELAY = 5000 class NumberAdder( private val dispatcher: CoroutineDispatcher = Dispatchers.IO, private val delay: Int = DELAY ) { suspend fun add(a: Int, b: Int): Flow<Int> { return flow { emit(a + b) }.onEach { delay(delay.toLong()) }.flowOn(dispatcher) } }
在这里,我们创建一个新的 Flow,其中发出a和b的和,然后对流中发出的每个项目进行延迟,最后,我们指定我们希望在它上执行求和的CoroutineDispatcher实例。
-
接下来,让我们修改求和的单元测试,如下所示:
class NumberAdderTest { @get:Rule val dispatcherTestRule = DispatcherTestRule() @ExperimentalCoroutinesApi @Test fun testAdd() = runBlockingTest { val adder = NumberAdder (dispatcherTestRule.testDispatcher, 0) val result = adder.add(1, 4).first() assertEquals(5, result) } }
由于add方法返回一个Flow对象,我们现在必须找到流中发出的第一个项目,并将该项目的值与我们的预期结果进行断言。
-
修改
MainViewModel类以消费add操作,如下所示:class MainViewModel(private val adder: NumberAdder = NumberAdder()) : ViewModel() { var resultState by mutableStateOf("0") private set fun add(a: String, b: String) { viewModelScope.launch { adder.add(a.toInt(), b.toInt()) .collect { resultState = it.toString() } } } }
在这里,add方法仍然将使用相同的CoroutineScope实例来启动add方法,该方法现在将使用collect方法来获取求和的结果。
如果我们按照练习中的步骤启动应用程序,其行为将与练习 02.01相同,我们可以看到 Kotlin 流如何通过引入来自 RxJava 的概念来扩展协程的功能,从而简化我们处理多个数据流的方式。
在本节中,我们看到了异步操作是如何随着时间的推移而演变的,以及我们的应用程序从诸如协程和流等概念中获得了多少好处,这些概念提供了对后台线程的管理,简化了执行异步操作的方式,管理多个数据流,并且可以连接到 Android 组件的生命周期。在下一节中,我们将探讨我们可以用来从网络获取数据的工具,以及它们如何与 Kotlin 协程和流集成。
使用 OkHttp 和 Retrofit 进行网络操作
在本节中,我们将探讨如何使用 Retrofit 库执行网络操作以及它提供的优势。
许多安卓应用程序需要互联网来访问存储在各种服务器上的数据。通常,这通过HttpURLConnection或 Apache HttpClient 来实现。使用这些组件中的任何一个意味着开发者需要手动处理从普通的 Java 对象(POJOs)到 JSON 的转换,处理各种网络配置,以及处理向后兼容性问题。
OkHttp 库将通过OkHttpClient类来解决这些问题,该类将处理各种网络配置并提供其他功能,如缓存。Retrofit 库可以放在 OkHttp 库之上,旨在确保处理各种数据格式时的类型安全。它非常可配置,并允许插入各种转换库以进行 POJO 到 JSON 的转换或可扩展标记语言(XML)或其他类型的格式。
为了将 Retrofit 和 OkHttp 添加到项目中,我们将向build.gradle文件中添加以下依赖项:
dependencies {
…
implementation "com.squareup.okhttp3:okhttp:4.9.0"
implementation "com.squareup.retrofit2:retrofit:2.9.0"
…
}
接下来,我们需要确定需要使用哪些转换器来处理数据。由于 JSON 是一种常见的格式,我们将使用 JSON 转换器和 Moshi 库来完成此操作,因此我们需要添加这两个库的依赖项,如下所示:
dependencies {
…
implementation "com.squareup.okhttp3:okhttp:4.9.0"
implementation "com.squareup.retrofit2:retrofit:2.9.0"
implementation "com.squareup.retrofit2:converter-moshi:2.9.0"
implementation "com.squareup.moshi:moshi:1.13.0"
…
}
在这里,Moshi 库将负责将 POJO 转换为 JSON,转换库将连接到 Retrofit 库,并在 Android 应用程序和服务器之间交换数据时触发此转换。
假设我们需要从服务器以 JSON 格式获取数据。我们可以使用jsonplaceholder.typicode.com/服务作为示例。如果我们想获取用户列表,我们可以使用jsonplaceholder.typicode.com/users 统一资源定位符(URL)。一个用户的 JSON 表示如下:
{
"id": 1,
"name": "Leanne Graham",
"username": "Bret",
"email": "Sincere@april.biz",
"address": {
"street": "Kulas Light",
"suite": "Apt. 556",
"city": "Gwenborough",
"zipcode": "92998-3874",
"geo": {
"lat": "-37.3159",
"lng": "81.1496"
}
},
"phone": "1-770-736-8031 x56442",
"website": "hildegard.org",
"company": {
"name": "Romaguera-Crona",
"catchPhrase": "Multi-layered client-server neural-
net",
"bs": "harness real-time e-markets"
}
我们可以在 JSON 表示中看到,用户有一个id,一个username,一个email值等等。在 Kotlin 中,我们可以创建一个表示,并且可以排除应用程序不需要的属性,例如email、address、phone、website和company,如下所示:
data class User(
@Json(name = "id") val id: Long,
@Json(name = "name") val name: String,
@Json(name = "username") val username: String
)
在这里,我们使用 Moshi 将 JSON 属性映射到 Kotlin 类型,并且只保留了初始 JSON 中存在的三个字段。现在,让我们看看我们如何初始化我们的网络库。完成此操作的代码如下所示:
fun createOkHttpClient() = OkHttpClient
.Builder()
.readTimeout(15, TimeUnit.SECONDS)
.connectTimeout(15, TimeUnit.SECONDS)
.build()
对于 OkHttp,我们使用Builder方法创建一个新的OkHttpClient实例,并且我们可以为其提供某些配置。现在,我们将使用之前创建的OkHttpClient实例来创建一个Retrofit实例,如下所示:
fun createRetrofit(
okHttpClient: OkHttpClient
): Retrofit {
return Retrofit.Builder()
.baseUrl("https://jsonplaceholder.typicode.com/")
.client(okHttpClient)
.build()
}
在这里,我们创建了一个新的Retrofit实例,其基本 URL 设置为jsonplaceholder.typicode.com/。在开发过程中更改基本 URL 非常有用。许多团队将有一个内部使用的开发 URL,用于测试功能和集成,并将有一个生产 URL,其中设置了实际的用户数据。现在,我们需要将 Moshi JSON 序列化连接到Retrofit实例,如下所示:
Fun createConverterFactory(): MoshiConverterFactory = MoshiConverterFactory.create()
在这里,我们创建MoshiConverterFactory,这是一个 Retrofit 转换器,旨在将Retrofit连接到 Moshi 执行的 JSON 序列化。现在,我们需要将我们的Retrofit初始化更改为以下内容:
fun createRetrofit(
okHttpClient: OkHttpClient,
gsonConverterFactory: MoshiConverterFactory
): Retrofit {
return Retrofit.Builder()
.baseUrl("https://jsonplaceholder.typicode.com/")
.client(okHttpClient)
.addConverterFactory(gsonConverterFactory)
.build()
}
在这里,我们将MoshiConverterFactory转换器添加到 Retrofit 的Builder方法中,以允许这两个组件协同工作。最后,我们可以创建一个 Retrofit 接口,其中包含 HTTP 请求的模板,如下所示:
interface UserService {
@GET("/users")
fun getUsers(): Call<List<User>>
@GET("/users/{userId}")
fun getUser(@Path("userId") userId: Int):
Call<User>
@POST("/users")
fun createUser(@Body user: User): Call<User>
@PUT("/users/{userId}")
fun updateUser(@Path("userId") userId: Int, @Body
user: User): Call<User>
}
此接口包含在服务器上获取、创建、更新和删除数据的各种方法的示例。请注意,这些方法的返回类型是Call对象,它提供了执行 HTTP 请求同步或异步的能力。使 Retrofit 对开发者更具吸引力的事情之一是它可以与其他异步库(如 RxJava 和协程)集成。将前面的示例转换为协程将看起来像这样:
interface UserService {
@GET("/users")
suspend fun getUsers(): List<User>
@GET("/users/{userId}")
suspend fun getUser(@Path("userId") userId: Int):
User
@POST("/users")
suspend fun createUser(@Body user: User): User
@PUT("/users/{userId}")
suspend fun updateUser(@Path("userId") userId: Int,
@Body user: User): User
}
在前面的示例中,我们为每个方法添加了 suspend 关键字,并移除了对 Call 类的依赖。这允许我们使用协程执行这些方法。要创建此类的实例,我们需要执行以下操作:
fun createUserService(retrofit: Retrofit) = retrofit.create(UserService::class.java)
在这里,我们使用之前创建的 Retrofit 实例来创建一个新的 UserService 实例。
在本节中,我们分析了如何使用 OkHttp 和 Retrofit 从互联网加载数据以及这些库提供的优势,特别是当与 Kotlin 协程和流结合使用时。在下一节中,我们将创建一个 Android 应用程序,该应用程序将使用这些库从 UI 中获取和显示数据。
练习 02.03 – 使用 OkHttp 和 Retrofit
创建一个连接到 jsonplaceholder.typicode.com/ 并使用 OkHttp、Retrofit 和 Moshi 显示用户列表的 Android 应用程序。对于每个用户,我们将显示姓名、用户名和电子邮件。
要完成练习,你需要执行以下操作:
-
创建一个将映射用户 JSON 表示的
User数据类。 -
创建一个具有获取用户列表方法
UserService类。 -
创建一个将使用
UserService获取用户列表的ViewModel类。 -
实现一个将显示用户列表的
Activity类。
将使用以下方法创建一个 UI 列表:
@Composable
fun UserList(users: List<User>) {
LazyColumn(modifier = Modifier.padding(16.dp)) {
items(users) {
Column(modifier = Modifier.padding(16.dp)) {
Text(text = it.name)
Text(text = it.username)
Text(text = it.email)
}
}
}
}
按照以下步骤完成练习:
-
创建一个具有 Empty Compose Activity 的 Android 应用程序。
-
在
build.gradle文件的顶层,定义 Compose 库版本,如下所示:buildscript { ext { compose_version = '1.0.5' } … } -
在
app/build.gradle文件中,添加以下依赖项:dependencies { implementation 'androidx.core:core-ktx:1.7.0' implementation 'androidx.appcompat:appcompat:1.4.0' implementation 'com.google.android.material:material:1.4.0' implementation "androidx.compose.ui:ui:$compose_version" implementation "androidx.compose.material:material:$compose_version" implementation "androidx.compose.ui:ui-tooling-preview:$compose_version" implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.0' implementation 'androidx.activity:activity-compose:1.4.0' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0' implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0" implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.4.0" implementation "com.squareup.okhttp3:okhttp:4.9.0" implementation "com.squareup.retrofit2:retrofit:2.9.0" implementation "com.squareup.retrofit2:converter-moshi:2.9.0" implementation "com.squareup.moshi:moshi:1.13.0" implementation "com.squareup.moshi:moshi-kotlin:1.13.0" testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version" testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.0" debugImplementation "androidx.compose.ui:ui-tooling:$compose_version" } -
现在,将互联网访问权限添加到
AndroidManifest.xml文件中,如下所示:<uses-permission android:name="android.permission.INTERNET"/> -
现在继续并创建一个将包含用户信息的类,如下所示:
@JsonClass(generateAdapter = true) data class User( @Json(name = "id") val id: Long, @Json(name = "name") val name: String, @Json(name = "username") val username: String, @Json(name = "email") val email: String )
在这里,我们将保存 id 字段,这通常是一个用于区分不同用户和我们需要显示的字段的关联字段。
-
接下来,创建一个将获取用户数据的
UserService类,如下所示:interface UserService { @GET("/users") suspend fun getUsers(): List<User> }
在这里,我们只有一个方法,它将从 /users 路径获取用户列表。
-
现在,我们初始化网络对象。因为我们没有使用任何
MainApplication类,如下所示:class MyApplication : Application() { companion object { lateinit var userService: UserService } override fun onCreate() { super.onCreate() val okHttpClient = OkHttpClient .Builder() .readTimeout(15, TimeUnit.SECONDS) .connectTimeout(15, TimeUnit.SECONDS) .build() val moshi = Moshi.Builder(). add(KotlinJsonAdapterFactory()).build() val retrofit = Retrofit.Builder() .baseUrl("https://jsonplaceholder.typicode.com/") .client(okHttpClient) .addConverterFactory(MoshiConverterFactory.create(moshi)) .build() userService = retrofit.create(UserService::class.java) } }
在这里,我们正在初始化我们的网络库和 UserService 对象。目前,我们持有对这个对象的静态引用,这在一般情况下不是一个好主意。通常,我们会依赖 DI 框架来管理这些网络依赖项。
-
在
AndroidManifest.xml文件中,添加以下代码:<application … android:name=".MyApplication" …>
由于我们正在继承 Application 类,我们需要将此类添加到清单中。
-
接下来,继续创建一个
MainViewModel类,如下所示:class MainViewModel(private val userService: UserService) : ViewModel() { var resultState by mutableStateOf <List<User>>(emptyList()) private set init { viewModelScope.launch { val users = userService.getUsers() resultState = users } } } class MainViewModelFactory : ViewModelProvider.Factory { override fun <T : ViewModel> create(modelClass: Class<T>): T = MainViewModel(MyApplication.userService) as T }
MainViewModel 类将依赖于 UserService 类来获取 Users 列表并将它们存储在用于 UI 的 Compose 状态中。在这里,我们还在创建一个 MainViewModelFactory 类,该类将负责将 UserService 类注入到 MainViewModel 类中。
-
现在,我们继续创建一个
MainActivity类,如下所示:class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { Exercise0203Theme { Surface { Screen() } } } } }
在这里,我们使用内容初始化我们的活动。应将 Exercise203Theme 主题替换为 Android Studio 在创建项目时生成的主题。通常,这应该在一个 Theme 文件中,并且应该是一个 @Composable 函数,该函数以应用程序名称结尾并带有 Theme 后缀。如果不可用,可以为了练习的目的使用 MaterialTheme。
-
创建一个
Screen方法,我们将从MainViewModel类中获取用户列表并绘制一个项目列表,如下所示:@Composable fun Screen(viewModel: MainViewModel = viewModel (factory = MainViewModelFactory())) { UserList(users = viewModel.resultState) } -
最后,将练习定义中的
UserList函数添加到MainActivity文件中。
如果我们按照练习中的步骤启动应用程序,如果设备有互联网访问,我们应该能够看到用户列表正在加载。
在本节中,我们看到了如何在 Android 应用程序中通常从互联网检索数据。我们探讨了 OkHttp 和 Retrofit 等库,并看到了如何以类型安全的方式直接进行 HTTP 调用,而无需手动将 JSON 文件转换为数据类。我们还观察了这些库的潜力,因为它们与异步技术如 RxJava 和协程的集成。在下一节中,我们将探讨用于持久化数据的库以及如何将它们与网络库以及协程和流集成。
使用 Room 库进行数据持久化
在本节中,我们将讨论如何在 Android 应用程序中持久化数据,以及我们如何使用 Room 库来实现这一点。
Android 提供了许多在 Android 设备上持久化数据的方法,大多数涉及文件。其中一些文件采用了专门的数据持久化方法。其中一种方法是以 SQLite 的形式。SQLite 是一种特殊类型的文件,可以使用 结构化查询语言(SQL)查询在其中存储结构化数据,就像 MySQL 和 Oracle 等其他类型的数据库一样。
在过去,如果开发人员想在 SQLite 中持久化数据,他们需要手动定义表、编写查询,并将包含此数据的对象转换为执行 创建、读取、更新和删除(CRUD)操作的正确格式。这类工作涉及大量样板代码,容易出错。Room 通过在 SQLite 操作之上提供抽象层来解决这个问题。
为了将 Room 添加到应用程序中,我们将在 build.gradle 中添加以下库:
dependencies {
…
implementation "androidx.room:room-runtime:2.4.0"
kapt "androidx.room:room-compiler:2.4.0"
…
}
使用 kapt 的原因是 Room 使用会生成与 SQLite 层交互所需代码的注解。为了使用 kapt 功能,我们需要将插件添加到 build.gradle 文件中,如下所示:
plugins {
…
id 'kotlin-kapt'
}
这将允许构建系统分析项目中需要代码生成的注解,并根据提供的注解生成必要的类。
我们想要存储的数据被 @Entity 注解标记,如下面的代码片段所示:
@Entity(tableName = "user")class UserEntity(
@PrimaryKey @ColumnInfo(name = "id") val id: Long,
@ColumnInfo(name = "name") val name: String,
@ColumnInfo(name = "username") val username: String
)
在这里,我们定义了一个名为 UserEntity 的 Room 实体,它将代表一个名为 user 的表,并且 @ColumnInfo 注解用于指定列在数据库中的名称。
一组典型的 CRUD 操作可能看起来像这样:
@Dao
interface UserDao {
@Query("SELECT * FROM user")
fun getAll(): List<UserEntity>
@Query("SELECT * FROM user WHERE id IN (:userIds)")
fun loadAllByIds(userIds: IntArray): List<UserEntity>
@Insert
fun insert(vararg users: User)
@Update
fun update(vararg users: User)
@Delete
fun delete(user: User)
}
正如我们在 Retrofit 中定义了一个用于与服务器通信的服务接口一样,我们也为 Room 定义了一个类似的接口,并使用 @Dao 注解,用于 数据访问对象(DAO)。在这个例子中,我们定义了一系列函数,用于获取存储在表中的所有用户,查找用户,插入新用户,更新用户和删除用户。
与 Retrofit 一样,Room 也提供了与协程的集成,如下面的代码片段所示:
@Dao
interface UserDao {
@Query("SELECT * FROM user")
suspend fun getAll(): List<UserEntity>
@Query("SELECT * FROM user WHERE id IN (:userIds)")
suspend fun loadAllByIds(userIds: IntArray):
List<UserEntity>
@Insert
suspend fun insert(vararg users: User)
@Update
suspend fun update(vararg users: User)
@Delete
suspend fun delete(user: User)
}
在前面的示例中,我们添加了 suspend 关键字,这使得 Room 库易于集成并在协程中执行。
在协程之上,Room 库还可以与 Kotlin flows 集成。这对于每次特定表发生变化时都会发出事件的查询非常有用。这种集成看起来可能如下所示:
@Dao
interface UserDao {
@Query("SELECT * FROM user")
fun getAll(): Flow<List<UserEntity>>
@Query("SELECT * FROM user WHERE id IN (:userIds)")
fun loadAllByIds(userIds: IntArray):
Flow<List<UserEntity>>
}
在前面的示例中,我们将 @Query 函数更改为返回一个 Flow 对象。如果用户表发生变化,则查询将被重新触发,并发出新的用户列表。
我们现在需要设置数据库,如下所示:
@Database(entities = [UserEntity::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
}
在前面的代码片段中,我们定义了一个新的类,该类从 RoomDatabase 类扩展,并使用 @Database 注解声明我们的实体和当前版本。这个版本用于跟踪在应用程序新版本发布之间数据库结构变化时的迁移。
为了初始化数据库,我们需要执行以下代码:
val db = Room.databaseBuilder(
applicationContext,
AppDatabase::class.java, "name"
).build()
这将创建我们的 SQLite 数据库,并返回一个 AppDatabase 实例,我们可以通过它访问我们定义的 DAO 对象并调用它们的方法来处理数据。
在本节中,我们探讨了如何使用 Room 持久化数据以及如何将其与协程和 flows 集成。在下一节中,我们将创建一个 Android 应用程序,该应用程序将使用 Room 持久化数据,并探讨如何将其与 Retrofit 和 OkHttp 集成。
练习 02.04 – 使用 Room 持久化数据
将 Room 集成到 练习 02.03 中,以便当用户从 Retrofit 加载时,它们将被存储在数据库中,然后显示在 UI 上。
要完成练习,你需要做以下事情:
-
创建一个
UserEntity类,它将成为 Room 实体。 -
创建一个
UserDao类,其中将包含插入用户和查询所有用户作为流的函数。 -
创建一个将代表应用程序数据库的
AppDatabase类。 -
修改
MainViewModel类,从UserService类获取用户,然后将其插入到UserDao类中。 -
修改
MainActivity类,使用UserEntity对象列表而不是User对象。
按照以下步骤完成练习:
-
将
kapt插件添加到app/build.gradle文件中,如下所示:plugins { … id 'kotlin-kapt' } -
将 Room 依赖项添加到
app/build.gradle中,如下所示:dependencies { … implementation "androidx.room:room-runtime:2.4.0" implementation "androidx.room:room-ktx:2.4.0" kapt "androidx.room:room-compiler:2.4.0" … } -
创建一个
UserEntity类,如下所示:@Entity(tableName = "user") class UserEntity( @PrimaryKey @ColumnInfo(name = "id") val id: Long, @ColumnInfo(name = "name") val name: String, @ColumnInfo(name = "username") val username: String, @ColumnInfo(name = "email") val email: String )
UserEntity类与User类具有相同的字段,并且包含 Room 注解,用于表名和每列的名称。
-
接下来,创建一个
UserDao类,如下所示:@Dao interface UserDao { @Query("SELECT * FROM user") fun getUsers(): Flow<List<UserEntity>> @Insert(onConflict = OnConflictStrategy.REPLACE) fun insertUsers(users: List<UserEntity>) }
在这里,我们使用 flows 返回用户列表,并使用OnConflictStrategy.REPLACE选项,如果插入相同用户多次,则将其替换为将要插入的那个。其他选项包括OnConflictStrategy.ABORT,如果发生冲突,将丢弃整个事务,或者OnConflictStrategy.IGNORE,如果发生冲突,将跳过插入行。
-
现在,继续创建一个
AppDatabase类,如下所示:@Database(entities = [UserEntity::class], version = 1) abstract class AppDatabase : RoomDatabase() { abstract fun userDao(): UserDao }
在AppDatabase中,我们提供UserDao类以供访问,并使用UserEntity类作为用户表。
-
接下来,我们需要初始化
AppDatabase对象,如下所示:class MyApplication : Application() { companion object { … lateinit var userDao: UserDao … } override fun onCreate() { super.onCreate() … val db = Room.databaseBuilder( applicationContext, AppDatabase::class.java, "my-database" ).build() userDao = db.userDao() … } }
在这里,我们遇到了与 Retrofit 相同的问题,因此我们将采取相同的方法并使用Application类。就像 Retrofit 一样,一个依赖注入框架将帮助我们解决这个问题。
-
现在,让我们将 Room 集成到
MainViewModel类中,如下所示:class MainViewModel( private val userService: UserService, private val userDao: UserDao ) : ViewModel() { var resultState by mutableStateOf<List<UserEntity>>(emptyList()) private set init { viewModelScope.launch { flow { emit(userService.getUsers()) } .onEach { val userEntities = it.map { user -> UserEntity (user.id, user.name, user.username, user.email) } userDao.insertUsers(userEntities) }.flatMapConcat { userDao.getUsers() } .catch { emitAll(userDao.getUsers()) } .flowOn(Dispatchers.IO) .collect { resultState = it } } } } class MainViewModelFactory : ViewModelProvider.Factory { override fun <T : ViewModel> create(modelClass: Class<T>): T = MainViewModel(MyApplication.userService, MyApplication.userDao) as T }
MainViewModel类现在有一个新的依赖项UserDao类。在init块中,我们现在创建一个流,其中发出从 Retrofit 获取的用户列表,然后将其转换为UserEntity并插入到数据库中。之后,我们将查询UserEntities实例,并以流的形式返回它们,这将作为结果。如果发生错误,我们将返回当前存储的用户。
-
最后,更新
MainActivity类中用户的类型,如下所示:class MainActivity : ComponentActivity() { … @Composable fun UserList(users: List<UserEntity>) { … } }
在这里,我们只是将依赖项更改为现在依赖于UserEntity类。
如果我们按照练习中的步骤运行应用程序,我们将看到与练习 02.03相同的输出。然而,如果我们关闭应用程序,在设备上开启飞行模式,然后重新打开应用程序,我们仍然会看到之前显示的信息。
在本节中,我们分析了如何在设备上持久化结构化数据,并使用了 Room 库来实现这一点。我们还观察了 Room 与其他库(如 Retrofit 和流)之间的交互,以及我们如何使用流以非常直接的方式结合 Room 和 Retrofit 的数据流。在下一节中,我们将探讨如何以键值对的形式持久化简单数据。
理解和使用 DataStore 库
在本节中,我们将讨论如何持久化数据的键值对,以及如何使用 DataStore 库来实现这一点。在 Android 中,我们有在键值对中持久化基本类型和字符串的可能性。在过去,这是通过 SharedPreferences 类来完成的,它是 Android 框架的一部分。键和值最终会被保存在设备上的一个 XML 文件中。由于这涉及到 I/O 操作,随着时间的推移,它演变为提供异步保存数据的能力,并保持内存缓存以快速访问数据。然而,这存在一些不一致性,尤其是在初始化 SharedPreferences 对象时。DataStore 的设计是为了解决这些问题,因为它与协程和流集成。
要将 DataStore 添加到项目中,我们需要以下依赖项:
dependencies {
…
implementation "androidx.datastore:datastore-preferences:1.0.0"
…
}
使用 DataStore 的样子如下:
private val KEY_TEXT = stringPreferencesKey("key_text")
class AppDataStore(private val dataStore:
DataStore<Preferences>) {
val savedText: Flow<String> = dataStore.data
.map { preferences ->
preferences[KEY_TEXT].orEmpty()
}
suspend fun saveText(text: String) {
dataStore.edit { preferences ->
preferences[KEY_TEXT] = text
}
}
}
KEY_TEXT 字段将代表一个用于存储文本的键。DataStore<Preferences> 负责获取和将数据写入 SharedPreferences。savedText 字段将监控首选项的变化,并为每个变化在 Flow 对象中发出一个新值。要异步写入数据,我们需要编辑当前的数据存储并设置与键关联的值。
要初始化 DataStore 库,我们需要将以下内容声明为顶级声明:
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "my_preferences")
这将使我们能够访问应用程序其余部分的 DataStore 库。
当我们想要初始化 AppDataStore 时,我们可以使用以下代码:
val appDataStore = AppDataStore(dataStore)
这允许我们包装 DataStore 类,避免将依赖项暴露给应用程序的其他地方。
在本节中,我们探讨了如何以键值对的形式持久化数据,以及如何使用 DataStore 库来实现这一点。在下一节中,我们将创建一个 Android 应用程序,该程序将使用 DataStore 并将其与 Kotlin 流和协程集成。
练习 02.05 – 使用 DataStore 持久化数据
修改 练习 02.04 并引入 DataStore 库,该库将持久化获取用户时执行的请求数量,并在项目列表上方显示此数量。
要完成练习,你需要做以下事情:
-
创建一个名为
AppDataStore的类,该类将管理与 DataStore 库的交互。 -
修改
MainViewModel类,以便注入并使用AppDataStore依赖项来检索当前请求数量并增加请求数量。 -
修改
MainActivity类,添加一个新的Text对象,用于显示请求的计数。
按照以下步骤完成练习:
-
将以下依赖项添加到
app/build.gradle文件中:dependencies { … implementation "androidx.datastore:datastore- preferences:1.0.0" … } -
创建一个
AppDataStore类,如下所示:private val KEY_COUNT = intPreferencesKey("key_count") class AppDataStore(private val dataStore: DataStore<Preferences>) { val savedCount: Flow<Int> = dataStore.data .map { preferences -> preferences[KEY_COUNT] ?: 0 } suspend fun incrementCount() { dataStore.edit { preferences -> val currentValue = preferences[KEY_COUNT] ?: 0 preferences[KEY_COUNT] = currentValue. inc() } } }}
在这里,KEY_COUNT代表 DataStore 库用于存储请求数量的键。每当saveCount字段发生变化时,它将发出一个新的计数值,而incrementCount将增加当前保存的数字 1。
-
现在,设置
AppDataStore依赖项,就像我们处理 Retrofit 和 Room 依赖项一样。代码如下所示:val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "my_preferences") class MyApplication : Application() { companion object { … lateinit var appDataStore: AppDataStore } override fun onCreate() { super.onCreate() … appDataStore = AppDataStore(dataStore) } }
在这里,我们初始化DataStore对象,然后将其注入到AppDataStore类中。
-
接下来,按照以下方式修改
MainViewModel类:class MainViewModel( private val userService: UserService, private val userDao: UserDao, private val appDataStore: AppDataStore ) : ViewModel() { var resultState by mutableStateOf(UiState()) private set init { viewModelScope.launch { flow { emit(userService.getUsers()) } .onEach { val userEntities = it.map { user -> UserEntity (user.id, user.name, user. username, user.email) } userDao.insertUsers(userEntities) appDataStore.incrementCount() }.flatMapConcat { userDao.getUsers() } .catch { emitAll(userDao.getUsers()) } .flatMapConcat { users -> appDataStore.savedCount.map { count -> UiState(users, count.toString()) } } .flowOn(Dispatchers.IO) .collect { resultState = it } } } }
在这里,我们向AppDataStore添加一个新的依赖项,然后在将用户从 Retrofit 插入后,从AppDataStore调用incrementCount方法,然后我们将AppDataStore中的savedCount插入到现有的流程中,并创建一个新的UiState对象,该对象包含用户列表和计数,这些将被收集在resultState对象中。
-
UiState类看起来可能如下所示:data class UiState( val userList: List<UserEntity> = listOf(), val count: String = "" )
此类将保存来自我们两个持久数据源的信息。
-
接下来,按照以下方式更改
MainViewModelFactory:class MainViewModelFactory : ViewModelProvider.Factory { override fun <T : ViewModel> create(modelClass: Class<T>): T = MainViewModel( MyApplication.userService, MyApplication.userDao, MyApplication.appDataStore ) as T }
在这里,我们将一个新的依赖项注入到AppDataStore中,并将其注入到MainViewModel类中。
-
最后,按照以下方式修改
MainActivity类:@Composable fun UserList(uiState: UiState) { LazyColumn(modifier = Modifier.padding(16.dp)) { item(uiState.count) { Column(modifier = Modifier.padding(16.dp)) { Text(text = uiState.count) } } items(uiState.userList) { Column(modifier = Modifier.padding(16.dp)) { Text(text = it.name) Text(text = it.username) Text(text = it.email) } } } }
在这里,我们将UserEntity列表替换为UiState依赖项,并在项目列表中添加一行新行,以指示请求的计数。
如果我们运行应用程序,我们将在顶部看到发送到服务器的当前请求计数。如果我们终止并重新打开应用程序,我们将看到计数增加,这显示了它如何在使用户停止应用程序或操作系统终止应用程序时存活下来。
在本节中,我们分析了在 Android 设备上通过 DataStore 库持久化数据的另一种常见方法。我们还观察到 DataStore 库与 flows 和其他库(如 Room 和 Retrofit)集成的简便性。
摘要
在本章中,我们讨论了如何在 Android 中加载数据和持久化数据,以及我们必须遵循的线程规则。我们首先分析了如何异步加载数据,并专注于协程和流,为此我们进行了简单的练习,以在不同的线程上执行异步操作并更新主线程上的 UI。然后我们研究了如何使用 OkHttp 和 Retrofit 从互联网加载数据,接着探讨了如何使用 Room 和 DataStore 持久化数据,以及我们如何将这些与协程和流集成在一起。我们在练习中强调了这些库的使用,同时也展示了它们如何与协程和流集成。不同数据流的集成被组合在ViewModel类中,其中我们加载网络数据并将其插入到本地数据库中。这通常不是一个好的方法,我们将在未来的章节中进一步探讨如何改进这一点。
在下一章中,我们将探讨如何向用户展示数据,以及我们可以使用的库和框架来实现这一点。
第三章:第三章: 在 Android 上理解数据展示
在本章中,我们将研究可用于在 ViewModel 和 Lifecycle 库上展示数据的库。然后,我们将分析 UI 工作的一些方面,并查看 Jetpack Compose 库如何通过其声明式方法彻底改变了 UI 的构建。最后,我们将探讨如何使用带有 Compose 扩展的 Navigation 库在 Compose 中构建的不同屏幕之间进行导航。
本章将涵盖以下主要内容:
-
分析生命周期感知组件
-
使用 Jetpack Compose 构建用户界面
到本章结束时,你将熟悉如何使用 ViewModel 和 Compose 在 UI 上展示数据。
技术要求
硬件和软件要求如下:
- Android Studio Arctic Fox 2020.3.1 补丁 3
本章的代码文件可以在以下位置找到:github.com/PacktPublishing/Clean-Android-Architecture/tree/main/Chapter3。
查看以下视频,了解代码的实际应用:bit.ly/3lmMIOg
分析生命周期感知组件
在本节中,我们将分析活动和片段的生命周期以及在使用它们时可能出现的潜在问题。我们还将观察 ViewModel 和 LiveData 的引入如何解决这些问题。
当 Android 操作系统和其开发框架发布时,活动是开发应用程序时最常用的组件,因为它们代表了应用程序与用户之间交互的入口点。随着显示技术和分辨率的提高,应用程序可以展示更多用户可以与之交互的信息和控制。对于开发者来说,这意味着管理单个活动逻辑所需的代码量增加了,尤其是在处理横屏和竖屏的不同布局时。片段的引入旨在解决这些问题之一。处理屏幕不同部分的逻辑的责任现在可以分配到不同的片段中。
然而,片段的引入并没有解决开发者所面临的所有问题,主要是因为活动和片段都有自己的生命周期。处理生命周期创造了应用程序出现上下文泄漏的可能性,而生命周期的组合和继承使得活动和片段都难以进行单元测试。
活动的生命周期如下:

图 3.1 – 活动生命周期
在 图 3.1 中,我们可以看到活动最著名的六个状态:
-
创建时间: 当调用
onCreate方法时,活动进入此状态。当系统创建活动时,将调用此方法。 -
启动:当调用
onStart方法时,活动进入此状态。这将在活动对用户可见时被调用。 -
已恢复:当调用
onResume方法时,活动进入此状态。这将在活动获得焦点(用户可以与之交互)时被调用。
当活动不再获得焦点时,将调用以下三个状态。这可能是由于用户关闭活动、将其置于后台或另一个组件获得焦点引起的:
-
暂停:当调用
onPause方法时,活动进入此状态。这将在活动可见但不再获得焦点时被调用。 -
停止:当调用
onStop方法时,活动进入此状态。这将在活动不再可见时被调用。 -
销毁:当调用
onDestroy方法时,活动进入此状态。这将在操作系统销毁活动时被调用。
当我们在代码中使用活动时,处理生命周期将看起来像这样:
class MyActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
}
override fun onStart() {
super.onStart()
}
override fun onResume() {
super.onResume()
}
override fun onPause() {
super.onPause()
}
override fun onStop() {
super.onStop()
}
override fun onDestroy() {
super.onDestroy()
}
}
我们可以看到,我们需要扩展Activity类,如果我们想在特定状态下执行特定操作,我们可以覆盖与该状态相关的方法并调用super调用。这是活动难以进行单元测试的主要原因。super调用不仅会调用我们的代码,还会调用父类的代码。活动难以测试的另一个原因是系统是实例化类的,这意味着我们无法使用类的构造函数进行注入,而必须依赖于设置器来注入模拟对象。
应该在销毁状态和垃圾回收之间做出重要区分。一个销毁的活动并不意味着它将被垃圾回收。垃圾回收的简单定义是:垃圾回收是释放不再使用的内存的过程。每个创建的对象都会占用一定量的内存。当垃圾回收器想要释放内存时,它会查看那些不再被其他对象引用的对象。如果我们想确保对象将被垃圾回收,我们需要确保那些比它们存活时间长的其他对象不会引用我们想要回收的对象。在 Android 中,我们希望调用onDestroy方法。这是因为它们往往占用大量内存,如果我们调用onDestroy方法之后的任何方法,最终会导致崩溃或错误。防止上下文对象被回收的泄漏被称为上下文泄漏。让我们来看一个简单的例子:
interface MyListener {
fun onChange(newText: String)
}
object MyManager {
private val listeners = mutableListOf<MyListener>()
fun addListener(listener: MyListener) {
listeners.add(listener)
}
fun performLogic() {
listeners.forEach {
it.onChange("newText")
}
}
}
在这里,我们有一个MyManager类,其中我们收集在调用performLogic时将被调用的MyListener列表。请注意,MyManager类是使用object关键字定义的。这将使MyManager类成为静态的,这意味着类的实例将与应用程序进程的生命周期一样长。如果我们想让活动在调用performLogic方法时进行监听,我们将有如下所示的内容:
class MyActivity : Activity(), MyListener {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
MyManager.addListener(this)
}
override fun onChange(newText: String) {
myTextView.setText(newText)
}
}
在这里,MyListener在MyActivity中实现,当onChange被调用时,myTextView将被更新。当活动被销毁时,上下文泄露就发生在这里。由于MyActivity是一个MyListener,并且它的引用被保存在生命周期较长的MyManager中,垃圾收集器将不会从内存中移除MyActivity实例。如果在MyActivity被销毁后调用performLogic,我们将得到NullPointerException,因为myTextView将被设置为 null;或者,如果多个MyActivity实例泄露,这可能会消耗整个应用程序的内存。对此的一个简单修复是在销毁时移除对MyActivity的引用:
object MyManager {
…
fun removeListener(listener: MyListener){
listeners.remove(listener)
}
…
}
class MyActivity : Activity(), MyListener {
…
override fun onDestroy() {
MyManager.removeListener(this)
super.onDestroy()
}
…
}
在这里,我们添加了一个简单的方法来从列表中移除MyListener并在onDestroy方法中调用它。
与片段一起工作会导致与活动相同类型的问题。片段有自己的生命周期,并从父Fragment类继承,这使得它们容易受到上下文泄露和难以单元测试的影响。
片段的生命周期如下:
![Figure 3.2 – Fragment 生命周期]
![Figure 3.02_B18320.jpg]
Figure 3.2 – Fragment 生命周期
在Figure 3.2中,我们可以看到片段具有与活动相似的生命周期状态。onAttach和onDetach回调处理片段附加到和从活动中分离的情况。当活动完成其自己的onCreate调用时,会调用onActivityCreated。onCreateView和onDestroyView回调处理填充和销毁片段的视图。这些回调存在的一个原因是片段回退栈。这是一个栈结构,其中保存了片段,以便当用户按下Back按钮时,当前片段从栈中弹出,并显示上一个片段。当片段在回退栈中被替换时,它们不会被完全销毁;只是销毁它们的视图以节省内存。当它们被弹出以供用户查看时,它们将不会被重新创建,并且会调用onCreateView。
为了解决处理活动和片段生命周期引起的问题,创建了一套库,这些库是androidx.lifecycle组的一部分。引入了Lifecycle类,它负责保持当前的生命周期状态和处理生命周期事件之间的转换。Lifecycle类的事件和状态如下:
![Figure 3.3 – 生命周期状态]
![Figure 3.03_B18320.jpg]
图 3.3 – 生命周期状态
在 图 3.3 中,我们可以看到 Lifecycle 类只有四个状态(INITIALIZED、CREATED、STARTED 和 DESTROYED),并且它将处理六个事件(ON_CREATE、ON_START、ON_RESUME、ON_PAUSE、ON_STOP 和 ON_DESTROY)。如果我们希望某个类具有生命周期感知能力,它将需要实现 LifecycleOwner 接口。活动(activities)和片段(fragments)已经实现了这个接口。我们可以看到,对于活动,事件与现有的回调相匹配,但对于片段,需要一些更改以匹配这些新事件。onAttach、onDetach 和 onActivityCreated 方法已被弃用,因此不应与新的 Lifecycle 库一起使用。对片段所做的另一个更改是引入了一个 viewLifecycleObserver 实例变量,它用于处理 onCreateView 和 onDestroyView 之间的生命周期。当注册生命周期感知组件并希望更新 UI 时,应使用此观察者。
在 Android 中,当发生配置更改(例如设备旋转和语言更改)时,活动(activities)和片段(fragments)将被重新创建(当前实例将被销毁,并将创建一个新的实例)。这通常会在数据加载期间发生配置更改或当我们想要恢复之前加载的数据时引起问题。ViewModel 类旨在解决这个问题,以及活动(activities)和片段(fragments)的可测试性问题。ViewModel 将一直存在,直到与之连接的活动或片段被销毁且不会被重新创建。ViewModel 提供了一个 onCleared 方法,可以重写以清除对任何挂起操作的任何订阅。
ViewModel 通常与一个名为 LiveData 的类配对。这是一个生命周期感知组件,用于观察和发射数据。这两个类的组合消除了上下文泄漏的风险,因为 LiveData 只会在观察者在 STARTED 或 RESUMED 状态时发射数据。一个额外的优点是它会保留最后持有的数据;因此,在配置更改的情况下,LiveData 中保留的最后数据将被重新发射。这个优点允许活动(activities)和片段(fragments)观察这些变化,并将 UI 恢复到它们被重新创建之前的状态。在 Jetpack Compose 中,由于 Compose 自带的状态处理类,不需要 LiveData。
要使用 ViewModel 和 LiveData,您需要在 build.gradle 中添加以下库:
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.4.0"
为了与 Jetpack Compose 集成,我们需要以下内容:
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.4.0"
implementation "androidx.compose.runtime:runtime-livedata:2.4.0 "
一个 ViewModel 和 LiveData 实现的例子看起来可能像这样:
class MyViewModel : ViewModel() {
private val _myLiveData = MutableLiveData("")
val myLiveData: LiveData<String> = _myLiveData
init {
_myLiveData.value = "My new value"
}
}
在前面的示例中,我们扩展了 ViewModel 类并定义了两个 LiveData 实例变量。_myLiveData 变量定义为 MutableLiveData 并设置为私有,这是为了防止其他对象更改 LiveData 的值。myLiveData 变量是公共的,可以被 Lifecycle 拥有者用来观察 LiveData 的变化。
要在活动或片段中获取 ViewModel 的实例,我们可以使用以下方法:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
…
val myViewModel : MyViewModel by viewModels()
…
}
}
在这里,viewModels 方法将检索 MyViewModel 的实例。此方法提供了传递 ViewModelProvider.Factory 对象的能力。这在我们需要在 ViewModel 中注入各种对象的情况下很有用。这看起来可能像这样:
val myViewModel : MyViewModel by viewModels {
object : ViewModelProvider.Factory {
override fun <T : ViewModel>
create(modelClass: Class<T>): T {
return MyViewModel() as T
}
}
}
如果我们想观察 LiveData 的变化,我们需要做类似以下的事情:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
…
super.onCreate(savedInstanceState)
val myViewModel: MyViewModel by viewModels()
myViewModel.myLiveData.observe(this) { text ->
myTextView.text = text
}
…
}
}
在前面的示例中,我们调用了 observe 方法,其中我们传递活动作为 LifecycleOwner,并传递一个 Lambda 作为 Observer,当 LiveData 的值发生变化时,它将被调用。
如果我们想在 Jetpack Compose 中使用 ViewModel 和 LiveData,我们必须做以下事情:
@Composable
fun MyScreen(viewModel: MyViewModel = viewModel()) {
viewModel.myLiveData.observeAsState().value?.let {
MyComposable(it)
}
}
@Composable
fun MyComposable(text: String){
…
}
在这里,我们使用 viewModel 方法来获取 MyViewModel 实例。此方法还提供了传递 ViewModelProvider.Factory 实例的可能性,例如之前的 viewModel 方法。observeAsState 扩展方法将观察 LiveData 的变化并将它们转换为 Compose 的 State 对象。
在本节中,我们讨论了活动(activities)和片段(fragments)的生命周期如何工作,以及开发者处理它们时遇到的问题。我们分析了生命周期感知组件(如 ViewModel 和 LiveData)是如何解决这些问题的。ViewModel 类本身代表了对 模型-视图-ViewModel(MVVM)模式的实现,这将在未来的章节中讨论。在下一节中,我们将查看一个练习,我们将使用 ViewModel 和 LiveData,并将它们与 Kotlin 流结合起来。
练习 3.1 – 使用 ViewModel 和 LiveData
修改 第 2.5 章 的 练习 2.5,即 第二章,深入数据源,以便将 UI 的状态保存在 MainViewModel 内部的 LiveData 对象中,而不是使用 Compose 的 State 对象,并显示 "Total request count: x",其中 x 是列表顶部的请求数量。
要完成这个练习,你需要构建以下内容:
-
在
strings.xml中添加指定的文本。 -
创建一个
MainTextFormatter类,它将有一个返回"Total request count: x"文本的方法。 -
在
MainViewModel中添加对MainTextFormatter的依赖,并将格式化后的文本作为值传递给UiState.count对象。 -
删除
resultState并用LiveData对象替换它。 -
更新
@Composable函数以使用LiveData。
按照以下步骤完成练习:
-
将 Jetpack Compose 的
LiveData扩展库添加到app/build.gradle:implementation "androidx.compose.runtime:runtime-livedata:$compose_version" -
在
strings.xml中添加"Total request count"文本:<string name="total_request_count">Total request count: %d</string> -
按照以下方式创建
MainTextFormatter类:class MainTextFormatter(private val applicationContext: Context) { fun getCounterText(count: Int) = applicationContext.getString(R.string.total_request_co unt, count) }
我们创建这个类的原因是为了防止 MainViewModel 类内部可能出现的上下文泄漏,通过在 MainViewModel 类中包含一个 Context 对象。在这里,我们有一个方法,它将计数作为参数并返回所需的文本。
-
在
MainViewModel中注入MainTextFormatter并使用格式化文本作为UiState.count对象的值:class MainViewModel( … private val mainTextFormatter: MainTextFormatter ) : ViewModel() { … init { viewModelScope.launch { … .flatMapConcat { users -> appDataStore.savedCount.map { count -> UiState( users, mainTextFormatter.getCounterText(count) ) } } … } } } -
接下来,在
MyApplication类中创建MainTextFormatter类的实例:class MyApplication : Application() { companion object { … lateinit var mainTextFormatter: MainTextFormatter } override fun onCreate() { super.onCreate() … mainTextFormatter = MainTextFormatter(this) } } -
现在,更新
MainViewModelFactory以使用刚刚创建的MainTextFormatter,并将其传递给MainViewModel:class MainViewModelFactory : ViewModelProvider.Factory { override fun <T : ViewModel> create(modelClass: Class<T>): T = MainViewModel( MyApplication.userService, MyApplication.userDao, MyApplication.appDataStore, MyApplication.mainTextFormatter ) as T } -
接下来,将
LiveData添加到MainViewModel:class MainViewModel( … ) : ViewModel() { private val _uiStateLiveData = MutableLiveData(UiState()) val uiStateLiveData: LiveData<UiState> = _uiStateLiveData init { viewModelScope.launch { … .collect { _uiStateLiveData.value = it } } } }
这里,我们定义了两个 LiveData 变量,一个用于更新值,另一个用于观察,并在 collect 方法中更新 LiveData 的值。
-
在
MainActivity中,更新@Composable函数以使用LiveData:… @Composable fun Screen(viewModel: MainViewModel = viewModel(factory = MainViewModelFactory())) { viewModel.uiStateLiveData.observeAsState().value?.let { UserList(uiState = it) } } …
在这里,我们调用来自 MainViewModel 的 LiveData 的 observeAsState 扩展方法,然后调用 UserList 方法,这将针对每个新值重新绘制 UI。

图 3.4 – 练习 3.1 的输出
如果我们运行应用程序,我们将看到相同的用户列表,在顶部,我们将看到 "Total request count: x" 而不是之前仅有的 x 字符,如图 3.4 所示。在这个练习中,我们使用了 Jetpack Compose 来渲染 UI。在接下来的部分中,我们将分析 Android 如何处理 UI,并更深入地探讨 Jetpack Compose 框架。
使用 Jetpack Compose 构建 UI
在本节中,我们将分析如何使用 View 层次结构构建 Android 应用程序的 UI,并探讨这对应用程序的影响。然后,我们将探讨 Jetpack Compose 如何简化并改变 UI 的构建方式,以及我们如何使用 Compose 创建 UI。我们将从如何与其他库集成以及如何构建简单 UI 的角度来探讨 Jetpack Compose。有关如何构建更复杂 UI 的更多信息,您可以参考以下官方文档:developer.android.com/jetpack/compose。
Android 处理 UI 的方式是通过 View 层次结构。View 的子类处理用户可以与之交互的特定 UI 组件。层次结构看起来类似于以下图表:

图 3.5 – 视图层次结构
TextView 类用于在屏幕上显示文本,EditText 用于处理用户输入的文本,Button 用于在屏幕上渲染按钮。ViewGroup 类是一个专门的子类。它代表负责在屏幕上对视图进行分组和排列的各种布局类的基类。在这里,我们可以找到如 LinearLayout(按垂直或水平顺序依次分组视图)、RelativeLayout(相对于父视图或彼此分组视图)或更近期的 ConstraintLayout 类,它提供了各种方式来定位视图,而无需创建许多嵌套布局(因为它对性能不利),这就是为什么它变得非常普遍。当处理未知长度的项目列表时,会使用如 ListView 和 RecyclerView 这样的对象。两者都需要创建适配器,适配器将负责将列表中的对象与相关的 View 配对,以便在 UI 中渲染列表中的行。
使用 ListViews 容易因为滚动时为每一行重新创建视图而造成效率低下,因此在长列表中会创建大量视图,然后被垃圾回收。为了解决这个问题,开发者必须实现一个名为 RecyclerView 的模式,该模式通过使用 ViewHolder 来解决此问题。这意味着如果用户查看包含 100 项的列表,并且屏幕上有 10 项可见,那么在屏幕上可见的 10 项将会有 10 个视图来表示每一行。当用户向下滚动时,最初创建的 10 个视图将显示当前可见项的内容。开发者还可以通过扩展任何现有的 View 类来创建自定义视图。这在某些 UI 组件需要在不同的活动、片段或其他自定义视图中重复使用时非常有用。
为了向用户显示这些视图,我们需要使用活动和片段。对于活动,这需要在 onCreate 方法中调用 setContentView 方法,而在片段中,我们需要在 onCreateView 方法中返回一个 View 对象。我们可以在 Java 或 Kotlin 中创建活动或片段的整个布局,但这会导致编写大量代码。此外,我们可以为不同的屏幕尺寸或设备旋转使用不同的布局,这导致了使用 res/layout 文件夹,在其中我们可以指定布局可能的外观。以下是一个示例:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/text_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
在前面的示例中,我们定义了 ConstraintLayout,它只包含显示 "Hello World" 文本的 TextView。为了获取 TextView 的引用,以便我们可以在动作或数据加载时更改文本,我们需要使用来自 Activity 类或 View 类的 findViewById 方法。这看起来可能如下所示:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val textView =
findViewById<TextView>(R.id.text_view)
textView.text = "Hello new world"
}
}
这种方法可能会导致应用程序中的崩溃。开发者需要确保当为Activity或Fragment设置布局并使用findViewById时,视图被添加到布局文件中。随着 Kotlin 的引入,这最初是通过 Kotlin Synthetics 框架解决的,该框架为布局中声明的视图生成扩展。Kotlin Synthetics 会为 View 的android:id XML 标签生成一个扩展,这在代码中是可访问的。后来,这被ViewBinding所取代。当在项目中使用ViewBinding时,会为每个布局生成一个类,该类将持有布局中所有视图的引用,消除了与findViewById相关的潜在崩溃。所有这些关于创建 UI 的方法都被定义为命令式,因为我们需要指定界面使用的视图,并控制当数据更改时如何更新视图。
另一种方法是使用View层次结构,而不是使用@Composable函数,在@Composable函数中,我们指定我们想在屏幕上显示的内容,而不需要考虑如何显示它,我们还可以使用 Kotlin 以比通常更少的代码创建 UI。在 Compose 中,Hello World示例可能如下所示:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Surface {
HelloWorld()
}
}
}
}
@Composable
fun HelloWorld() {
Text(text = "Hello World")
}
如果我们想因为数据的变化而更新文本,我们需要使用Compose库中的State对象。Compose 会观察这些状态,当值发生变化时,Compose 会重新绘制与该状态关联的 UI。以下是一个例子:
@Composable
fun HelloWorld() {
val text = remember { mutableStateOf("Hello World") }
ShowText(text = text.value) {
text.value = text.value + "0"
}
}
@Composable
fun ShowText(text: String, onClick: () -> Unit) {
ClickableText(
text = AnnotatedString(text = text),
onClick = {
onClick()
})
}
在这个例子中,当文本被点击时,0 字符会被添加到文本中,并且 UI 会更新。这是因为使用了mutableStateOf。需要remember方法是因为这个状态被保存在一个@Composable函数内部,并且它用于在重新组合发生时(UI 被重新绘制)保持状态完整。为了使文本可点击,我们需要将Text改为ClickableText。我们使用两个@Composable函数的原因是我们希望尽可能使@Composable函数可重用。这被称为HelloWorld组件从无状态组件(ShowText)。
当涉及到渲染项目列表时,Compose 提供了一个简单的方法以Column(当列表长度已知且较短时)和LazyColumn(当项目列表未知且可能很长时)的形式渲染它们。以下是一个来自练习 3.1的例子:
LazyColumn(modifier = Modifier.padding(16.dp)) {
item(uiState.count) {
Column(modifier = Modifier.padding(16.dp)) {
Text(text = uiState.count)
}
}
items(uiState.userList) {
Column(modifier = Modifier.padding(16.dp)) {
Text(text = it.name)
Text(text = it.username)
Text(text = it.email)
}
}
}
在这里,我们在项目列表的顶部显示一个标题,然后我们使用另一个列来设置行的填充;然后,我们使用items函数显示整个项目列表,并为每一行设置填充并显示一个包含三个文本的组。
如果我们要显示输入字段和按钮,我们可以查看我们在练习 2.1中如何实现 UI,见第二章,深入数据源:
@Composable
fun Calculator(
a: String, onAChanged: (String) -> Unit,
b: String, onBChanged: (String) -> Unit,
result: String,
onButtonClick: () -> Unit
) {
Column(modifier = Modifier.padding(16.dp)) {
OutlinedTextField(
value = a,
onValueChange = onAChanged,
keyboardOptions = KeyboardOptions(keyboardType
= KeyboardType.Number),
label = { Text("a") }
)
OutlinedTextField(
value = b,
onValueChange = onBChanged,
keyboardOptions = KeyboardOptions(keyboardType
= KeyboardType.Number),
label = { Text("b") }
)
Text(text = result)
Button(onClick = onButtonClick) {
Text(text = "Calculate")
}
}
}
在这里,我们使用了OutlinedTextField来渲染相当于TextInputLayout的效果。如果我们想要相当于简单的EditText,我们可以使用TextField。对于显示按钮,我们可以使用Button方法,该方法使用Text来渲染按钮上的文本。
Compose 还与其他库集成,例如ViewModel和LiveData:
@Composable
fun Screen(viewModel: MainViewModel = viewModel(factory = MainViewModelFactory())) {
viewModel.uiStateLiveData.observeAsState().value?.let {
UserList(uiState = it)
}
}
在这里,我们可以将ViewModel作为参数传递到我们的Composable函数中,并使用observeAsState函数将LiveData转换为State对象,然后由 Compose 观察以重新绘制 UI。Compose 还支持与Hilt库的集成。当 Hilt 添加到项目中时,则无需为 ViewModel 指定Factory。
Compose 的另一个重要特性是它如何处理不同屏幕之间的导航。Compose 导航建立在androidx.navigation库之上。这使得 Compose 可以使用NavHost和NavController组件在屏幕之间导航。屏幕是用 Compose 构建的,这意味着仅使用 Compose 的应用程序理想情况下只有一个活动。这消除了与活动和片段生命周期相关的任何潜在问题。要将导航引入项目,需要以下库:
dependencies {
…
implementation "androidx.navigation:navigation-compose:2.4.0-rc01"
…
}
如果我们要从一个屏幕导航到另一个屏幕,我们需要获取NavHostController并将其传递给一个表示应用程序结构的@Composable方法:
Surface {
val navController = rememberNavController()
AppNavigation(navController = navController)
}
AppNavigation @Composable方法看起来可能如下所示:
@Composable
fun AppNavigation(navController: NavHostController) {
NavHost(navController, startDestination = "screen1") {
composable(route = "screen1") {
Screen1(navController)
}
composable(
route = "screen2/{param}",
arguments = listOf(navArgument("param") { type
= NavType.StringType })
) {
Screen2(navController,
it.arguments?.getString("param").orEmpty())
}
}
}
在AppNavigation中,我们调用NavHost @Composable函数,在其中我们将放置应用程序的屏幕以及每个屏幕的路径。在这种情况下,Screen1将有一个简单的路径用于导航,而Screen2在导航到时会需要一个参数,通过{param}表示法来指示。对于参数,我们需要指定参数的类型。在这种情况下,它将是String,NavType.StringType表示这一点。如果我们希望传递更复杂的参数,那么我们需要提供我们自己的自定义类型,并指示它们应该如何序列化和反序列化。当我们想要从Screen1导航到Screen2时,我们需要做以下操作:
@Composable
fun Screen1(navController: NavController) {
Column(modifier = Modifier.clickable {
navController.navigate("screen2/test")
}) {
Text(text = "My text")
}
}
当在Screen1中点击Column时,它将调用NavController导航到Screen2并传递test参数。Screen2看起来如下所示:
@Composable
fun Screen2(navController: NavController, text: String) {
Column {
Text(text = text)
}
}
Screen2将使用从it.arguments?.getString("param").orEmpty()提取的文本,并在 UI 上显示它。
在本节中,我们讨论了 Android 如何处理 UI。我们回顾了命令式方法,然后介绍了 Uis 的声明式方法。我们分析了 Jetpack Compose 库以及它试图解决的问题,例如更少的代码和不需要 XML 布局声明。它遵循来自其他技术(如 React 和 SwiftUI)的库的原则,并展示了从函数式编程的角度如何构建 UI。在下一节中,我们将查看如何使用 Compose 在应用程序的两个屏幕之间导航的练习。
练习 3.2 – 使用 Jetpack Compose 导航
修改 练习 3.1,将当前的 @Composable 函数移动到一个名为 UserListScreen 的新文件中,然后创建一个包含新 @Composable 函数的新文件,这些函数将渲染一个简单的文本 UserScreen。当列表中的用户被点击时,新屏幕将被打开,并显示用户的姓名。
要完成练习,你需要构建以下内容:
-
创建一个名为
AppNavigation的密封类,它将有两个变量。第一个变量,名为route,将是String类型,第二个变量,名为argumentName,也将是String类型,默认为empty。AppNavigation的两个子类将是Users(将route变量设置为"users") 和User(将route设置为"users/{name}",然后argumentName设置为name,并有一个创建特定名称路由的方法)。 -
在
MainActivity中,将屏幕@Composable函数重命名为Users,并使用NavController对象在列表行上设置点击监听器,导航到AppNavigation中的User类的路由。 -
创建一个名为
User的新@Composable函数,它将负责显示简单的Text,并将文本作为参数显示。 -
在
MainActivity中,创建一个名为MainApplication的@Composable函数,它将使用NavHost@Composable函数在两个屏幕之间建立导航链接。
按照以下步骤完成练习:
-
在
app/build.gradle中添加 Compose 的navigation库:dependencies { … implementation "androidx.navigation:navigation-compose:2.4.0-rc01" … } -
创建
AppNavigation类,它将保存每个屏幕的路由和参数信息:private const val ROUTE_USERS = "users" private const val ROUTE_USER = "users/%s" private const val ARG_USER_NAME = "name" sealed class AppNavigation(val route: String, val argumentName: String = "") { object Users : AppNavigation(ROUTE_USERS) object User : AppNavigation (String.format(ROUTE_USER, "{$ARG_USER_NAME}") , ARG_USER_NAME) { fun routeForName(name: String) = String.format(ROUTE_USER, name) } }
由于导航依赖于 URL 来识别不同的屏幕,我们可以利用 Kotlin 中的密封类和对象来跟踪每个屏幕所需的输入。
-
将
MainActivity中的屏幕@Composable函数重命名为Users并添加NavController作为参数:@Composable fun Users( navController: NavController, viewModel: MainViewModel = viewModel(factory = MainViewModelFactory()) ) { viewModel.uiStateLiveData.observeAsState().value?.let { UserList(uiState = it, navController) } } -
接下来,将
NavController参数传递给UserList并实现用户行的事件监听器:@Composable fun UserList(uiState: UiState, navController: NavController) { LazyColumn(modifier = Modifier.padding(16.dp)) { item(uiState.count) { Column(modifier = Modifier.padding(16.dp)) { Text(text = uiState.count) } } items(uiState.userList) { Column(modifier = Modifier .padding(16.dp) .clickable { navController.navigate (AppNavigation.User.routeForName (it.name)) }) { Text(text = it.name) Text(text = it.username) Text(text = it.email) } } } } -
在
MainActivity中创建User@Composable函数:@Composable fun User(text: String) { Column { Text(text = text) } } -
现在,创建一个使用
NavHost在MainActivity中设置两个屏幕之间导航的App@Composable函数:@Composable fun App(navController: NavHostController) { NavHost(navController, startDestination = AppNavigation.Users.route) { composable(route = AppNavigation.Users.route) { Users(navController) } composable( route = AppNavigation.User.route, arguments = listOf(navArgument (AppNavigation.User.argumentName) { type = NavType.StringType }) ) { User(it.arguments?.getString(AppNavigation.User.argumentName).orEmpty()) } } } -
最后,当在
MainActivity中设置Activity内容时,调用App函数:class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { Exercise0302Theme { // Replace this with your application's theme Surface { val navController = rememberNavController() App(navController = navController) } } } } }

图 3.6 – 练习 3.2 的输出
如果我们运行应用程序,我们应该看到之前相同的用户列表,如果我们点击一个用户,它将过渡到一个新的屏幕,该屏幕将显示所选用户的姓名,如图 图 3.6 所示。如果我们按下 返回 按钮,我们应该看到初始的用户列表;这是因为默认情况下,navigation 库处理返回导航。
在这个练习中,我们分析了如何使用 Jetpack Compose 在应用程序中在两个屏幕之间进行导航。在未来的章节中,当我们必须在不同的模块之间导航到不同的屏幕时,我们将重新审视导航。
摘要
在本章中,我们分析了如何在 Android 中展示数据,并讨论了我们现在可用的库。我们探讨了 Android 生命周期以及应用程序可能面临的与生命周期相关的问题,然后探讨了库如 ViewModel 和 LiveData 如何解决这些问题的大部分。然后我们研究了 Android 中 UI 的工作方式以及我们需要如何处理使用 XML 定义布局,在这些布局中我们会插入需要显示的视图,以及当数据发生变化时我们需要如何更新视图的状态。然后我们研究了 Jetpack Compose 如何以声明式函数式的方式解决这些问题。我们基于上一章的练习来展示如何在单个应用程序中集成多个库并显示来自互联网的数据。
在下一章中,我们将处理管理应用程序内部的依赖关系以及可用于此目的的库。
第四章:第四章:在 Android 应用程序中管理依赖
在本章中,我们将分析 依赖注入(DI)的概念及其提供的优势,并查看过去在 Android 应用程序中是如何通过手动注入或使用 Dagger 2 来实现的。我们将回顾一些在 Android 应用程序中使用的库,并更详细地查看 Hilt 库以及它是如何简化 Android 应用的 DI 的。
在本章中,我们将涵盖以下主题:
-
DI 简介
-
使用 Dagger 2 管理依赖
-
使用 Hilt 管理依赖
到本章结束时,你将熟悉 DI 模式以及 Dagger 和 Hilt 等库,这些库可以用于管理 Android 应用程序中的依赖项。
技术要求
硬件和软件要求如下:
- Android Studio Arctic Fox 2020.3.1 补丁 3
本章的代码文件可以在以下位置找到:github.com/PacktPublishing/Clean-Android-Architecture/tree/main/Chapter4。
查看以下视频以查看代码的实际应用:bit.ly/38yFDHz
DI 简介
在本节中,我们将探讨 DI 是什么,它提供的优势以及这个概念是如何应用于 Android 应用的。然后,我们将探讨一些 DI 库以及它们是如何工作的。
当一个类依赖于另一个类的功能时,两个类之间就创建了一个依赖。要调用你依赖的类的功能,你需要实例化它,如下例所示:
class ClassA() {
private val b: ClassB = ClassB()
fun executeA() {
b.executeB()
}
}
class ClassB() {
fun executeB() {
}
}
在这个例子中,ClassA 创建了一个新的 ClassB 实例,然后当调用 executeA 时,它将调用 executeB。这引发了一个问题,因为 ClassA 将承担创建 ClassB 的额外责任。让我们看看如果 ClassB 需要变为以下内容时会发生什么:
class ClassB(private val myFlag: Boolean) {
fun executeB() {
if (myFlag) {
// Do something
} else {
// Do something else
}
}
}
在这里,我们向 ClassB 添加了 myFlag 变量,该变量在 executeB 方法中使用。这个更改将导致编译错误,因为现在 ClassA 需要修改才能使代码编译。
class ClassA() {
private val b: ClassB = ClassB(true)
fun executeA() {
b.executeB()
}
}
在这里,我们需要在创建 ClassB 时提供一个布尔值。
当应用程序的代码库增加时,对这些类型的变化进行修改将使维护变得困难。解决这个问题的一个方案是将我们使用依赖项的方式与我们创建依赖项的方式分开,并将创建委托给不同的对象。继续前面的例子,我们可以将 ClassA 重写为以下内容:
class ClassA(private val b: ClassB) {
fun executeA() {
b.executeB()
}
}
在这里,我们移除了 ClassB 的实例化,并将变量移动到了 ClassA 的构造函数中。现在,我们可以创建一个负责创建两个类实例的类,如下所示:
class Injector() {
fun createA(b: ClassB) = ClassA(b)
fun createB() = ClassB(true)
}
在这里,我们有一个新的类,它将使用ClassB作为参数创建ClassA的实例,并且还有一个单独的方法用于创建ClassB的实例。理想情况下,当程序初始化时,我们需要初始化所有依赖项并适当地传递它们:
fun main(args : Array<String>) {
val injector = Injector()
val b = injector.createB()
val a = injector.createA(b)
}
在这里,我们创建了Injector,它负责创建我们的实例,然后调用Injector的适当方法来检索每个类的适当实例。我们在这里所做的是所谓的 DI。不是ClassA创建ClassB的实例,而是它将通过构造函数注入一个ClassB的实例,这也被称为构造函数注入。
在ClassB中,我们在executeB方法中有一个if-else语句。我们可以在那里引入一个抽象,因此我们将if-else语句拆分为两个单独的实现:
class ClassA(private val b: ClassB) {
fun executeA() {
b.executeB()
}
}
interface ClassB {
fun executeB()
}
class ClassB1() : ClassB {
override fun executeB() {
// Do something
}
}
class ClassB2() : ClassB {
override fun executeB() {
// Do something else
}
}
在这里,ClassA保持不变,而ClassB已成为一个接口,有两个实现,分别称为ClassB1和ClassB2,代表if-else分支的实现。在这里,我们也可以使用Injector类来注入这两个实现之一,而无需对ClassA进行任何更改:
class Injector() {
fun createA(b: ClassB) = ClassA(b)
fun createB() = ClassB1()
}
在createB方法中,我们返回一个ClassB1的实例,然后稍后将其注入到ClassA中。这代表了 DI 的另一个好处,我们可以使我们的代码依赖于抽象而不是具体实现,并为不同的目的提供不同的具体实现。基于此,我们可以定义以下角色,当涉及到 DI 时:
-
ClassB1和ClassB2在我们的例子中) -
ClassB在我们的例子中) -
ClassA在我们的例子中) -
Injector在我们的例子中)

Figure 4.1 – DI 类图
前面的图显示了我们的例子和 DI 模式的类图。我们可以观察到Injector类负责创建和注入依赖项,ClassA是接收依赖项的客户端,ClassB是接口,而ClassB1和ClassB2代表服务。
DI 的类型有多种分类,它们主要围绕两种注入依赖项的方式:
-
构造函数注入:依赖项通过构造函数传递。
-
字段注入:依赖项通过 setter 方法或更改实例变量传递。这也可以称为setter 注入,并且还可以扩展到接口注入,其中 setter 方法被抽象为一个接口。
DI 的另一个好处是它使代码更容易测试。当依赖项注入到对象中时,它使类更容易测试,因为在测试代码中,我们可以注入允许我们模拟各种行为的对象,称为mocks。
在本节中,我们介绍了依赖注入(DI)模式,它的工作原理以及它解决的问题。开发者可以通过设置注入器手动管理应用程序的依赖和注入。但是,随着应用程序的增长,手动管理变得越来越困难,尤其是在我们希望某些对象只与其他对象一起存在,而不是与应用程序一样长,或者处理同一类的不同实例时。有各种 DI 框架和库可以管理所有这些情况,在 Android 中,最常用的之一就是 Dagger 2。
使用 Dagger 2 管理依赖项
在本节中,我们将分析 Dagger 2 库,它如何处理 DI,它的工作原理,它如何集成到 Android 应用程序中,以及它可能引起的问题。
Dagger 2 库依赖于基于注解处理的代码生成,这将生成执行 DI 所需的样板代码。该库是用 Java 编写的,并且用于 Android 应用程序之外的各个项目。因为它是用 Java 编写的,所以它为用 Java、Kotlin 或两者编写的应用程序提供了兼容性。该库使用 @Inject、@Named、@Qualifier、@Scope 和 @Singleton 构建)。
当集成 Dagger 2 时,有三个主要概念是我们需要考虑的:
-
用于类的
@Module注解和用于方法的@Provides注解。为了避免过多的@Module定义,我们可以在构造函数上使用@Inject注解,这将提供对象作为依赖项。 -
@Inject注解。 -
@Component注解。
为了将 Dagger 2 添加到 Android 应用程序中,你首先需要将 Kotlin 注解处理器插件添加到使用 Dagger 2 的模块的 build.gradle 文件中:
plugins {
…
id 'kotlin-kapt'
…
}
在这里,我们添加了 kotlin-kapt 插件以允许 Dagger 2 生成 DI 所需的代码。接下来,我们需要 Dagger 2 依赖项:
dependencies {
…
implementation 'com.google.dagger:dagger:2.40.5'
kapt 'com.google.dagger:dagger-compiler:2.40.5'
…
}
在这里,我们向 Dagger 2 库添加了一个依赖,以及一个用于代码生成的注解处理库的依赖。库的版本应该是库仓库中可用的最新稳定版本。
让我们重新介绍上一节中的示例:
class ClassA(private val b: ClassB) {
fun executeA() {
b.executeB()
}
}
interface ClassB {
fun executeB()
}
class ClassB1() : ClassB {
override fun executeB() {
// Do something
}
}
class ClassB2() : ClassB {
override fun executeB() {
// Do something else
}
}
这里,我们有相同的类和相同的依赖项。我们不需要定义 Injector 类,而是可以使用 Dagger 2 来定义 @Module:
@Module
class ApplicationModule {
@Provides
fun provideClassA(b: ClassB): ClassA = ClassA(b)
@Provides
fun provideClassB(): ClassB = ClassB1()
}
在这里,我们使用 @Module 注解了类,并为每个实例使用了 @Provides 注解。我们可以进一步使用 @Inject 注解来简化这一点,并从 ApplicationModule 中删除 @Provides 方法:
class ClassA @Inject constructor(private val b: ClassB) {
…
}
class ClassB1 @Inject constructor() : ClassB {
…
}
class ClassB2 @Inject constructor() : ClassB {
…
}
在前面的代码中,我们已经为每个构造函数添加了@Inject。对于ClassA来说,它将同时扮演向ClassB注入的角色,并将ClassA作为依赖提供给其他对象。然而,存在一个问题,因为ClassA依赖于抽象而不是具体实现,所以 Dagger 将不知道应该为ClassA提供哪个实例。现在,我们可以在ApplicationModule中添加一个被@Binds注解的方法,将抽象与实现连接起来:
@Module
abstract class ApplicationModule {
@Binds
abstract fun bindClassB(b: ClassB1): ClassB
}
在这里,我们添加了bindClassB抽象方法,该方法被@Binds注解。这个方法将告诉 Dagger 2 将ClassB1实现与ClassB抽象连接起来。为了避免大的@Provides注解,我们应该尝试在无法修改代码的地方使用注解,而在可能的情况下使用@Inject在构造函数上,并使用@Binds。
现在,我们需要创建连接器:
@Singleton
@Component(modules = [ApplicationModule::class])
interface ApplicationComponent
在这里,我们定义了一个@Component,在其中我们指定应用程序将使用的模块。@Singleton注解告诉 Dagger,这个组件中的所有依赖将与应用程序的生命周期一样长。在这个时候,我们应该在应用程序上触发构建。这将触发编译,生成一个DaggerApplicationComponent类。这是一个ApplicationComponent的实现,Dagger 2 将处理它。这个类将用于创建整个依赖图。在 Android 中,我们需要一个入口点,这由Application类表示:
class MyApplication : Application() {
lateinit var component: ApplicationComponent
override fun onCreate() {
super.onCreate()
component = DaggerApplicationComponent.create()
}
}
在这里,在MyApplication类中,我们使用DaggerApplicationComponent创建依赖图。这将遍历图中的所有模块并调用所有的@Provides方法。@Component注解还有另一个作用,即在构造函数注入不可用时定义成员注入。在 Android 中,这种情况发生在处理生命周期组件,如活动和片段时,因为我们不允许修改这些类的默认构造函数。为此,我们可以这样做:
@Singleton
@Component(modules = [ApplicationModule::class])
interface ApplicationComponent {
fun inject(mainActivity: MainActivity)
}
在ApplicationComponent中,我们添加了一个名为inject的方法和想要执行注入的Activity。在MainActivity类中,我们需要做以下操作:
class MainActivity : AppCompatActivity() {
@Inject
lateinit var a: ClassA
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
(application as
MyApplication).component.inject(this)
a.executeA()
}
}
在这里,我们需要访问在 MyApplication 中创建的 ApplicationComponent 实例,然后从 ApplicationComponent 中调用 inject 方法。这将初始化变量 a 为 Dagger 2 创建的实例。然而,这种方法有一个问题,因为所有依赖项都将与应用程序的生命周期一样长。这意味着当不需要依赖项时,Dagger 2 需要保留它们在内存中。Dagger 2 以范围和子组件的形式提供了解决方案。我们可以创建一个新的范围,这将告诉 Dagger 2 只在 Activity 存活期间保留某些依赖项,然后将其应用于子组件,这将处理更小的依赖项图。
@Scope
@MustBeDocumented
@kotlin.annotation.Retention(AnnotationRetention.RUNTIME)
annotation class ActivityScope
在这里,我们创建了一个新的 @Scope 注解,它将指示依赖项将与活动一样长。接下来,我们将使用 @ActivityScope 创建一个 @Subcomponent 注解的类:
@ActivityScope
@Subcomponent(modules = [ApplicationModule::class])
interface MainSubcomponent {
fun inject(mainActivity: MainActivity)
}
在这里,我们定义了一个子组件,它将使用 ApplicationModule 并为 MainActivity 中的字段注入提供了一个 inject 方法。之后,我们需要告诉 Dagger 2 创建 MainSubcomponent,通过修改 ApplicationComponent:
@Singleton
@Component
interface ApplicationComponent {
fun createMainSubcomponent(): MainSubcomponent
}
在这里,我们从 @Component 中移除了 ApplicationModule,并用一个 createMainSubcomponent 方法替换了 inject 方法,这将允许 Dagger 创建 MainSubcomponent。最后,我们需要在 MainActivity 中访问 MainSubcomponent 并注入 ClassA 依赖项:
class MainActivity : AppCompatActivity() {
@Inject
lateinit var a: ClassA
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
(application as MyApplication).component.
createMainSubcomponent().inject(this)
a.executeA()
}
}
在这里,我们从 MyApplication 访问 ApplicationComponent 实例,然后创建 MainSubcomponent 并将 ClassA 依赖项注入到 a 变量中。Dagger 2 生成的代码可以在 {module}/build/generated/source/kapt/{build type} 文件夹中看到,看起来类似于以下图:

图 4.2 – 生成的 Dagger 类
在前面的图中,我们可以看到 Dagger 将为 ApplicationComponent 接口以及 MainSubcomponent 实现生成实现。对于需要注入的依赖项,它将生成一个 Factory 类来创建依赖项。在我们通过成员注入的地方,它将创建一个 Injector 类,该类将负责设置成员变量的值,例如 MainActivity 类。
在本节中,我们讨论了 Dagger 2 库以及如何使用它来提供和注入依赖。因为它是在 Android 之外的框架中使用的库,所以它需要特定的解决方案来在活动和片段中注入,使用成员注入器和子组件。通过引入 Dagger Android 库来尝试解决这个问题,该库处理了创建 @Subcomponent 注解的类的创建,并引入了新的注解来指示子组件应该如何创建。最近,Hilt 库的引入通过进一步简化开发者需要编写的代码量,并提供了与 ViewModel 等组件更好的兼容性,更有效地解决了这些问题。在下一节中,我们将查看 Hilt 库以及它是如何解决这些问题的。
使用 Hilt 管理依赖
在本节中,我们将讨论 Hilt DI 库,如何在 Android 应用程序中使用它,以及它相对于 Dagger 2 库提供的额外功能。
Hilt 是一个建立在 Dagger 2 之上的库,专注于 Android 应用程序。这是为了移除在应用程序中使用 Dagger 2 所需要的额外样板代码。Hilt 移除了使用 @Component 和 @Subcomponent 注解的类来使用 Dagger 2 的需求,并转而提供新的注解:
-
在 Android 类中注入依赖时,我们可以为
Application类使用@HiltAndroidApp,为活动、片段、服务、广播接收器和视图使用@AndroidEntryPoint,以及为ViewModels使用@HiltViewModel。 -
当使用
@Module注解时,我们现在可以选择使用@InstallIn并指定一个@DefineComponent注解的类,它代表模块将被添加到的组件。Hilt 提供了一套有用的组件来安装模块:-
@SingletonComponent:这将使依赖与应用程序生命周期相同。 -
@ViewModelComponent:这将使依赖与ViewModel生命周期相同。 -
@ActivityComponent:这将使依赖与Activity生命周期相同。 -
@FragmentComponent:这将使依赖与Fragment生命周期相同。 -
@ServiceComponent:这将使依赖与Service生命周期相同。
-
为了在项目中使用 Hilt,它需要一个 Gradle 插件,该插件需要作为依赖添加到项目的根 build.gradle 文件中:
buildscript {
repositories {
…
}
dependencies {
…
classpath 'com.google.dagger:hilt-android-gradle-
plugin:2.40.5'
}
}
然后,我们需要将注解处理器插件和 Hilt 插件添加到我们想要在 Gradle 模块中使用 Hilt 库的 build.gradle 文件中:
plugins {
…
id 'kotlin-kapt'
id 'dagger.hilt.android.plugin'
}
这两个插件的组合使得 Hilt 能够生成注入依赖所需的源代码。最后,我们还需要将依赖添加到 Hilt 库中:
dependencies {
…
implementation 'com.google.dagger:hilt-android:2.40.5'
kapt 'com.google.dagger:hilt-compiler:2.40.5'
…
}
在这里,我们需要对库本身的依赖以及对注解处理器的依赖,就像在 Dagger 2 中那样必要。
现在,让我们重新引入上一节中的示例:
class ClassA @Inject constructor(private val b: ClassB) {
fun executeA() {
b.executeB()
}
}
interface ClassB {
fun executeB()
}
class ClassB1 @Inject constructor() : ClassB {
override fun executeB() {
// Do something
}
}
class ClassB2 @Inject constructor() : ClassB {
override fun executeB() {
// Do something else
}
}
在这里,我们可以保持我们类的相同结构,并像之前那样使用 @Inject 注解。将提供这些依赖项的 @Module 注解的类将类似于 Dagger 2 模块:
@Module
@InstallIn(SingletonComponent::class)
abstract class ApplicationModule {
@Binds
abstract fun bindClassB(b: ClassB1): ClassB
}
在 ApplicationModule 类中,我们保持之前的实现不变,但现在我们添加了 @InstallIn 注解,这将使此模块提供的依赖项的生命周期与应用程序的生命周期相同。接下来,我们需要触发组件的生成:
@HiltAndroidApp
class MyApplication : Application()
在这里,我们不再需要使用 DaggerApplicationComponent 来手动触发依赖图的创建,而是使用 @HiltAndroidApp,这将为我们完成这项工作,并提供将依赖项注入到 MyApplication 类的能力。最后,我们需要将依赖项注入到 Activity 中:
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@Inject
lateinit var a: ClassA
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
a.executeA()
}
}
在这里,我们使用 @AndroidEntry 入口点通知 Hilt 我们想要将依赖注入到 Activity 中,然后像在 Dagger 2 中那样使用 @Inject 注解。Hilt 生成的代码将类似于以下图示,并可在 {module}/build/generated/source/kapt/{构建类型} 中找到:

图 4.3 – 生成的 Hilt 类
在前面的图中,我们可以看到类似于 Dagger 2 生成的 Factory 类,但 Hilt 将生成额外的类来处理与 Dagger 2 一起工作时所需的样板工作,例如处理活动或片段的注入或在 Application 类中创建依赖图。
在本节中,我们讨论了 Hilt 库,我们如何使用它来管理 Android 应用程序中的依赖项,以及它如何消除了 Dagger 2 所需的样板代码。在下一节中,我们将探讨将 Hilt 集成到应用程序中与其他库一起使用的练习。
Exercise 04.01 – 使用 Hilt 管理依赖项
修改来自 第三章 的 Exercise 03.02 – 使用 Jetpack Compose 进行导航,以便它将使用 Hilt 来管理应用程序中的依赖项。
要完成练习,你需要执行以下操作:
-
将 Hilt 库添加到项目中。
-
创建一个
NetworkModule类,它将提供 Retrofit 依赖项。 -
创建一个
PersistenceModule类,它将提供 Room 和 Data Store 依赖项。 -
清理
MyApplication类,删除MainViewModelFactory类,并改用@HiltViewModel注解。 -
修改
MainActivity,使其从 Hilt Compose Navigation 库中获取MainView模型的实例。
按照以下步骤完成练习:
-
将 Hilt Gradle 插件添加到根项目的
build.gradle文件:buildscript { repositories { … } dependencies { … classpath 'com.google.dagger:hilt-android- gradle-plugin:2.40.5' } } -
将 Gradle 插件应用到 app 模块中的
build.gradle文件:plugins { … id 'dagger.hilt.android.plugin' } -
将 Hilt 库依赖项添加到 app 模块的
build.gradle文件:dependencies { … implementation 'com.google.dagger:hilt-android :2.40.5' kapt 'com.google.dagger:hilt-compiler:2.40.5' implementation 'androidx.hilt:hilt-navigation- compose:1.0.0-rc01' … }
在这里,我们添加了一个允许 Hilt 与 Jetpack Compose Navigation 库一起工作的依赖项。
-
在一个
NetworkModule类中提供网络依赖项:@Module @InstallIn(SingletonComponent::class) class NetworkModule { @Provides fun provideOkHttpClient(): OkHttpClient = OkHttpClient .Builder() .readTimeout(15, TimeUnit.SECONDS) .connectTimeout(15, TimeUnit.SECONDS) .build() @Provides fun provideMoshi(): Moshi = Moshi.Builder(). add(KotlinJsonAdapterFactory()).build() @Provides fun provideRetrofit(okHttpClient: OkHttpClient, moshi: Moshi): Retrofit = Retrofit.Builder() .baseUrl("https://jsonplaceholder.typicode.com/") .client(okHttpClient) .addConverterFactory(MoshiConverterFactory.create (moshi)) .build() @Provides fun provideUserService(retrofit: Retrofit): UserService = retrofit.create(UserService::class.java) }
在这里,我们将所有网络相关的依赖项移动到单独的方法中,分别为 OkHttplClient、Moshi、Retrofit 以及最终的 UserService 类。
-
接下来,创建一个
PersistenceModule类,它将返回所有持久化相关的依赖项:val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "my_preferences") @Module @InstallIn(SingletonComponent::class) class PersistenceModule { @Provides fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase = Room.databaseBuilder( context, AppDatabase::class.java, "my-database" ).build() @Provides fun provideUserDao(appDatabase: AppDatabase): UserDao = appDatabase.userDao() @Provides fun provideAppDataStore(@ApplicationContext context: Context) = AppDataStore (context.dataStore) }
在这里,我们将所有与 Room 相关的类和数据存储类移动到了这里。对于 DataStore,我们要求在文件的最顶层声明 Context.dataStore 文件,因此我们需要将其保留在这里。使用 @ApplicationContext 的目的是表示 Context 对象是应用程序的上下文,而不是其他上下文对象,例如 Activity 对象或 Service 对象。这个注解是一个 Qualifier,其目的是区分同一类的不同实例(在这种情况下,是为了区分应用程序上下文和活动上下文)。
-
将
@Inject注解添加到MainTextFormatter类的构造函数中:class MainTextFormatter @Inject constructor(@ApplicationContext private val applicationContext: Context) { fun getCounterText(count: Int) = applicationContext.getString(R.string.total_ request_count, count) }
这样可以让 Hilt 在每次使用 MainTextFormatter 作为依赖项时提供一个新的实例。在这里,我们还需要使用 @ApplicationContext 注解来使用应用程序的 Context 对象。
-
在
MyApplication类中删除所有依赖项,并添加@HiltAndroidApp注解:@HiltAndroidApp class MyApplication : Application() -
删除
MainViewModelFactory类。 -
将
@HiltViewModel注解添加到MainViewModel类,并将@Inject添加到构造函数:@HiltViewModel class MainViewModel @Inject constructor( private val userService: UserService, private val userDao: UserDao, private val appDataStore: AppDataStore, private val mainTextFormatter: MainTextFormatter ) : ViewModel() { … } -
在
MainActivity的Users@Composable方法中删除对MainViewModelFactory的引用:@Composable fun Users( navController: NavController, viewModel: MainViewModel ) { … } -
将
MainActivity中的@ComposableApp方法修改为在调用Users方法时提供MainViewModel实例:@Composable fun App(navController: NavHostController) { NavHost(navController, startDestination = AppNavigation.Users.route) { composable(route = AppNavigation.Users.route) { Users(navController, hiltViewModel()) } composable( route = AppNavigation.User.route, arguments = listOf(navArgument (AppNavigation.User.argumentName) { type = NavType.StringType }) ) { User(it.arguments?.getString(AppNavigation.User. argumentName).orEmpty()) } } }
这里,我们使用了 hiltViewModel 方法,它来自与 Navigation 库兼容的 Hilt 库。
-
在
MainActivity上添加@AndroidEntryPoint注解:@AndroidEntryPoint class MainActivity : ComponentActivity() { … } -
如果在构建应用程序时遇到
Records requires ASM8错误,请在根项目的gradle.properties文件中添加以下内容:android.jetifier.ignorelist=moshi-1.13.0
这个错误是由当前 Android 构建工具中存在的兼容性问题引起的,应该在后续更新中解决。
如果我们运行本练习涵盖的应用程序,功能界面应该与之前相同。Hilt 在这里的作用是简化我们管理依赖项的方式,这体现在我们如何简化了 MyApplication 类,仅留下一个简单的注解,以及我们移除了 MainViewModelFactory,它本身必须依赖于 MyApplication 类。我们还可以看到将 Hilt 与我们在练习中使用的其他库集成是多么容易。
摘要
在本章中,我们探讨了 DI 模式以及一些可用于将此模式应用于 Android 应用的流行库。我们最初探讨了 Dagger 2 及其如何集成到应用中,然后分析了基于 Dagger 2 构建的 Hilt 库,该库解决了 Android 开发中的一些特定问题。
还有其他库可以用来管理依赖关系,例如 Koin,它使用服务定位器模式(其中创建一个注册表,可以从中获取依赖项)并专为 Kotlin 开发而开发。本章的练习展示了如何将 Hilt 与其他库集成到 Android 应用中。问题是,应用仍然没有形状;没有我们可以指出的东西来表明用例是什么。在接下来的章节中,我们将进一步探讨如何使用 Clean Architecture 原则来结构化我们的代码,从而给它一个形状,从定义实体和用例开始。
第二部分 – 领域和数据层
在本部分中,你将了解数据层和领域层,如何将你的代码结构化到这两个层中,以及构建它们所涉及到的组件。
本部分包括以下章节:
-
第五章,构建 Android 应用程序的领域
-
第六章,构建存储库
-
第七章,构建数据源
第五章:第五章: 构建安卓应用程序的领域
在本章中,我们将分析 Android 应用程序的典型架构及其三个主要层(表示层、领域层和数据层)。然后,我们将学习如何将其转换为 clean architecture,并重点关注位于架构中心的领域层。接下来,我们将探讨它在应用程序架构中的作用以及其实体和用例。最后,我们将通过一个练习来了解如何设置一个具有多个模块的 Android Studio 项目,并使用它们来构建领域层。
在本章中,我们将涵盖以下主题:
-
介绍应用的架构
-
创建领域层
到本章结束时,您将熟悉应用程序的领域层、领域实体和用例。
技术要求
这些是硬件和软件要求:
- Android Studio – Arctic Fox | 2020.3.1 Patch 3
本章的代码文件可以在以下位置找到:github.com/PacktPublishing/Clean-Android-Architecture/tree/main/Chapter5.
查看以下视频,了解代码的实际应用:bit.ly/3826FH6
介绍应用的架构
在本节中,我们将讨论适用于 Android 应用程序的最常见架构,以及它如何与 clean architecture 原则相结合,并了解我们理想中应该如何构建我们的代码库。
在前几章的练习中,我们看到了对于需要集成多个数据源进行网络和持久化的应用程序,我们不得不在 ViewModel 类中放置大量逻辑。在这些示例中,ViewModel 具有多重职责,包括从互联网获取数据、本地持久化以及保留用户界面中所需的信息。除了这些额外的职责外,ViewModel 还依赖于不同的数据源;这意味着网络或持久化库的任何变化都需要修改 ViewModel。为了解决这个问题,我们的代码需要分成具有不同职责的单独层。通常,这些层将类似于以下图示:

图 5.1 – 应用架构图
在 图 5.1 中,我们可以看到有三个具有不同职责的层:
-
ViewModels. -
领域层: 此层负责从数据层获取数据并执行可以在应用程序中跨层重用的业务逻辑。
-
数据层: 此层负责处理与数据管理相关的应用程序的业务逻辑。
我们可以在分层架构之上应用清洁架构原则,通过将领域层置于中心,如图5.2所示,并使其成为存储我们的实体和用例的地方。在外层是表示层和数据层,它们由ViewModels和Repositories表示,以及框架层(由用户界面、持久化和网络框架表示):

图 5.2 – 应用层依赖关系图
在前面的图中,我们可以看到领域层和数据层之间的依赖关系是反转的。领域层仍然会从数据层获取数据,但由于它具有反转的依赖关系,它将受到该层任何变化的较小影响,就像任何变化发生在表示层,它们不会影响领域层一样。如果应用程序受到用例变化的任何影响,那么它将驱动表示层和数据层的变更。
为了分离层,我们可以使用Android 模块。这将帮助我们通过避免层之间的不必要依赖来对项目施加进一步的严谨性。这也帮助提高了大型应用程序的构建时间,因为 Gradle 构建缓存只会重新构建有代码更改的模块。这看起来可能像以下图示:

图 5.3 – 应用模块图
我们可以看到,对于每一层,我们并不需要有限数量的模块,或者我们不需要在三层之间有对应的模块。每一层的扩展可以由不同的因素驱动,例如数据源、应用程序的使用、在数据源中使用的技术和协议(对于某些数据使用 REST API,对于其他数据类型使用蓝牙或近场通信)。用例的使用可能也是一个因素(例如,为多个应用程序使用的一组特定用例)。我们可能想要扩展表示层,因为某些屏幕是如何分组以形成应用程序内部某些隔离的功能和流程的(例如,应用程序的设置部分或登录/注册流程)。一个值得注意的有趣方面是:app模块,它具有组合所有依赖项并将它们组装在一起的作用。在这里,我们将收集所有必需的依赖项并初始化它们。
这里需要注意的一个重要事项是,模块本身并不等同于层;数据模块可以依赖于较低级别的数据模块。实际上,这种情况会在一个层的模块需要依赖于同一层的另一个模块时发生。如果我们在这两个模块之间创建依赖关系,我们可能会得到一个循环依赖关系,这是我们不希望的。在这种情况下,我们需要在这两个模块之间创建一个公共模块,该模块将包含所需的依赖项。例如,如果我们想从:presentation1中的一个屏幕导航到:presentation2中的屏幕或任何其他屏幕,我们需要创建一个新的模块,所有展示模块都将依赖于它,并且将存储处理导航所需的数据或逻辑。当讨论展示层时,我们将更详细地探讨这个问题。
要在 Android Studio 中创建一个新的模块,您需要在 Android Studio 中右键单击项目,选择新建,然后选择模块,如图所示:

图 5.4 – 创建新的 Android Studio 模块
然后,您将被提示选择模块类型,根据功能,您可以选择build.gradle文件。文件中的插件部分将指示已创建 Android 库:
plugins {
id 'com.android.library'
…
}
如果我们想向新创建的模块添加依赖项,我们可以在app模块中使用以下内容:
dependencies {
implementation(project(path: ":my-new-module"))
…
}
向模块添加依赖项的语法类似于添加外部依赖项的语法,并且是通过 Gradle 的implementation方法实现的。其余部分表示app模块将依赖于同一项目内的另一个模块。
在本节中,我们分析了 Android 应用架构的层级以及如何将这些层应用于清洁架构原则。在下一节中,我们将探讨如何构建领域层。
创建领域层
在本节中,我们将讨论如何构建领域层以及它包含的内容,并通过某些示例进行说明。最后,我们将查看一个创建领域层的练习。
因为领域层位于应用程序的中心,它将需要最少的依赖项。这意味着构成领域层的 Gradle 模块将是项目中最稳定的模块。这是为了避免因为领域模块使用的依赖项中发生的变化而导致其他模块发生变化。领域应该负责定义应用程序的实体和用例。
实体由包含数据的主要不可变对象表示。假设我们想将用户表示为一个实体。我们可能会得到以下内容:
data class User(
val id: String,
val firstName: String,
val lastName: String,
val email: String
) {
fun getFullName() = "$firstName $lastName"
}
这里,我们使用了一个简单的data class,并使用val关键字声明所有字段为不可变。我们还有一个针对此对象的业务逻辑函数,它将返回用户的完整名称。
接下来,我们需要定义我们的用例。因为用例将需要从数据层获取数据,我们首先需要为我们的存储库创建一个抽象,最终我们会得到以下内容:
interface UserRepository {
fun getUser(id: String): User
}
这里,我们只有一个简单的方法,它将根据id返回一个用户。现在,我们可以创建一个用于检索用户的用例:
class GetUserUseCase(private val userRepository: UserRepository) {
fun getUser(id: String) = userRepository.getUser(id)
}
在前面的例子中,我们定义了一个用于检索用户的用例,它将依赖于UserRepository来检索用户信息。如果我们查看前面的例子,我们可以看到一些冗余,因为用例没有任何额外的逻辑,只是返回存储库的值。用例的好处在于,当我们想要组合多个存储库的多个结果时。
假设我们想要将用户与特定的位置关联起来,如下所示:
data class Location(
val id: String,
val userId: String,
val lat: Double,
val long: Double
)
这里,我们只保留与特定用户关联的纬度和经度。现在,假设我们会有一个用于不同位置的存储库:
interface LocationRepository {
fun getLocation(userId: String): Location
}
这里,我们再次有一个存储库的抽象,它有一个根据userId获取特定位置的方法。如果我们想获取一个用户及其关联的位置,我们需要为这个创建一个特定的用例:
class GetUserWithLocationUseCase(
private val userRepository: UserRepository,
private val locationRepository: LocationRepository
) {
fun getUser(id: String) =
UserWithLocation(userRepository.getUser(id), locationRepository.getLocation(id))
}
data class UserWithLocation(
val user: User,
val location: Location
)
在前面的例子中,我们创建了一个新的实体UserWithLocation,它将存储User和Location。UserWithLocation将随后用作GetUserWithLocationUseCase中getUser方法的返回结果。这将依赖于UserRepository和LocationRepository来获取相关数据。
我们还可以通过处理线程来进一步改进用例。因为用例将主要处理检索和管理数据,这需要异步处理,所以我们应该在单独的线程上处理。我们可以使用Kotlin flows来管理这个,我们最终可能会得到如下所示的存储库:
interface UserRepository {
fun getUser(id: String): Flow<User>
}
interface LocationRepository {
fun getLocation(id: String): Flow<Location>
}
这里,我们将方法的返回类型更改为 Kotlin flow,它可能会发出数据流或单个项目。现在,我们可以在用例中组合流中的不同流:
class GetUserWithLocationUseCase(
private val userRepository: UserRepository,
private val locationRepository: LocationRepository
) {
fun getUser(id: String) = combine(
userRepository.getUser(id),
locationRepository.getLocation(id)
) { user, location ->
UserWithLocation(user, location)
}.flowOn(Dispatchers.IO)
}
在这里,我们将User和Location流组合成一个UserWithLocation流,并将数据检索操作在IO调度器上执行。
经常在处理数据加载和管理时,尤其是从互联网上,我们可能会遇到不同的错误,这些错误我们必须考虑在我们的用例中。为了解决这个问题,我们可以定义错误实体。定义它们有很多可能性,包括扩展Throwable类、定义特定的数据类、两者的组合,或者将它们与密封类结合:
sealed class UseCaseException(override val cause: Throwable?) : Throwable(cause) {
class UserException(cause: Throwable) :
UseCaseException(cause)
class LocationException(cause: Throwable) :
UseCaseException(cause)
class UnknownException(cause: Throwable) :
UseCaseException(cause)
companion object {
fun extractException(throwable: Throwable):
UseCaseException {
return if (throwable is UseCaseException)
throwable else UnknownException(throwable)
}
}
}
在这里,我们创建了一个密封类,它将作为子类为每个实体提供一个专用错误,以及一个未知错误来处理我们没有考虑到的错误,还有一个伴随方法,该方法将检查一个 Throwable 对象,并返回 UnknownException 以处理任何不是 UseCaseException 的 Throwable。我们需要确保错误通过流程流传播,但首先,我们可以将成功实体与错误实体组合起来,以确保用例的消费者不需要再次检查 Throwable 的类型并进行类型转换。我们可以使用以下方法来实现:
sealed class Result<out T : Any> {
data class Success<out T : Any>(val data: T) :
Result<T>()
class Error(val exception: UseCaseException) :
Result<Nothing>()
}
在这里,我们定义了一个 Result 密封类,它将有两个子类用于成功和错误。Success 类将包含用例的相关数据,而 Error 类将包含之前定义的异常。如果需要,Error 类可以进一步扩展以包含数据以及错误,如果我们想显示缓存的或持久化的数据作为占位符。现在我们可以修改用例以包含 Result 类和错误状态:
class GetUserWithLocationUseCase(
private val userRepository: UserRepository,
private val locationRepository: LocationRepository
) {
fun getUser(id: String) = combine(
userRepository.getUser(id),
locationRepository.getLocation(id)
) { user, location ->
Result.Success(UserWithLocation(user, location)) as
Result<UserWithLocation>
}.flowOn(Dispatchers.IO)
.catch {
emit(Result.Error(UseCaseException.
extractException(it)))
}
}
在这里,我们返回 Result.Success,如果没有发生错误,它将包含 UserWithLocation 对象,并使用 catch 操作符来发出带有在获取数据时发生的 UseCaseException 的 Result.Error。因为这些操作将在多个用例中重复,我们可以使用抽象来创建一个模板,说明每个用例的行为,并让实现只处理必要的数据。一个例子可能如下所示:
abstract class UseCase<T : Any, R : Any>(private val dispatcher: CoroutineDispatcher) {
fun execute(input: T): Flow<Result<R>> =
executeData(input)
.map {
Result.Success(it) as Result<R>
}
.flowOn(dispatcher)
.catch {
emit(Result.Error(UseCaseException.
extractException(it)))
}
internal abstract fun executeData(input: T): Flow<R>
}
在前面的例子中,我们定义了一个抽象类,它将包含 execute 方法,该方法将调用抽象的 executeData 方法,然后将该方法的输出映射到 Result 对象中,接着在 CoroutineDispatcher 上设置流程,最后在 catch 操作符中处理错误。这个实现的代码如下。请注意,executeData 方法的 internal 关键字将只使该方法在当前模块中可访问。这是因为我们只想让用例的用户调用 execute 方法:
class GetUserWithLocationUseCase(
dispatcher: CoroutineDispatcher,
private val userRepository: UserRepository,
private val locationRepository: LocationRepository
) : UseCase<String, UserWithLocation>(dispatcher) {
override fun executeData(input: String):
Flow<UserWithLocation> {
return combine(
userRepository.getUser(input),
locationRepository.getLocation(input)
) { user, location ->
UserWithLocation(user, location)
}
}
}
在这个例子中,GetUserWithLocationUseCase 只需要在 executeData 方法中处理与用例相关的必要数据。我们可以通过引入对所需输入和输出的进一步抽象来使用泛型来绑定用例要处理的数据类型:
abstract class UseCase<T : UseCase.Request, R : UseCase.Response>(private val dispatcher: CoroutineDispatcher) {
…
interface Request
interface Response
}
在这里,我们将 UseCase 类中的泛型绑定到了两个接口——Request 和 Response。前者由用例所需的输入数据表示,后者由用例的输出表示。实现现在看起来像这样:
class GetUserWithLocationUseCase(
dispatcher: CoroutineDispatcher,
private val userRepository: UserRepository,
private val locationRepository: LocationRepository
) : UseCase<GetUserWithLocationUseCase.Request,
GetUserWithLocationUseCase.Response>(dispatcher) {
override fun executeData(input: Request): Flow
<Response> {
return combine(
userRepository.getUser(input.userId),
locationRepository.getLocation(input.userId)
) { user, location ->
Response(UserWithLocation(user, location))
}
}
data class Request(val userId: String) : UseCase.
Request
data class Response(val userWithLocation:
UserWithLocation) : UseCase.Response
}
在这里,我们提供了Request和Response类的实现,并在扩展基类时使用了它们。在这种情况下,Request和Response类代表数据传输对象。当我们为用例创建模板时,观察它们的演变非常重要,因为随着复杂性的增加,模板可能变得不适用。
通常,我们将有机会从现有的较小用例中构建一个新的用例。假设在检索用户和位置时,我们有两个独立的用例:
class GetUserUseCase(
dispatcher: CoroutineDispatcher,
private val userRepository: UserRepository
) : UseCase<GetUserUseCase.Request,
GetUserUseCase.Response>(dispatcher) {
override fun executeData(input: Request): Flow
<Response> {
return userRepository.getUser(input.userId)
.map {
Response(it)
}
}
data class Request(val userId: String) : UseCase.
Request
data class Response(val user: User) : UseCase.Response
}
class GetLocationUseCase(
dispatcher: CoroutineDispatcher,
private val locationRepository: LocationRepository
) : UseCase<GetLocationUseCase.Request,
GetLocationUseCase.Response>(dispatcher) {
override fun executeData(input: Request): Flow
<Response> {
return locationRepository.getLocation(input.userId)
.map {
Response(it)
}
}
data class Request(val userId: String) : UseCase
.Request
data class Response(val location: Location) : UseCase.
Response
}
在前面的例子中,我们为每个用例检索用户和位置创建了两个类。
我们可以将GetUserWithLocationUseCase修改为使用现有的用例,如下所示:
class GetUserWithLocationUseCase(
dispatcher: CoroutineDispatcher,
private val getUserUseCase: GetUserUseCase,
private val getLocationUseCase: GetLocationUseCase
) : UseCase<GetUserWithLocationUseCase.Request,
GetUserWithLocationUseCase.Response>(dispatcher) {
override fun executeData(input: Request): Flow
<Response> {
return combine( getUserUseCase.executeData
(GetUserUseCase.Request(input.userId)),
getLocationUseCase.executeData
(GetLocationUseCase.Request(input.userId))
) { userResponse, locationResponse ->
Response(UserWithLocation(userResponse.user,
locationResponse.location))
}
}
data class Request(val userId: String) : UseCase
.Request
data class Response(val userWithLocation:
UserWithLocation) : UseCase.Response
}
在这里,我们将依赖关系更改为使用两个现有的用例而不是仓库,从每个用例中调用executeData方法,然后使用这两个用例的响应构建一个新的Response。
在本节中,我们探讨了如何使用实体、用例和抽象为仓库构建领域层。在接下来的章节中,我们将探讨与构建领域层相关的练习。
练习 05.01 – 构建领域层
在这个练习中,我们将创建一个新的项目,domain模块将被创建。此模块将包含包含以下数据的实体:
-
User: 这将有一个Long类型的 ID 和一个名字、用户名和电子邮件。 -
Post: 这将有一个 ID 和一个用户 ID,类型为Long,标题和正文。 -
Interaction: 这将包含与该应用的总交互次数。 -
Errors: 这是在帖子或用户无法加载时使用的。
应用程序需要定义以下用例:
-
根据 ID 检索包含用户信息的帖子列表,并按交互数据分组
-
根据 ID 检索特定用户的信息
-
根据 ID 检索特定帖子的信息
-
更新交互数据
要完成此练习,您需要执行以下操作:
-
在 Android Studio 中创建一个新的项目。
-
在根
build.gradle文件中创建所有库依赖项及其版本的映射。 -
在 Android Studio 中创建
domain模块。 -
创建所需的数据和错误实体。
-
创建一个
Result类,它将包含成功和错误场景。 -
创建用于获取用户、帖子和信息交互的仓库抽象。
-
创建所需的四个用例。
按照以下步骤完成练习:
-
在 Android Studio 中创建一个新的项目并选择Empty Compose Activity。
-
在根
build.gradle文件中,添加以下配置,这些配置将用于项目中的所有模块:buildscript { ext { javaCompileVersion = JavaVersion.VERSION_1_8 jvmTarget = "1.8" defaultCompileSdkVersion = 31 defaultTargetSdkVersion = 31 defaultMinSdkVersion = 21 … } -
在同一文件中,添加 Gradle 模块将使用的库版本:
buildscript { ext { … versions = [ androidGradlePlugin: "7.0.4", kotlin : "1.5.31", hilt : "2.40.5", coreKtx : "1.7.0", appCompat : "1.4.1", compose : "1.0.5", lifecycleRuntimeKtx: "2.4.0", activityCompose : "1.4.0", material : "1.5.0", coroutines : "1.5.2", junit : "4.13.2", mockito : "4.0.0", espressoJunit : "1.1.3", espressoCore : "3.4.0" ] … } -
在同一文件中,添加整个项目将使用的插件依赖项的映射。
buildscript { ext { … gradlePlugins = [ android: "com.android.tools.build: gradle:${versions. androidGradlePlugin}", kotlin : "org.jetbrains.kotlin:kotlin- gradle-plugin:${versions.kotlin}", hilt : "com.google.dagger:hilt- android-gradle-plugin: ${versions.hilt}" ] … } -
接下来,您需要将
androidx库的依赖项添加到androidx库中:buildscript { ext { … androidx = [ core : "androidx.core:core-ktx:${versions.coreKtx}", appCompat : "androidx.appcompat:appcompat:${versions.appCompat}", composeUi : "androidx.compose.ui:ui:${versions.compose}", composeMaterial : "androidx.compose.material:material:${versions.compose}", composeUiToolingPreview: "androidx.compose.ui:ui-tooling-preview:${versions.compose}", lifecycleRuntimeKtx : "androidx.lifecycle:lifecycle-runtime-ktx:${versions.lifecycleRuntimeKtx}", composeActivity : "androidx.activity:activity-compose:${versions.activityCompose}" ] … } -
接下来,添加剩余的用于材料设计、依赖注入和测试的库:
buildscript { ext { … material = [ material: "com.google.android. material:material:$ {versions.material}" ] coroutines = [ coroutinesAndroid: "org.jetbrains. kotlinx:kotlinx-coroutines- android:${versions.coroutines}" ] di = [ hiltAndroid : "com.google.dagger:hilt- android:${versions.hilt}", hiltCompiler: "com.google.dagger:hilt- compiler:${versions.hilt}" ] test = [ junit : "junit:junit:${versions.junit}", coroutines: "org.jetbrains.kotlinx: kotlinx-coroutines-test: ${versions.coroutines}", mockito : "org.mockito.kotlin: mockito-kotlin:${versions.mockito}" ] androidTest = [ junit : "androidx.test.ext :junit:${versions.espressoJunit}", espressoCore : "androidx.test. espresso:espresso-core:$ {versions.espressoCore}", composeUiTestJunit: "androidx.compose. ui:ui-test-junit4:${versions.compose}" ] } … } -
在同一文件中,您需要将之前的映射替换为插件依赖项:
buildscript { … dependencies { classpath gradlePlugins.android classpath gradlePlugins.kotlin classpath gradlePlugins.hilt } } -
现在,您需要切换到应用模块中的
build.gradle文件,并将现有的配置更改为顶级build.gradle文件中定义的配置:android { compileSdk defaultCompileSdkVersion defaultConfig { … minSdk defaultMinSdkVersion targetSdk defaultTargetSdkVersion versionCode 1 versionName "1.0" … } … compileOptions { sourceCompatibility javaCompileVersion targetCompatibility javaCompileVersion } kotlinOptions { jvmTarget = jvmTarget useIR = true } buildFeatures { compose true } composeOptions { kotlinCompilerExtensionVersion versions.compose } … } -
在同一文件中,您需要将依赖项替换为顶级
build.gradle文件中定义的依赖项:dependencies { implementation androidx.core implementation androidx.appCompat implementation material.material implementation androidx.composeUi implementation androidx.composeMaterial implementation androidx.composeUiToolingPreview implementation androidx.lifecycleRuntimeKtx implementation androidx.composeActivity testImplementation test.junit } -
在 Android Studio 中,执行同步项目与 Gradle 文件命令,然后执行构建项目命令以确保项目构建时没有错误。
-
为项目创建一个名为
domain的新模块,该模块将是一个 Android 库模块。 -
在
domain模块的build.gradle文件中,确保您有以下插件:plugins { id 'com.android.library' id 'kotlin-android' id 'kotlin-kapt' id 'dagger.hilt.android.plugin' } -
在同一文件中,确保您使用顶级
build.gradle文件中定义的配置:android { compileSdk defaultCompileSdkVersion defaultConfig { minSdk defaultMinSdkVersion targetSdk defaultTargetSdkVersion … } … compileOptions { sourceCompatibility javaCompileVersion targetCompatibility javaCompileVersion } kotlinOptions { jvmTarget = jvmTarget } } -
在同一文件中,您需要添加以下依赖项:
dependencies { implementation coroutines.coroutinesAndroid implementation di.hiltAndroid kapt di.hiltCompiler testImplementation test.junit testImplementation test.coroutines testImplementation test.mockito } -
使用 Gradle 文件同步项目,并再次构建项目以确保 Gradle 配置正确。
-
在
domain模块中,创建一个名为entity的新包。 -
在
entity包中,创建一个名为Post的类,该类将包含id、userId、title和body:data class Post( val id: Long, val userId: Long, val title: String, val body: String ) -
在同一包中,创建一个名为
User的类,该类将包含id、name、username和email:data class User( val id: Long, val name: String, val username: String, val email: String ) -
接下来,创建一个名为
PostWithUser的类,该类将包含post和user信息:data class PostWithUser( val post: Post, val user: User ) -
在同一包中,创建一个名为
Interaction的类,该类将包含总点击次数:data class Interaction(val totalClicks: Int) -
现在,我们需要创建错误实体:
sealed class UseCaseException(cause: Throwable) : Throwable(cause) { class PostException(cause: Throwable) : UseCaseException(cause) class UserException(cause: Throwable) : UseCaseException(cause) class UnknownException(cause: Throwable) : UseCaseException(cause) companion object { fun createFromThrowable(throwable: Throwable): UseCaseException { return if (throwable is UseCaseException) throwable else UnknownException(throwable) } } }
在这里,我们定义了当加载帖子信息或用户信息出现问题时抛出的异常,以及当其他事情出错时抛出的UnknownException。
-
接下来,让我们创建
Result类,该类将包含成功和错误信息:sealed class Result<out T : Any> { data class Success<out T : Any>(val data: T) : Result<T>() class Error(val exception: UseCaseException) : Result<Nothing>() } -
现在,我们需要继续定义存储库的抽象,为此,我们创建一个名为
repository的新包。 -
在
repository包中,创建一个用于管理帖子数据的接口:interface PostRepository { fun getPosts(): Flow<List<Post>> fun getPost(id: Long): Flow<Post> } -
在同一包中,创建一个用于管理用户数据的接口:
interface UserRepository { fun getUsers(): Flow<List<User>> fun getUser(id: Long): Flow<User> } -
在同一包中,创建一个用于管理交互数据的接口:
interface InteractionRepository { fun getInteraction(): Flow<Interaction> fun saveInteraction(interaction: Interaction): Flow<Interaction> } -
现在,我们继续到用例部分,首先创建一个名为
usecase的新包。 -
在此包中,创建
UseCase模板:abstract class UseCase<I : UseCase.Request, O : UseCase.Response>(private val configuration: Configuration) { fun execute(request: I) = process(request) .map { Result.Success(it) as Result<O> } .flowOn(configuration.dispatcher) .catch { emit(Result.Error(UseCaseException. createFromThrowable(it))) } internal abstract fun process(request: I): Flow<O> class Configuration(val dispatcher: CoroutineDispatcher) interface Request interface Response }
在此模板中,我们定义了数据传输对象的抽象,以及一个包含CoroutineDispatcher的Configuration类。创建此Configuration类的原因是能够在不修改UseCase子类的情况下添加其他参数用于用例。我们有一个abstract方法,该方法将由子类实现以从存储库中检索数据,以及一个execute方法,该方法将数据转换为Result,处理错误场景,并设置适当的CoroutineDispatcher。
-
在
usecase包中,创建一个用于检索包含用户信息和交互数据的帖子列表的用例:class GetPostsWithUsersWithInteractionUseCase @Inject constructor( configuration: Configuration, private val postRepository: PostRepository, private val userRepository: UserRepository, private val interactionRepository: InteractionRepository ) : GetPostsWithUsersWithInteractionUseCase GetPostsWithUsersWithInteractionUseCase { override fun process(request: Request): Flow<Response> = combine( postRepository.getPosts(), userRepository.getUsers(), interactionRepository.getInteraction() ) { posts, users, interaction -> val postUsers = posts.map { post -> val user = users.first { it.id == post.userId } PostWithUser(post, user) } Response(postUsers, interaction) } object Request : UseCase.Request data class Response( val posts: List<PostWithUser>, val interaction: Interaction ) : UseCase.Response }
在这个类中,我们扩展了UseCase类,并在process方法中结合了帖子、用户和交互流程。因为没有输入要求,Request类将必须为空,而Response类将包含一个包含组合用户和帖子信息以及交互数据的列表。@Inject注解将帮助我们在此演示层中注入此用例。
-
在同一包中,创建一个用于通过 ID 检索帖子的用例:
class GetPostUseCase @Inject constructor( configuration: Configuration, private val postRepository: PostRepository ) : UseCase<GetPostUseCase.Request, GetPostUseCase.Response>(configuration) { override fun process(request: Request): Flow <Response> = postRepository.getPost(request.postId) .map { Response(it) } data class Request(val postId: Long) : UseCase. Request data class Response(val post: Post) : UseCase. Response } -
在同一包中,创建一个用于通过 ID 检索用户的用例:
class GetUserUseCase @Inject constructor( configuration: Configuration, private val userRepository: UserRepository ) : UseCase<GetUserUseCase.Request, GetUserUseCase.Response>(configuration) { override fun process(request: Request): Flow <Response> = userRepository.getUser(request.userId) .map { Response(it) } data class Request(val userId: Long) : UseCase. Request data class Response(val user: User) : UseCase. Response } -
现在,我们继续到最后一个用例,用于更新交互数据:
class UpdateInteractionUseCase @Inject constructor( configuration: Configuration, private val interactionRepository: InteractionRepository ) : UseCase<UpdateInteractionUseCase.Request, UpdateInteractionUseCase.Response>(configuration) { override fun process(request: Request): Flow <Response> { return interactionRepository.saveInteraction (request.interaction) .map { Response } } data class Request(val interaction: Interaction) : UseCase.Request object Response : UseCase.Response } -
为了单元测试代码,我们需要在
domain模块的test文件夹中创建一个名为resources的新文件夹。 -
在
resources文件夹内,创建一个名为mockito-extensions的子文件夹;在这个文件夹内,创建一个名为org.mockito.plugins.MockMaker的文件;并在该文件内添加以下文本——mock-maker-inline。这允许 Mockito 测试库模拟finalJava 类,在 Kotlin 中意味着所有不带open关键字的类。 -
在
domain模块的测试文件夹中创建一个名为UseCaseTest的新类:class UseCaseTest { @ExperimentalCoroutinesApi private val configuration = UseCase.Configuration (TestCoroutineDispatcher()) private val request = mock<UseCase.Request>() private val response = mock<UseCase.Response>() @ExperimentalCoroutinesApi private lateinit var useCase: UseCase<UseCase.Request, UseCase.Response> @ExperimentalCoroutinesApi @Before fun setUp() { useCase = object : UseCase<UseCase.Request, UseCase.Response>(configuration) { override fun process(request: Request): Flow<Response> { assertEquals(this@UseCaseTest.request, request) return flowOf(response) } } } }
这里,我们为UseCase类提供了一个实现,它将返回一个模拟的响应。
-
接下来,创建一个测试方法来验证
execute方法的成功场景:@ExperimentalCoroutinesApi @Test fun testExecuteSuccess() = runBlockingTest { val result = useCase.execute(request).first() assertEquals(Result.Success(response), result) }
这里,我们断言execute方法的结果是Success并且它包含模拟的响应。
-
接下来,创建一个名为
GetPostsWithUsersWithInteractionUseCaseTest的新测试类:class GetPostsWithUsersWithInteractionUseCaseTest { private val postRepository = mock<PostRepository>() private val userRepository = mock<UserRepository>() private val interactionRepository = mock<InteractionRepository>() private val useCase = GetPostsWithUsersWithInteractionUseCase( mock(), postRepository, userRepository, interactionRepository ) }
这里,我们模拟了所有仓库并将模拟注入我们想要测试的类中。
-
最后,创建一个测试方法来验证我们正在测试的用例中的
process方法:@ExperimentalCoroutinesApi @Test fun testProcess() = runBlockingTest { val user1 = User(1L, "name1", "username1", "email1") val user2 = User(2L, "name2", "username2", "email2") val post1 = Post(1L, user1.id, "title1", "body1") val post2 = Post(2L, user1.id, "title2", "body2") val post3 = Post(3L, user2.id, "title3", "body3") val post4 = Post(4L, user2.id, "title4", "body4") val interaction = Interaction(10) whenever(userRepository.getUsers()).thenReturn (flowOf(listOf(user1, user2))) whenever(postRepository.getPosts()).thenReturn (flowOf(listOf(post1, post2, post3, post4)))whenever(interactionRepository.getInteraction ()).thenReturn(flowOf(interaction)) val response = useCase.process (GetPostsWithUsersWithInteractionUseCase. Request).first() assertEquals( GetPostsWithUsersWithInteractionUseCase. Response( listOf( PostWithUser(post1, user1), PostWithUser(post2, user1), PostWithUser(post3, user2), PostWithUser(post4, user2), ), interaction ), response ) }
这里,我们提供了模拟的用户和帖子列表以及一个模拟的交互,然后我们为每个仓库调用返回这些,然后断言结果是包含四个帖子、由两个用户编写以及模拟交互的列表。
如果我们运行这两个方法的测试,它们应该会通过。为了测试剩余的用例,我们可以应用与GetPostsWithUsersWithInteractionUseCaseTest相同的原理——创建模拟仓库,将它们注入我们希望测试的对象中,然后定义process方法的输入模拟和预期的结果,这将给我们以下截图所示输出:
![图 5.5 – 用例单元测试的输出
![img/Figure_5.05_B18320.jpg]
图 5.5 – 用例单元测试的输出
在本节中,我们进行了一个练习,创建了一个简单的领域,其中包含实体、几个简单的用例以及一个结合了多个数据源的特定用例。领域模块依赖于 flows 和 Hilt。这意味着这些库的更改可能会影响我们的领域模块。这个决定是因为这些库在响应式编程和依赖注入方面提供的优势。由于我们在定义用例时考虑了依赖注入,这使得它们更容易测试,因为我们能够非常容易地将模拟对象注入到测试对象中。
摘要
在本章中,我们探讨了 Android 应用程序架构的分层结构,并专注于领域层,讨论了实体和用例的主题。我们还学习了如何使用依赖倒置将用例和实体置于我们架构的中心。我们通过创建可以在底层实现的仓库抽象来实现这一点。我们还学习了如何使用库模块来强制层之间的分离。
在本章的练习中,我们为 Android 应用程序创建了一个领域模块,提供了一个领域层可能的样子示例。在下一章中,我们将专注于数据层,我们将提供在领域层定义的仓库抽象的实现,并讨论我们如何使用这些仓库来管理应用程序的数据。
第六章:第六章:组装仓库
在本章中,我们将首先讨论应用程序的数据层以及构成这一层的组件,包括仓库和数据源。然后,我们将继续讨论仓库这一应用程序层的组件及其在管理应用程序数据中的作用。在本章的练习中,我们将继续上一章开始的项目,提供上一章中定义的抽象的仓库实现,并引入针对不同类型数据源的新抽象。
在本章中,我们将涵盖以下主题:
-
创建数据层
-
创建仓库
到本章结束时,你将了解数据层是什么以及我们如何为 Android 应用程序创建仓库。
技术要求
硬件和软件要求如下:
- Android Studio Arctic Fox 2020.3.1 补丁 3
本章的代码文件可以在以下位置找到:github.com/PacktPublishing/Clean-Android-Architecture/tree/main/Chapter6.
查看以下视频以查看代码的实际应用:bit.ly/3NpAhNs
创建数据层
在本节中,我们将探讨 Android 应用程序的数据层以及通常构成数据层的组件。
数据层是数据创建和管理的层。这意味着这一层负责创建、读取、更新和删除数据,以及管理和确保来自互联网的数据与持久数据同步。
在上一章中,我们了解到用例依赖于仓库类的抽象,并且可以为不同数据类型存在多个仓库。仓库代表数据层的入口点,并负责管理多个数据源并集中数据。数据源代表数据层的另一个组件,并负责管理特定来源的数据(互联网、Room、数据存储等)。
下图展示了特定数据集的数据层示例,该数据集使用两个数据源:

图 6.1 – 数据层示例
在前面的图示中,我们有一个数据层连接到领域层的示例。我们可以观察到 UseCase 类依赖于一个 Repository 抽象,它代表领域层。数据层由 RepositoryImpl 表示,它是 Repository 抽象的实现。RepositoryImpl 类依赖于两个数据源实现:RemoteDataSourceImpl 和 LocalDataSourceImpl。每个数据源随后依赖于特定实现来管理来自互联网的数据,在 RetrofitService 的情况下使用 Retrofit,或者在 DbDao 的情况下使用特定的数据访问类。
这种方法由于 RepositoryImpl 和 RemoteDataSourceImpl 之间的直接依赖而存在问题,问题出现在我们可能想要用替代品替换 Retrofit 或 Room 时。如果我们可能想要用其他库替换这些库,我们可能会在 RepositoryImpl 类中引起变化,这违反了单一职责原则。解决方案类似于我们解决用例和仓库之间依赖关系的解决方案,即反转仓库和数据源之间的依赖关系。这看起来将如下所示:

图 6.2 – 倒置依赖的数据层
在前面的图示中,我们为每个数据源引入了两个抽象,分别命名为 RemoteDataSource 和 LocalDataSource。RepositoryImpl 现在依赖于这两个抽象,并且所有 Retrofit 或 Room 相关对象与领域实体之间的转换现在应放置在 RemoteDataSourceImpl 或 LocalDataSourceImpl 中,这些实现继承新的抽象并将继续处理 Retrofit 或 Room 的数据。如果我们想将数据层拆分为不同的 Gradle 模块,我们将有以下情况:

图 6.3 – 数据层模块
前面的图示展示了仓库与本地和远程数据源之间的 Gradle 模块依赖关系。在这里,我们可以看到依赖反转的好处,它允许我们拥有一个独立的仓库模块,而不依赖于 Retrofit 或 Room。
在本节中,我们讨论了数据层及其内部组件,以及如何管理所有组件之间的依赖关系。在下一节中,我们将更详细地探讨仓库及其实现方法。
创建仓库
在本节中,我们将探讨仓库是什么以及它在应用程序数据层中扮演的角色,以及我们如何使用各种数据源创建仓库。
仓库代表应用程序使用的数据的抽象,它负责管理和集中化来自一个或多个数据源的数据。
在上一章中,我们定义了以下实体:
data class User(
val id: String,
val firstName: String,
val lastName: String,
val email: String
) {
fun getFullName() = "$firstName $lastName"
}
在这里,我们有一个简单的User数据类,包含一些相关字段。User数据的仓库抽象如下:
interface UserRepository {
fun getUser(id: String): Flow<User>
}
在这里,我们有一个名为UserRepository的接口,它负责在 Kotlin 流中获取用户信息。
如果我们想从互联网上获取数据,我们必须首先定义一个UserRemoteDataSource抽象:
interface UserRemoteDataSource {
fun getUser(id: String): Flow<User>
}
在这种情况下,我们有一个类似于UserRepository定义的接口,它有一个简单的User对象检索方法。现在我们可以实现UserRepository以使用此数据源:
class UserRepositoryImpl(private val userRemoteDataSource:
UserRemoteDataSource) : UserRepository {
override fun getUser(id: String): Flow<User> =
userRemoteDataSource.getUser(id)
}
在这里,我们有一个对UserRemoteDataSource的依赖,并调用getUser方法。如果我们想将远程用户数据本地持久化,我们需要定义一个UserLocalDataSource抽象,它将负责插入用户:
interface UserLocalDataSource {
suspend fun insertUser(user: User)
}
在这里,我们有一个将用户插入本地存储的方法。现在我们可以更新UserRepositoryImpl以连接数据源,并在检索后插入用户:
class UserRepositoryImpl(
private val userRemoteDataSource: UserRemoteDataSource,
private val userLocalDataSource: UserLocalDataSource
) : UserRepository {
override fun getUser(id: String): Flow<User> =
userRemoteDataSource.getUser(id)
.onEach {
userLocalDataSource.insertUser(it)
}
}
这代表了一个简单的数据源使用案例,但我们可以使用仓库来提升用户体验。例如,我们可以更改仓库实现以返回保存的数据,并为远程获取数据提供一个单独的方法。我们可以利用流,它可以在流中发射多个用户:
interface UserLocalDataSource {
suspend fun insertUser(user: User)
fun getUser(id: String): Flow<User>
}
在前面的例子中,我们添加了getUser方法来检索本地持久化的User对象。我们需要修改仓库抽象如下:
interface UserRepository {
fun getUser(id: String): Flow<User>
fun refreshUser(id: String): Flow<User>
}
在这里,我们添加了refreshUser方法,当实现时,将负责从互联网上获取新用户。实现如下:
class UserRepositoryImpl(
private val userRemoteDataSource: UserRemoteDataSource,
private val userLocalDataSource: UserLocalDataSource
) : UserRepository {
override fun getUser(id: String): Flow<User> =
userLocalDataSource.getUser(id)
override fun refreshUser(id: String): Flow<User> =
userRemoteDataSource.getUser(id)
.onEach {
userLocalDataSource.insertUser(it)
}
}
在这里,我们在getUser方法中返回持久化的用户,在refreshUser方法中,我们现在获取远程数据并将其本地插入。如果我们使用 Room 等库,这将触发新的User对象的发射,该对象将来自UserLocalDataSource。这意味着所有getUser方法的订阅者都将收到更改通知并接收一个新的User对象。
我们还可以使用仓库在内存中缓存数据。以下是一个例子:
class UserRepositoryImpl(
private val userRemoteDataSource: UserRemoteDataSource,
private val userLocalDataSource: UserLocalDataSource
) : UserRepository {
private val usersFlow = MutableStateFlow
(emptyMap<String, User>().toMutableMap())
override fun getUser(id: String): Flow<User> =
usersFlow.flatMapLatest {
val user = it[id]
if (user != null) {
flowOf(user)
} else {
userLocalDataSource.getUser(id)
.onEach { persistedUser ->
saveUser(persistedUser)
}
}
}
override fun refreshUser(id: String): Flow<User> =
userRemoteDataSource.getUser(id)
.onEach {
saveUser(it)
userLocalDataSource.insertUser(it)
}
private fun saveUser(user: User) {
val map = usersFlow.value
map[user.id] = user
usersFlow.value = map
}
}
在这里,我们添加了一个新的MutableStateFlow对象,它将持有一个映射,其中键由用户 ID 表示,值是用户。在getUser方法中,我们检查用户是否存储在内存中,如果存在则返回内存值,否则我们获取持久化数据,之后将其存储在内存中。在refreshUser方法中,我们将值持久化并本地持久化数据。
由于我们定义了仓库抽象以返回实体,我们应该尽可能在仓库和数据源抽象中使用实体。然而,我们可能需要特定的对象定义来处理从数据源获取的数据。我们可以在这一层定义这些特定的类,然后将其转换为仓库实现中的实体。
在本节中,我们看到了如何创建仓库以及它们如何被用来管理应用程序中的数据。在接下来的章节中,我们将查看一个练习,我们将为应用程序创建仓库。
练习 06.01 – 创建仓库
修改 练习 05.01:构建领域层,以便在 Android Studio 中创建一个新的库模块。该模块将命名为 data-repository,并将依赖于 domain 模块。在此模块中,我们将实现领域模块中的仓库类,如下所示:
-
UserRepositoryImpl将依赖于以下数据源:UserRemoteDataSource,它将获取列表和按 ID 获取用户,以及UserLocalDataSource,它将包含插入用户列表和获取相同列表的方法。UserRepositoryImpl将始终加载远程用户并将它们本地化。 -
PostRepositoryImpl将依赖于以下数据源:PostRemoteDataSource,它将获取用户列表和按 ID 获取用户,以及PostLocalDataSource,它将包含插入帖子列表和获取相同列表的方法。PostRepositoryImpl将始终加载远程帖子并将它们本地化。 -
InteractionRepositoryImpl将依赖于单一的数据源LocalInteractionDataSource,它将负责加载交互并保存。InteractionRepositoryImpl将加载交互并保存一个新的交互。
要完成此练习,你需要执行以下操作:
-
在 Android Studio 中创建数据仓库模块
-
创建用户的数据源和仓库
-
创建帖子的数据源和仓库
-
创建交互数据源和仓库
按照以下步骤完成练习:
-
创建一个名为
data-repository的新模块,它将是一个 Android 库模块。 -
确保在顶级
build.gradle文件中,以下依赖项已设置:buildscript { … dependencies { classpath gradlePlugins.android classpath gradlePlugins.kotlin classpath gradlePlugins.hilt } } -
在
data-repository模块的build.gradle文件中,确保以下插件存在:plugins { id 'com.android.library' id 'kotlin-android' id 'kotlin-kapt' id 'dagger.hilt.android.plugin' } -
在同一文件中,将配置更改为顶级
build.gradle文件中定义的配置:android { compileSdk defaultCompileSdkVersion defaultConfig { minSdk defaultMinSdkVersion targetSdk defaultTargetSdkVersion … } … compileOptions { sourceCompatibility javaCompileVersion targetCompatibility javaCompileVersion } kotlinOptions { jvmTarget = jvmTarget } } -
在同一文件中,确保以下依赖项已指定:
dependencies { implementation(project(path: ":domain")) implementation coroutines.coroutinesAndroid implementation di.hiltAndroid kapt di.hiltCompiler testImplementation test.junit testImplementation test.coroutines testImplementation test.mockito }
在这里,我们使用implementation方法向:domain模块添加依赖,就像其他库被引用一样。在 Gradle 中,我们也有使用api方法的选项。这使得模块的依赖对其他模块是公开的。这反过来可能会产生潜在的副作用,例如泄露应该保持私有的依赖。在这个例子中,由于两个模块之间关系紧密(这将使得所有依赖于:data-repository的模块不必添加对:domain的依赖),使用api方法对:domain模块可能更有帮助。然而,像 Hilt 和 Coroutines 这样的依赖应该使用implementation方法,因为我们希望避免在未使用这些库的模块中暴露这些库。
-
在
data-repository模块中,创建一个名为data_source的新包。 -
在
data_source包内,创建一个名为remote的新包。 -
在
remote包内,创建RemoteUserDataSource接口:interface RemoteUserDataSource { fun getUsers(): Flow<List<User>> fun getUser(id: Long): Flow<User> } -
在
remote包内,创建RemotePostDataSource接口:interface RemotePostDataSource { fun getPosts(): Flow<List<Post>> fun getPost(id: Long): Flow<Post> } -
在
data_source包内,创建一个名为local的新包。 -
在
local包内,创建LocalUserDataSource接口:interface LocalUserDataSource { fun getUsers(): Flow<List<User>> suspend fun addUsers(users: List<User>) } -
在
local包内,创建LocalPostDataSource接口:interface LocalPostDataSource { fun getPosts(): Flow<List<Post>> suspend fun addPosts(posts: List<Post>) } -
在
local包内,创建LocalInteractionDataSource包:interface LocalInteractionDataSource { fun getInteraction(): Flow<Interaction> suspend fun saveInteraction(interaction: Interaction) } -
在
data_source包旁边,创建一个名为repository的新包。 -
在
repository包内,创建UserRepositoryImpl类:class UserRepositoryImpl @Inject constructor( private val remoteUserDataSource: RemoteUserDataSource, private val localUserDataSource: LocalUserDataSource ) : UserRepository { override fun getUsers(): Flow<List<User>> = remoteUserDataSource.getUsers() .onEach { localUserDataSource.addUsers(it) } override fun getUser(id: Long): Flow<User> = remoteUserDataSource.getUser(id) .onEach { localUserDataSource.addUsers(listOf(it)) } }
在这里,我们从远程数据源获取用户数据并将其存储在本地。
-
在同一个包内,创建
PostRepositoryImpl类:class PostRepositoryImpl @Inject constructor( private val remotePostDataSource: RemotePostDataSource, private val localPostDataSource: LocalPostDataSource ) : PostRepository { override fun getPosts(): Flow<List<Post>> = remotePostDataSource.getPosts() .onEach { localPostDataSource.addPosts(it) } override fun getPost(id: Long): Flow<Post> = remotePostDataSource.getPost(id) .onEach { localPostDataSource.addPosts(listOf(it)) } }
在这里,我们从远程数据源获取帖子数据,并使用本地数据源来持久化数据。
-
在同一个包内,创建
InteractionRepositoryImpl类:class InteractionRepositoryImpl @Inject constructor( private val interactionDataSource: LocalInteractionDataSource ) : InteractionRepository { override fun getInteraction(): Flow<Interaction> = interactionDataSource.getInteraction() override fun saveInteraction(interaction: Interaction): Flow<Interaction> = flow { interactionDataSource.saveInteraction(interaction) this.emit(Unit) }.flatMapLatest { getInteraction() } }
在这里,我们只是与本地数据源交互来读取和存储数据。
-
我们现在想使用 Hilt 将仓库抽象与实现绑定,因此我们需要在
data_source和repository包旁边创建一个名为injection的新包。 -
在
injection包内,创建一个名为RepositoryModule的类:@Module @InstallIn(SingletonComponent::class) abstract class RepositoryModule { @Binds abstract fun bindPostRepository(postRepositoryImpl : PostRepositoryImpl): PostRepository @Binds abstract fun bindUserRepository (userRepositoryImpl: UserRepositoryImpl): UserRepository @Binds abstract fun bindInteractionRepository (interactionRepositoryImpl: InteractionRepositoryImpl): InteractionRepository }
在这里,我们使用@Binds Hilt 注解,它将带有@Inject注解的仓库实现与抽象映射。
-
为了对代码进行单元测试,我们现在需要在
data-repository模块的测试文件夹中创建一个名为resources的新文件夹。 -
在资源文件夹内,创建一个名为
mockito-extensions的文件夹,在这个文件夹内创建一个名为org.mockito.plugins.MockMaker的文件,并在该文件内添加以下文本:mock-maker-inline。 -
为
UserRepositoryImpl方法创建一个UserRepositoryImplTest类进行单元测试:class UserRepositoryImplTest { private val remoteUserDataSource = mock<RemoteUserDataSource>() private val localUserDataSource = mock<LocalUserDataSource>() private val repositoryImpl = UserRepositoryImpl (remoteUserDataSource, localUserDataSource) } -
在
UserRepositoryImplTest类中,为每个仓库方法添加一个测试方法:class UserRepositoryImplTest { … @ExperimentalCoroutinesApi @Test fun testGetUsers() = runBlockingTest { val users = listOf(User(1, "name", "username", "email")) whenever(remoteUserDataSource.getUsers()). thenReturn(flowOf(users)) val result = repositoryImpl.getUsers().first() assertEquals(users, result) verify(localUserDataSource).addUsers(users) } @ExperimentalCoroutinesApi @Test fun testGetUser() = runBlockingTest { val id = 1L val user = User(id, "name", "username", "email" ) whenever(remoteUserDataSource.getUser(id)) .thenReturn(flowOf(user)) val result = repositoryImpl.getUser(id). first() assertEquals(user, result) verify(localUserDataSource).addUsers(listOf(user)) } }
在这个类中,我们通过模拟本地数据和远程数据源,并验证从远程数据源获取的数据是否被插入到本地数据源中,对UserRepositoryImpl类中的每个方法进行单元测试。
-
创建一个
PostRepositoryImplTest类来测试PostRepositoryImpl类:class PostRepositoryImplTest { private val remotePostDataSource = mock<RemotePostDataSource>() private val localPostDataSource = mock<LocalPostDataSource>() private val repositoryImpl = PostRepositoryImpl (remotePostDataSource, localPostDataSource) } -
为
PostRepositoryImpl类中的每个方法创建单元测试:class PostRepositoryImplTest { … @ExperimentalCoroutinesApi @Test fun testGetPosts() = runBlockingTest { val posts = listOf(Post(1, 1, "title", "body")) whenever(remotePostDataSource.getPosts()) .thenReturn(flowOf(posts)) val result = repositoryImpl.getPosts().first() Assert.assertEquals(posts, result) verify(localPostDataSource).addPosts(posts) } @ExperimentalCoroutinesApi @Test fun testGetPost() = runBlockingTest { val id = 1L val post = Post(id, 1, "title", "body") whenever(remotePostDataSource.getPost(id)).thenReturn(flowOf(post)) val result = repositoryImpl.getPost(id).first() Assert.assertEquals(post, result) verify(localPostDataSource).addPosts(listOf(post)) } }
在这个类中,我们执行了为UserRepositoryImpl执行的相同测试。
-
创建一个
InteractionRepositoryImplTest类来测试InteractionRepositoryImpl类:class InteractionRepositoryImplTest { private val localInteractionDataSource = mock<LocalInteractionDataSource>() private val repositoryImpl = InteractionRepositoryImpl (localInteractionDataSource) } -
为
InteractionRepositoryImpl类中的每个方法创建单元测试:class InteractionRepositoryImplTest { … @ExperimentalCoroutinesApi @Test fun testGetInteraction() = runBlockingTest { val interaction = Interaction(10) whenever(localInteractionDataSource. getInteraction()). thenReturn(flowOf(interaction)) val result = repositoryImpl.getInteraction() .first() assertEquals(interaction, result) } @ExperimentalCoroutinesApi @Test fun testSaveInteraction() = runBlockingTest { val interaction = Interaction(10) whenever(localInteractionDataSource. getInteraction()).thenReturn (flowOf(interaction)) val result = repositoryImpl.saveInteraction (interaction).first() veriy(localInteractionDataSource). saveInteraction(interaction) assertEquals(interaction, result) } }
在这个类中,我们模拟本地数据源,然后验证仓库对LocalInteractionDataStore模拟的适当调用。
如果我们运行测试,我们应该看到以下截图类似的内容:

图 6.4 – 仓库单元测试的输出
在这个练习中,我们在一个新模块中实现了我们的仓库,并为仓库将使用的源定义了新的抽象。在这里,我们继续与其他库的集成,例如 Hilt 用于依赖注入,以及 Kotlin flows 以响应式方法处理数据。依赖注入的使用使得单元测试的编写变得简单,因为我们能够轻松提供模拟。
摘要
在本章中,我们开始探讨 Android 应用程序的数据层,并概述了该层包含的组件。我们还研究了负责管理一个或多个数据源提供的数据的仓库组件,并提供了构建不同仓库的示例。我们还探讨了仓库和数据源之间的关系,以及我们如何通过依赖倒置进一步解耦组件,以保持我们的仓库不受用于获取数据的库的变化影响。最后,我们查看了一个关于如何使用本地和远程数据源构建仓库的练习。在下一章中,我们将继续探讨数据层,以及我们如何使用 Room 和 Retrofit 等库集成远程和本地数据源。
第七章:第七章:构建数据源
在本章中,我们将继续关注数据层,通过讨论如何实现本地和远程数据源以及它们在整洁架构中的作用来继续关注数据层。首先,我们将探讨如何构建远程数据源以及它们如何通过调用 Retrofit 从互联网获取数据。然后,我们将探讨实现本地数据源以及它们如何与 Room 和 Data Store 交互以在本地持久化数据。在章节的练习中,我们将继续之前的练习,并添加章节中讨论的数据源,看看我们如何将它们连接到 Room 和 Retrofit。
在本章中,我们将涵盖以下主题:
-
构建和使用远程数据源
-
构建和集成本地数据源
到本章结束时,你将了解数据源的作用,如何实现使用 Retrofit、Room 和 Data Store 管理应用程序数据的远程和本地数据源,以及我们如何将这些数据源分离到单独的库模块中。
技术要求
硬件和软件要求如下:
- Android Studio – Arctic Fox | 2020.3.1 Patch 3
本章的代码文件可以在此处找到:github.com/PacktPublishing/Clean-Android-Architecture/tree/main/Chapter7.
查看以下视频以查看代码的实际应用:bit.ly/3yOa7jE
构建和使用远程数据源
在本节中,我们将探讨如何构建远程数据源以及如何结合 Retrofit 使用它们从互联网获取和操作数据。
在前几章中,我们为数据源定义了抽象,这些数据源是存储库依赖以操作数据。这是因为我们想要避免存储库对数据源的依赖,而是让数据源依赖于存储库。对于远程数据源,这看起来像以下图示:

图 7.1 – 远程数据源类图
远程数据源的实现有两个角色。它将调用网络层来获取和操作数据,并将数据转换为领域实体或,如果需要,存储库所需的中介数据。
让我们看看前几章中定义的实体:
data class User(
val id: String,
val firstName: String,
val lastName: String,
val email: String
) {
fun getFullName() = "$firstName $lastName"
}
这里,我们有与领域定义相同的 User 数据类。现在让我们假设我们从互联网以 JSON 格式获取以下数据:
data class UserApiModel(
@Json(name = "id") val id: String,
@Json(name = "first_name") val firstName: String,
@Json(name = "last_name") val lastName: String,
@Json(name = "email") val email: String
)
在这里,我们有一个 UserApiModel 类,其中我们定义了与 User 类相同的字段,并使用 Moshi 库的 @Json 注解进行注解。
远程数据源抽象看起来如下:
interface UserRemoteDataSource {
fun getUser(id: String): Flow<User>
}
这是我们在上一章中定义的抽象。在我们编写此类的实现之前,我们首先需要指定我们的 Retrofit 服务:
interface UserService {
@GET("/users/{userId}")
suspend fun getUser(@Path("userId") userId: String):
UserApiModel
}
这是一个典型的 Retrofit 服务类,它将从 /users/{userId} 端点获取 UserApiModel 类。我们现在可以创建数据源实现来从 UserService 获取用户:
data class UserRemoteDataSourceImpl(private val userService: UserService) : UserRemoteDataSource {
override fun getUser(id: String): Flow<User> {
return flow {
emit(userService.getUser(id))
}.map {
User(it.id, it.firstName, it.lastName,
it.email)
}
}
}
在这里,我们实现了 UserRemoteDataSource 接口,并在 getUser 方法中调用 UserService 依赖项中的 getUser 方法。一旦获得 UserApiModel,我们将其转换为 User 类。
在本节中,我们探讨了如何使用 Retrofit 库构建远程数据源来操作来自互联网的数据。在下一节中,我们将查看一个练习,展示如何实现远程数据源。
练习 07.01 – 构建远程数据源
修改 练习 06.01 – 创建仓库,以便在 Android Studio 中创建一个新的库模块。模块名称为 data-remote。此模块将依赖于 domain 和 data-repository。该模块将负责从 jsonplaceholder.typicode.com/ 获取用户和帖子作为 JSON。
用户将具有以下 JSON 表示形式:
{
"id": 1,
"name": "Leanne Graham",
"username": "Bret",
"email": "Sincere@april.biz"
}
帖子将具有以下 JSON 表示形式:
{
"userId": 1,
"id": 1,
"title": "sunt aut facere repellat provident
occaecati excepturi optio reprehenderit",
"body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"
}
该模块需要实现以下功能:
-
UserApiModel和PostApiModel,它们将保存来自 JSON 的数据。 -
UserService,它将从/usersURL 返回UserApiModel列表,并根据/users/{userId}URL 的 ID 返回UserApiModel。 -
PostService,它将从/postsURL 返回PostApiModel列表,并根据/post/{postId}URL 的 ID 返回PostApiModel。 -
RemoteUserDataSourceImpl,它将实现RemoteUserDataSource,调用UserService,并返回Flow,如果调用UserService时出现错误,则发出User对象列表或UseCaseException.UserException。基于 ID 返回User的相同方法也将被采用。 -
RemotePostDataSourceImpl将实现RemotePostDataSource,调用PostService,并返回Flow,如果调用PostService时出现错误,则发出Post对象列表或UseCaseException.PostException。基于 ID 返回帖子的相同方法也将被采用。
要完成此练习,您需要执行以下操作:
-
创建
data-remote模块。 -
创建
UserApiModel和UserService类。 -
创建
PostApiModel和PostService类。 -
为
RemoteUserDataSource和RemotePostDataSource创建远程数据源实现。
按照以下步骤完成练习:
-
创建一个名为
data-remote的新模块,它将是一个 Android 库模块。 -
确保在顶级
build.gradle文件中设置了以下依赖项:buildscript { … dependencies { classpath gradlePlugins.android classpath gradlePlugins.kotlin classpath gradlePlugins.hilt } } -
在同一文件中,将网络库添加到库映射中:
ext { … versions = [ … okHttp : "4.9.0", retrofit : "2.9.0", moshi : "1.13.0", … ] … network = [ okHttp : "com.squareup.okhttp3: okhttp:${versions.okHttp}", retrofit : "com.squareup.retrofit2 :retrofit:${versions.retrofit}", retrofitMoshi: "com.squareup.retrofit2 :converter-moshi:$ {versions.retrofit}", moshi : "com.squareup.moshi: moshi:${versions.moshi}", moshiKotlin : "com.squareup.moshi: moshi-kotlin:${versions.moshi}" ] … } -
在
data-remote模块的build.gradle文件中,确保存在以下插件:plugins { id 'com.android.library' id 'kotlin-android' id 'kotlin-kapt' id 'dagger.hilt.android.plugin' } -
在同一文件中,将配置更改为顶级
build.gradle文件中定义的配置:android { compileSdk defaultCompileSdkVersion defaultConfig { minSdk defaultMinSdkVersion targetSdk defaultTargetSdkVersion … } compileOptions { sourceCompatibility javaCompileVersion targetCompatibility javaCompileVersion } kotlinOptions { jvmTarget = jvmTarget } }
在这里,我们确保新的模块将使用与项目其余部分相同的配置,包括编译和最小/最大 Android 版本,以便更容易地在所有模块之间更改配置。
-
在同一文件中,添加对网络库和
data-repository以及domain模块的依赖项:dependencies { implementation(project(path: ":domain")) implementation(project(path: ":data-repository")) implementation coroutines.coroutinesAndroid implementation network.okHttp implementation network.retrofit implementation network.retrofitMoshi implementation network.moshi implementation network.moshiKotlin implementation di.hiltAndroid kapt di.hiltCompiler testImplementation test.junit testImplementation test.coroutines testImplementation test.mockito } -
在顶级
gradle.properties文件中,为moshi添加以下配置:android.jetifier.ignorelist=moshi-1.13.0 -
在
data-remote模块的AndroidManifest.xml文件中,添加互联网权限:<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.clean.data_remote"> <uses-permission android:name="android.permission.INTERNET" /> </manifest> -
在
data-remote模块中,创建一个名为networking的新包。 -
在
networking包中,创建一个名为user的新包。 -
在
user包中,创建一个名为UserApiModel的新类:data class UserApiModel( @Json(name = "id") val id: Long, @Json(name = "name") val name: String, @Json(name = "username") val username: String, @Json(name = "email") val email: String ) -
在同一包中,创建一个名为
UserService的新接口:interface UserService { @GET("/users") suspend fun getUsers(): List<UserApiModel> @GET("/users/{userId}") suspend fun getUser(@Path("userId") userId: Long): UserApiModel } -
在
networking包中,创建一个名为post的新包。 -
在
post包中,创建一个名为PostApiModel的新类:data class PostApiModel( @Json(name = "id") val id: Long, @Json(name = "userId") val userId: Long, @Json(name = "title") val title: String, @Json(name = "body") val body: String ) -
在同一包中,创建一个名为
PostService的新接口:interface PostService { @GET("/posts") suspend fun getPosts(): List<PostApiModel> @GET("/posts/{postId}") suspend fun getPost(@Path("postId") id: Long): PostApiModel } -
在
data-remote模块中,创建一个名为source的新包。 -
在
source包中,创建一个名为RemoteUserDataSourceImpl的新类:class RemoteUserDataSourceImpl @Inject constructor(private val userService: UserService) : RemoteUserDataSource { override fun getUsers(): Flow<List<User>> = flow { emit(userService.getUsers()) }.map { users -> users.map { userApiModel -> convert(userApiModel) } }.catch { throw UseCaseException.UserException(it) } override fun getUser(id: Long): Flow<User> = flow { emit(userService.getUser(id)) }.map { convert(it) }.catch { throw UseCaseException.UserException(it) } private fun convert(userApiModel: UserApiModel) = User(userApiModel.id, userApiModel.name, userApiModel.username, userApiModel.email) }
这里,我们调用 UserService 中的 getUsers 和 getUser 方法,然后将 UserApiModel 对象转换为 User 对象,以避免其他层依赖于与网络相关的数据。同样的原则也适用于错误处理。如果发生网络错误,例如 HTTP 404 状态码,异常将是 HttpException,它是 Retrofit 库的一部分。
-
在
source包中,创建一个名为RemotePostDataSourceImpl的新类:class RemotePostDataSourceImpl @Inject constructor(private val postService: PostService) : RemotePostDataSource { override fun getPosts(): Flow<List<Post>> = flow { emit(postService.getPosts()) }.map { posts -> posts.map { postApiModel -> convert(postApiModel) } }.catch { throw UseCaseException.PostException(it) } override fun getPost(id: Long): Flow<Post> = flow { emit(postService.getPost(id)) }.map { convert(it) }.catch { throw UseCaseException.PostException(it) } private fun convert(postApiModel: PostApiModel) = Post(postApiModel.id, postApiModel.userId, postApiModel.title, postApiModel.body) }
这里,我们遵循与 RemoteUserDataSourceImpl 类相同的原理。
-
在
data-remote模块中,创建一个名为injection的新包。 -
在
injection包中,创建一个名为NetworkModule的新类:@Module @InstallIn(SingletonComponent::class) class NetworkModule { @Provides fun provideOkHttpClient(): OkHttpClient = OkHttpClient .Builder() .readTimeout(15, TimeUnit.SECONDS) .connectTimeout(15, TimeUnit.SECONDS) .build() @Provides fun provideMoshi(): Moshi = Moshi.Builder().add (KotlinJsonAdapterFactory()).build() @Provides fun provideRetrofit(okHttpClient: OkHttpClient, moshi: Moshi): Retrofit = Retrofit.Builder() .baseUrl ("https://jsonplaceholder.typicode.com/") .client(okHttpClient) .addConverterFactory (MoshiConverterFactory.create(moshi)) .build() @Provides fun provideUserService(retrofit: Retrofit): UserService = retrofit.create(UserService::class.java) @Provides fun providePostService(retrofit: Retrofit): PostService = retrofit.create(PostService::class.java) }
这里,我们提供了网络所需的 Retrofit 和 OkHttp 依赖项。
-
在
injection包中,创建一个名为RemoteDataSourceModule的类:@Module @InstallIn(SingletonComponent::class) abstract class RemoteDataSourceModule { @Binds abstract fun bindPostDataSource(postDataSourceImpl: RemotePostDataSourceImpl): RemotePostDataSource @Binds abstract fun bindUserDataSource (userDataSourceImpl: RemoteUserDataSourceImpl): RemoteUserDataSource }
这里,我们使用 Hilt 将此模块中的实现与 data-repository 模块中定义的抽象绑定。
-
为了单元测试代码,我们现在需要在
data-remote模块的test文件夹中创建一个名为resources的新文件夹。 -
在
resources文件夹内,创建一个名为mockito-extensions的文件夹;在这个文件夹内,创建一个名为org.mockito.plugins.MockMaker的文件;并在该文件中添加以下文本 –mock-maker-inline。 -
创建一个名为
RemoteUserDataSourceImplTest的测试类,该类将测试RemoteUserDataSourceImpl内部方法的成功场景:class RemoteUserDataSourceImplTest { private val userService = mock<UserService>() private val userDataSource = RemoteUserDataSourceImpl(userService) @ExperimentalCoroutinesApi @Test fun testGetUsers() = runBlockingTest { val remoteUsers = listOf(UserApiModel(1, "name", "username", "email")) val expectedUsers = listOf(User(1, "name", "username", "email")) whenever(userService.getUsers()). thenReturn(remoteUsers) val result = userDataSource.getUsers().first() Assert.assertEquals(expectedUsers, result) } @ExperimentalCoroutinesApi @Test fun testGetUser() = runBlockingTest { val id = 1L val remoteUser = UserApiModel(id, "name", "username", "email") val user = User(id, "name", "username", "email") whenever(userService.getUser(id)) .thenReturn(remoteUser) val result = userDataSource.getUser(id). first() Assert.assertEquals(user, result) } }
这里,我们正在模拟 UserService 接口并提供模拟用户数据,然后这些数据将被 RemoteDataSourceImpl 获取并转换。
-
在相同的测试类中,添加错误场景:
class RemoteUserDataSourceImplTest { … @ExperimentalCoroutinesApi @Test fun testGetUsersThrowsError() = runBlockingTest { whenever(userService.getUsers()).thenThrow (RuntimeException()) userDataSource.getUsers().catch { Assert.assertTrue(it is UseCaseException. UserException) }.collect() } @ExperimentalCoroutinesApi @Test fun testGetUserThrowsError() = runBlockingTest { val id = 1L whenever(userService.getUser(id)).thenThrow (RuntimeException()) userDataSource.getUser(id).catch { Assert.assertTrue(it is UseCaseException. UserException) }.collect() } }
在这里,我们正在模拟 UserService 抛出的错误,然后由 RemoteUserDataSourceImpl 转换为 UseCaseException.UserException。
-
创建一个名为
RemotePostDataSourceImplTest的测试类,它将具有与RemoteUserDataSourceImplTest相似的测试方法,用于帖子:class RemotePostDataSourceImplTest { private val postService = mock<PostService>() private val postDataSource = RemotePostDataSourceImpl(postService) @ExperimentalCoroutinesApi @Test fun testGetPosts() = runBlockingTest { val remotePosts = listOf(PostApiModel(1, 1, "title", "body")) val expectedPosts = listOf(Post(1, 1, "title", "body")) whenever(postService.getPosts()).thenReturn (remotePosts) val result = postDataSource.getPosts().first() Assert.assertEquals(expectedPosts, result) } @ExperimentalCoroutinesApi @Test fun testGetPost() = runBlockingTest { val id = 1L val remotePost = PostApiModel(id, 1, "title", "body") val expectedPost = Post(id, 1, "title", "body") whenever(postService.getPost(id)).thenReturn (remotePost) val result = postDataSource.getPost(id). first() Assert.assertEquals(expectedPost, result) } }
在这里,我们正在对帖子做我们在 RemoteUserDataSourceImplTest 中对用户所做的事情。
-
在
RemotePostDataSourceImplTest中添加错误场景:class RemotePostDataSourceImplTest { … @ExperimentalCoroutinesApi @Test fun testGetPostsThrowsError() = runBlockingTest { whenever(postService.getPosts()).thenThrow (RuntimeException()) postDataSource.getPosts().catch { Assert.assertTrue(it is UseCaseException. PostException) }.collect() } @ExperimentalCoroutinesApi @Test fun testGetPostThrowsError() = runBlockingTest { val id = 1L whenever(postService.getPost(id)).thenThrow (RuntimeException()) postDataSource.getPost(id).catch { Assert.assertTrue(it is UseCaseException. PostException) }.collect() } }
如果我们运行测试,我们应该看到如下图所示的内容:

图 7.2 – 远程数据源单元测试输出
在这个练习中,我们向应用程序添加了一个新模块,我们可以看到如何将远程数据源添加到应用程序中。为了获取数据,我们使用 OkHttp 和 Retrofit 等库,并将它们与用于获取用户和帖子的数据源实现相结合。在下一节中,我们将扩展应用程序以介绍本地数据源,我们将在这里持久化我们获取的数据。
构建和集成本地数据源
在本节中,我们将分析如何构建本地数据源并将它们与 Room 和 Data Store 等库集成。
本地数据源的结构与远程数据源类似。抽象由上层提供,实现负责调用持久化框架的方法并将数据转换为实体,如下面的图所示:

图 7.3 – 本地数据源图
假设我们在前面的章节中定义了相同的 UserEntity:
data class User(
val id: String,
val firstName: String,
val lastName: String,
val email: String
) {
fun getFullName() = "$firstName $lastName"
}
让我们对 UserLocalDataSource 做相同的假设:
interface UserLocalDataSource {
suspend fun insertUser(user: User)
fun getUser(id: String): Flow<User>
}
我们现在需要提供一个实现,该实现将操作来自 Room 的数据。首先,我们需要为 Room 定义一个用户实体:
@Entity(tableName = "user")
data class UserEntity(
@PrimaryKey @ColumnInfo(name = "id") val id: String,
@ColumnInfo(name = "first_name") val firstName: String,
@ColumnInfo(name = "last_name") val lastName: String,
@ColumnInfo(name = "email") val email: String
)
现在,我们可以定义 UserDao,它通过 ID 查询用户并插入用户:
@Dao
interface UserDao {
@Query("SELECT * FROM user where id = :id")
fun getUser(id: String): Flow<UserEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertUser(users: UserEntity)
}
最后,数据源的实施看起来像这样:
class UserLocalDataSourceImpl(private val userDao: UserDao) : UserLocalDataSource {
override suspend fun insertUser(user: User) {
userDao.insertUser(UserEntity(user.id,
user.firstName, user.lastName, user.email))
}
override fun getUser(id: String): Flow<User> {
return userDao.getUser(id).map {
User(it.id, it.firstName, it.lastName,
it.email)
}
}
}
在这里,本地数据源调用 UserDao 来插入和检索用户,并将域实体转换为 Room 实体。
如果我们想使用 Data Store 而不是 Room 与本地数据存储实现,我们可以有如下示例:
private val KEY_ID = stringPreferencesKey("key_id")
private val KEY_FIRST_NAME =
stringPreferencesKey("key_first_name")
private val KEY_LAST_NAME =
stringPreferencesKey("key_last_name")
private val KEY_EMAIL = stringPreferencesKey("key_email")
class UserLocalDataSourceImpl(private val dataStore:
DataStore<Preferences>) : UserLocalDataSource {
override suspend fun insertUser(user: User) {
dataStore.edit {
it[KEY_ID] = user.id
it[KEY_FIRST_NAME] = user.firstName
it[KEY_LAST_NAME] = user.lastName
it[KEY_EMAIL] = user.email
}
}
override fun getUser(id: String): Flow<User> {
return dataStore.data.map {
User(
it[KEY_ID].orEmpty(),
it[KEY_FIRST_NAME].orEmpty(),
it[KEY_LAST_NAME].orEmpty(),
it[KEY_EMAIL].orEmpty()
)
}
}
}
在这里,我们使用一个键来存储 User 对象的每个字段的数据。getUser 方法不使用 ID 来搜索用户,这表明对于这个特定的用例,Room 是更合适的方法。
在本节中,我们探讨了如何使用 Room 和 Data Store 库构建本地数据源,以便能够在设备上本地查询和持久化数据。接下来,我们将查看一个练习,展示我们如何实现本地数据存储。
练习 07.02 – 构建本地数据源
修改练习 07.01 – 构建远程数据源,以便创建一个名为data-local的新 Android 库模块。此模块将依赖于domain和data-repository。
该模块将实现以下功能:
-
UserEntity和PostEntity,将保存从User和Post持久化的数据 -
UserDao和PostDao,将负责持久化和检索UserEntity和PostEntity列表 -
LocalUserDataSourceImpl和LocalPostDataSourceImpl,将负责调用UserDao和PostDao对象以持久化数据,并将数据转换为User和Post对象 -
LocalInteractionDataSourceImpl,将负责持久化Interaction对象
要完成此练习,您需要执行以下操作:
-
创建
data-local模块。 -
创建
UserEntity和PostEntity类。 -
为用户和帖子创建 DAO。
-
创建数据源实现。
按照以下步骤完成练习:
-
创建一个名为
data-local的新模块,它将是一个 Android 库模块。 -
确保在顶级
build.gradle文件中,以下依赖项已设置:buildscript { … dependencies { classpath gradlePlugins.android classpath gradlePlugins.kotlin classpath gradlePlugins.hilt } } -
在同一文件中,将持久化库添加到库映射中:
ext { … versions = [ … room : "2.4.0", datastore : "1.0.0", … ] … persistence = [ roomRuntime : "androidx.room:room- runtime:${versions.room}", roomKtx : "androidx.room:room- ktx:${versions.room}", roomCompiler: "androidx.room:room- compiler:${versions.room}", datastore : "androidx.datastore: datastore-preferences:$ {versions.datastore}" ] … } -
在
data-local模块的build.gradle文件中,确保存在以下插件:plugins { id 'com.android.library' id 'kotlin-android' id 'kotlin-kapt' id 'dagger.hilt.android.plugin' } -
在同一文件中,将配置更改为顶级
build.gradle文件中定义的配置:android { compileSdk defaultCompileSdkVersion defaultConfig { minSdk defaultMinSdkVersion targetSdk defaultTargetSdkVersion … } compileOptions { sourceCompatibility javaCompileVersion targetCompatibility javaCompileVersion } kotlinOptions { jvmTarget = jvmTarget } } -
在同一文件中,添加网络库和
data-repository以及domain模块的依赖项:dependencies { implementation(project(path: ":domain")) implementation(project(path: ":data-repository")) implementation coroutines.coroutinesAndroid implementation persistence.roomRuntime implementation persistence.roomKtx kapt persistence.roomCompiler implementation persistence.datastore implementation di.hiltAndroid kapt di.hiltCompiler testImplementation test.junit testImplementation test.coroutines testImplementation test.mockito } -
在
data-local模块中,创建一个名为db的新包。 -
在
db包中,创建一个名为user的新包。 -
在
user包中,创建UserEntity类:@Entity(tableName = "user") data class UserEntity( @PrimaryKey @ColumnInfo(name = "id") val id: Long, @ColumnInfo(name = "name") val name: String, @ColumnInfo(name = "username") val username: String, @ColumnInfo(name = "email") val email: String ) -
在同一包中,创建
UserDao接口:@Dao interface UserDao { @Query("SELECT * FROM user") fun getUsers(): Flow<List<UserEntity>> @Insert(onConflict = OnConflictStrategy.REPLACE) fun insertUsers(users: List<UserEntity>) } -
在
db包中,创建一个名为post的新包。 -
在
post包中,创建一个名为PostEntity的新类:@Entity(tableName = "post") data class PostEntity( @PrimaryKey @ColumnInfo(name = "id") val id: Long, @ColumnInfo(name = "userId") val userId: Long, @ColumnInfo(name = "title") val title: String, @ColumnInfo(name = "body") val body: String ) -
在同一包中,创建一个名为
PostDao的新接口:@Dao interface PostDao { @Query("SELECT * FROM post") fun getPosts(): Flow<List<PostEntity>> @Insert(onConflict = OnConflictStrategy.REPLACE) fun insertPosts(users: List<PostEntity>) } -
在
db包中,创建AppDatabase类:@Database(entities = [UserEntity::class, PostEntity::class], version = 1) abstract class AppDatabase : RoomDatabase() { abstract fun userDao(): UserDao abstract fun postDao(): PostDao } -
在
data-local模块中,创建一个名为source的新包。 -
在
source包中,创建一个名为LocalUserDataSourceImpl的新类:class LocalUserDataSourceImpl @Inject constructor(private val userDao: UserDao) : LocalUserDataSource { override fun getUsers(): Flow<List<User>> = userDao.getUsers().map { users -> users.map { User(it.id, it.name, it.username, it.email) } } override suspend fun addUsers(users: List<User>) = userDao.insertUsers(users.map { UserEntity(it.id, it.name, it.username, it.email) }) }
在这里,在getUsers方法中,我们从UserDao检索UserEntity对象列表并将它们转换为User对象。在addUsers方法中,我们执行相反的操作,将待插入的User对象列表转换为UserEntity对象。
-
在同一包中,创建
LocalPostDataSourceImpl类:class LocalPostDataSourceImpl @Inject constructor(private val postDao: PostDao) : LocalPostDataSource { override fun getPosts(): Flow<List<Post>> = postDao.getPosts().map { posts -> posts.map { Post(it.id, it.userId, it.title, it.body) } } override suspend fun addPosts(posts: List<Post>) = postDao.insertPosts(posts.map { PostEntity(it.id, it.userId, it.title, it.body) }) }
这里,我们遵循与LocalUserDataSourceImpl相同的做法。
-
在同一包中,创建
LocalInteractionDataSourceImpl类:internal val KEY_TOTAL_TAPS = intPreferencesKey("key_total_taps") class LocalInteractionDataSourceImpl @Inject constructor(private val dataStore: DataStore<Preferences>) : LocalInteractionDataSource { override fun getInteraction(): Flow<Interaction> { return dataStore.data.map { Interaction(it[KEY_TOTAL_TAPS] ?: 0) } } override suspend fun saveInteraction(interaction: Interaction) { dataStore.edit { it[KEY_TOTAL_TAPS] = interaction.totalClicks } } }
在这里,我们使用偏好数据存储库来持久化交互对象,通过为Interaction类中的每个字段保留不同的键,在这种情况下,将只有一个键用于总点击数。
-
在
data-local模块中,创建一个名为injection的新包。 -
在
injection包中,创建一个名为PersistenceModule的新类:val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "my_preferences") @Module @InstallIn(SingletonComponent::class) class PersistenceModule { @Provides fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase = Room.databaseBuilder( context, AppDatabase::class.java, "my-database" ).build() @Provides fun provideUserDao(appDatabase: AppDatabase): UserDao = appDatabase.userDao() @Provides fun providePostDao(appDatabase: AppDatabase): PostDao = appDatabase.postDao() @Provides fun provideLocalInteractionDataSourceImpl (@ApplicationContext context: Context) = LocalInteractionDataSourceImpl(context.dataStore) }
在这里,我们提供了所有 Data Store 和 Room 依赖项。
-
在同一个包中,创建一个名为
LocalDataSourceModule的新类,在其中我们将抽象与绑定连接起来:@Module @InstallIn(SingletonComponent::class) abstract class LocalDataSourceModule { @Binds abstract fun bindPostDataSource (lostDataSourceImpl: LocalPostDataSourceImpl): LocalPostDataSource @Binds abstract fun bindUserDataSource (userDataSourceImpl: LocalUserDataSourceImpl): LocalUserDataSource @Binds abstract fun bindInteractionDataStore (interactionDataStore:LocalInteractionData SourceImpl): LocalInteractionDataSource } -
为了单元测试代码,我们现在需要在
data-local模块的测试文件夹中创建一个名为resources的新文件夹。 -
在
resources文件夹中,创建一个名为mockito-extensions的文件夹;在这个文件夹中,创建一个名为org.mockito.plugins.MockMaker的文件;并在该文件中添加以下文本——mock-maker-inline。 -
创建
LocalUserDataSourceImplTest测试类:class LocalUserDataSourceImplTest { private val userDao = mock<UserDao>() private val userDataSource = LocalUserDataSourceImpl(userDao) @ExperimentalCoroutinesApi @Test fun testGetUsers() = runBlockingTest { val localUsers = listOf(UserEntity(1, "name", "username", "email")) val expectedUsers = listOf(User(1, "name", "username", "email")) whenever(userDao.getUsers()).thenReturn (flowOf(localUsers)) val result = userDataSource.getUsers().first() Assert.assertEquals(expectedUsers, result) } @ExperimentalCoroutinesApi @Test fun testAddUsers() = runBlockingTest { val localUsers = listOf(UserEntity(1, "name", "username", "email")) val users = listOf(User(1, "name", "username", "email")) userDataSource.addUsers(users) verify(userDao).insertUsers(localUsers) } }
在这里,我们模拟了UserDao类,并使用它为LocalUserDataSourceImpl提供模拟数据,然后将其转换为User对象。
-
创建
LocalPostDataSourceImplTest测试类:class LocalPostDataSourceImplTest { private val postDao = mock<PostDao>() private val postDataSource = LocalPostDataSourceImpl(postDao) @ExperimentalCoroutinesApi @Test fun testGetPosts() = runBlockingTest { val localPosts = listOf(PostEntity(1, 1, "title", "body")) val expectedPosts = listOf(Post(1, 1, "title", "body")) whenever(postDao.getPosts()).thenReturn (flowOf(localPosts)) val result = postDataSource.getPosts().first() Assert.assertEquals(expectedPosts, result) } @ExperimentalCoroutinesApi @Test fun testAddUsers() = runBlockingTest { val localPosts = listOf(PostEntity(1, 1, "title", "body")) val posts = listOf(Post(1, 1, "title", "body")) postDataSource.addPosts(posts) verify(postDao).insertPosts(localPosts) } }
在这里,我们为帖子执行与在LocalUserDataSourceImplTest中对用户执行的相同类型的测试。
-
创建
LocalInteractionDataSourceImplTest测试类:class LocalInteractionDataSourceImplTest { private val dataStore = mock<DataStore <Preferences>>() private val interactionDataSource = LocalInteractionDataSourceImpl(dataStore) @ExperimentalCoroutinesApi @Test fun testGetInteraction() = runBlockingTest { val clicks = 10 val interaction = Interaction(clicks) val preferences = mock<Preferences>() whenever(preferences[KEY_TOTAL_TAPS]). thenReturn(clicks) whenever(dataStore.data).thenReturn (flowOf(preferences)) val result = interactionDataSource. getInteraction().first() assertEquals(interaction, result) } @ExperimentalCoroutinesApi @Test fun testSaveInteraction() = runBlockingTest { val clicks = 10 val interaction = Interaction(clicks) val preferences = mock<MutablePreferences>() whenever(preferences.toMutablePreferences()) .thenReturn(preferences) whenever(dataStore.updateData(any())). thenAnswer { runBlocking { it.getArgument<suspend (Preferences) - > Preferences>(0).invoke(preferences) } preferences } interactionDataSource.saveInteraction(interaction) verify(preferences)[KEY_TOTAL_TAPS] = clicks } }
在这里,在testSaveInteraction方法中,我们需要模拟updateData方法而不是DataStore类的edit方法。这是因为edit方法是一个扩展函数,我们无法使用当前库进行模拟,而必须依赖于它调用的方法,即updateData。
如果我们运行测试,我们应该看到如下所示的图:

图 7.4 – 本地数据源单元测试输出
如果我们在练习中绘制模块图,我们会看到如下所示的图:

图 7.5 – 练习 07.02 模块图
我们可以看到:data-remote和:data-local模块彼此隔离。这两个模块有不同的职责和处理不同的依赖。:data-remote处理从互联网获取数据,而:data-local处理使用 Room 将数据持久化到 SQLite,并使用 Data Store 处理文件。这使我们的代码更具灵活性,因为我们能够改变我们获取数据的方式——例如,不会影响我们持久化数据的方式。
在这个练习中,我们在应用程序中创建了一个新的模块,其中我们处理本地数据源。为了持久化数据,我们使用了 Room 和 Data Store 等库,并将它们与本地数据存储集成。
摘要
在本章中,我们探讨了数据源的概念以及我们在 Android 应用程序中可用的不同类型的数据源。我们从远程数据源开始,看到了一些如何构建数据源并将其与 Retrofit 和 OkHttp 等库结合的例子。本地数据源与远程数据源遵循类似的原则,在这里,我们使用了 Room 和 Data Store 来实现这一点。
在练习中,我们将数据源作为不同模块的一部分进行了实现。这样做是为了避免在应用程序的其他层与我们所使用的特定数据源框架之间创建任何不必要的依赖。在下一章中,我们将探讨如何构建表示层并向用户展示数据。我们还将探索如何将表示层拆分为独立的模块,并通过引入可以被其他表示模块共享的模块,从一个模块的屏幕导航到另一个模块的屏幕。
第三部分 – 展示层
本部分将介绍展示层以及可以应用于实现解耦和可测试代码库的模式。
本部分包括以下章节:
-
第八章,实现 MVVM 架构
-
第九章,实现 MVI 架构
-
第十章,整合一切
第八章:第八章: 实现 MVVM 架构
在本章中,我们将探讨 Android 应用程序如何向最终用户展示数据。我们将回顾可用于数据展示的架构模式,并分析它们之间的差异。稍后,我们将探讨模型-视图-视图模型(MVVM)模式,它在分离业务逻辑和用户界面更新中所起的作用,以及我们如何使用Android 架构组件来实现它。最后,我们将探讨如何将表示层拆分到多个库模块中。在本章的练习中,我们将整合前几章中构建的层与使用 MVVM 构建的表示层,我们将创建一个将插入到领域层以获取和更新数据的表示层,并且我们还将探讨如何在表示层的不同模块之间处理常见的逻辑。
在本章中,我们将涵盖以下主题:
-
在 Android 应用程序中展示数据
-
使用 MVVM 展示数据
-
在多个模块中展示数据
到本章结束时,你将能够使用 ViewModel 架构组件在 Android 应用程序中实现 MVVM 架构模式,并且能够将表示层拆分为独立的库模块。
技术要求
本章有以下硬件和软件要求:
- Android Studio Arctic Fox 2020.3.1 Patch 3
本章的代码文件可以在以下位置找到:github.com/PacktPublishing/Clean-Android-Architecture/tree/main/Chapter8。
查看以下视频以查看代码的实际运行情况:bit.ly/3FZJWIl
在 Android 应用程序中展示数据
在本节中,我们将探讨适用于在 Android 应用程序中展示数据的各种架构模式,并分析它们的优缺点。
早期的 Android 应用程序依赖于类似于android.widget.View层次结构的模式,模型负责管理应用程序的数据。组件之间的关系看起来可能如下:

图 8.1 – Android MVC 关系图
从图 8.1中,我们可以看到由活动表示的控制器会与模型交互以获取和操作数据,然后它会用相关信息更新视图。
理念是将每个Activity尽可能沙盒化,以便它们可以在多个应用程序之间提供和共享(就像相机应用程序被其他应用程序打开来拍照并将这些照片提供给那些应用程序一样)。正因为如此,活动需要通过意图启动,而不是通过实例化它们。通过移除直接实例化Activity的能力,我们失去了通过构造函数注入依赖的能力。另一个我们需要考虑的因素是活动具有生命周期状态,我们在应用程序中的每个Activity中继承这些状态。所有这些因素加在一起使得Activity非常难以测试,或者几乎不可能进行单元测试,除非我们使用像Robolectric这样的库,或者依赖于 Android 设备或模拟器的仪器化测试。这两种选项都速度较慢,在需要在使用测试云(如Firebase Test Lab)运行测试的情况下,仪器化测试可能成本较高。
为了解决存在于活动中的单元测试逻辑问题,以及后来片段中的问题,对Activity的各种适应以及android.widget.View层次结构变为 View,Presenter负责从模型中获取数据并执行所需的逻辑,更新View,而模型具有与 MVC 相同的责任来处理应用程序的数据。这些组件之间的关系如下所示:


图 8.2 – MVP 关系
组件之间关系的有趣方面是Presenter和View之间的双向关系。Presenter将更新View的相关数据,但View也会在必要时调用Presenter以进行用户交互。由于这两个组件之间的关系,需要定义一个契约,如下所示:
interface Presenter {
fun loadUsers()
fun validateInput(text: String)
}
interface View {
fun showUsers(users: List<User>)
fun showInputError(error: String)
}
在这里,我们有一个View接口和一个Presenter接口。Presenter的实现可能看起来像这样:
class PresenterImpl(
private val view: View,
private val getUsersUseCase: GetUsersUseCase
) : Presenter {
private val scope = CoroutineScope(Dispatchers.Main)
override fun loadUsers() {
scope.launch {
getUsersUseCase.execute()
.collect { users ->
view.showUsers(users)
}
}
}
override fun validateInput(text: String) {
if (text.isEmpty()) {
view.showInputError("Invalid input")
}
}
}
在这里,PresenterImpl类依赖于View类以及一个GetUsersUseCase对象,该对象将返回一个包含用户列表的Flow对象。当Presenter接收到用户列表时,它将调用View中的showUsers方法。当调用validateInput方法时,Presenter将检查文本是否为空,并使用错误信息调用View中的showInputError方法。View的实现可能如下所示:
class MainActivity : ComponentActivity(), View {
@Inject
private lateinit var presenter: Presenter
private lateinit var usersAdapter: UsersAdapter
private lateinit var editText: EditText
private lateinit var errorView: TextView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
…
editText.addTextChangedListener(object :
TextWatcher {
…
override fun afterTextChanged(s: Editable?) {
presenter.validateInput(s?.toString().orEmpty())
}
})
presenter.loadUsers()
}
override fun showUsers(users: List<User>) {
usersAdapter.add(users)
}
override fun showInputError(error: String) {
errorView.text = error
}
}
在这里,我们在 MainActivity 中实现了 View 接口;在方法的实现中,我们调用适当的 View- 相关类来显示相关数据,例如在 TextView 对象中显示无效输入的错误消息,以及在 RecyclerView.Adapter 对象中设置数据。为了验证输入,当 EditText 对象中的文本发生变化时,它将调用 Presenter 来验证新的文本。Presenter 依赖项将通过某种形式的依赖注入来实现。
因为演示者最终将执行后台操作,我们面临造成 Context 泄漏的风险。这意味着我们需要将 Activity 的生命周期考虑进 MVP 协议中。为了实现这一点,我们将在 Presenter 中定义一个 close 方法:
interface Presenter {
…
fun close()
}
在前面的代码片段中,我们添加了 close 方法,该方法将在 Activity 的 onDestroy 方法中被调用,如下所示:
override fun onDestroy() {
presenter.close()
super.onDestroy()
}
close 方法的实现必须清理所有可能引起泄漏的资源:
class PresenterImpl(
private val view: View,
private val getUsersUseCase: GetUsersUseCase
) : Presenter {
private val scope = CoroutineScope(Dispatchers.Main)
…
override fun close() {
scope.cancel()
}
}
在这里,我们正在取消对 Flow 对象的订阅,以确保在 Activity 被销毁后不会收到任何更新。
在本节中,我们回顾了在 Android 应用程序中使用过的先前架构模式,从早期 Android 应用程序中使用的类似 MVC 的方法到 MVP,旨在解决最初方法的一些问题。尽管 MVP 在过去很受欢迎,并且仍然存在于一些 Android 应用程序中,但它已经被逐渐淘汰,主要是因为 Android 架构组件的发布,这些组件依赖于 MVVM 模式,以及 Jetpack Compose,它更适合与数据流一起工作,而数据流更适合 MVVM。在接下来的部分中,我们将探讨 MVVM 架构模式以及它与 MVP 作为概念的不同之处。
使用 MVVM 展示数据
在本节中,我们将分析 模型-视图-视图模型(Model-View-ViewModel) 架构模式及其在 Android 应用程序中的实现方式。
MVVM 代表了一种对 Humble Object 模式的不同方法,它试图将逻辑从活动和片段中提取出来。在 MVVM 中,视图由活动和片段表示,就像在 MVP 中一样,模型扮演相同的角色,管理数据,而视图模型则位于两者之间,当视图需要数据时,从模型请求数据。三者之间的关系如下:

图 8.3 – MVVM 关系
在图 8.3中,我们看到三个组件之间存在单向关系。视图依赖于 ViewModel,而 ViewModel 依赖于模型。这提供了更多的灵活性,因为多个视图可以使用相同的 ViewModel。为了在视图中更新数据,MVVM 需要一个观察者模式的实现。这意味着 ViewModel 使用一个可观察的对象,视图将订阅并响应数据的变化。
要开发 Android 应用程序,我们有使用 Android Architecture Components 库的可能性,这些库提供了一个ViewModel类,该类解决了活动和片段生命周期的问题,并结合了用于订阅流或协程的协程扩展,当活动和片段处于数据显示无效状态时停止数据发射,以避免上下文泄露。
从LiveData的角度来看(它充当视图可以订阅的可观察对象)。一个ViewModel类的示例可能如下所示:
class MyViewModel(
private val getUsersUseCase: GetUsersUserUseCase
) : ViewModel() {
private val _usersFlow =
MutableStateFlow<List<UiUser>>(listOf<UiUser>())
val usersFlow: StateFlow<List<UiUser>> = _usersFlow
fun load() {
viewModelScope.launch {
getUsersUseCase.execute()
.map {
// Convert List<User> to List<UiUser>
}
.collect {
_usersFlow.value = it
}
}
}
}
在这里,我们加载一个User对象的列表,并将其保存在一个StateFlow对象中。这个StateFlow对象取代了LiveData,代表视图将订阅的可观察对象。当视图需要用户列表时,它将调用load方法。
在本节中,我们分析了 MVVM 架构模式和它与 MVP 模式之间的区别。在下一节中,我们将探讨如何在 Android 应用程序中使用 MVVM 来展示数据。
练习 08.01 – 实现 MVVM
修改第七章中的练习 7.02,构建本地数据源,以便创建一个新的模块presentation-posts。该模块将负责使用 MVVM 显示GetPostsWithUsersWithInteractionUseCase的数据。数据将以以下格式显示:
-
一个包含以下文本的标题:"总点击次数:x",其中 x 是从
Interaction类中的totalClicks字段获取的点击次数 -
一系列帖子,其中每一行包含以下内容:"作者:x"和"标题:y",其中 x 是
User类中的name字段,y 是Post类中的title字段 -
当数据正在加载时的加载视图
-
当出现错误时的
Snackbar视图
要完成这个练习,你需要做以下事情:
-
创建
presentation-post模块。 -
创建一个新的密封类
UiState,它将具有子类Loading、Error(将包含错误消息)和Success(将包含帖子数据)。 -
创建一个名为
PostListItemModel的新类,该类将包含id、author和name作为字段。 -
创建一个名为
PostListModel的新类,该类将包含一个headerText字段和一个PostListItemModel对象的列表。 -
创建一个名为
PostListConverter的新类,该类将Result.Success对象转换为UiState.Success,它包含PostListModel对象,并将Result.Error对象转换为UiState.Error对象。 -
创建一个名为
PostListViewModel的新类,该类将从GetPostsWithUsersWithInteractionUseCase加载数据,使用PostListConverter转换数据,并将UiState存储在StateFlow中。 -
创建一个新的 Kotlin 文件,其中将包含负责绘制 UI 的
@Composable方法。 -
修改
app模块中的MainActivity,使其显示帖子列表。
按照以下步骤完成练习:
-
创建一个名为
presentation-post的新模块,它将是一个 Android 库模块。 -
确保在顶级
build.gradle文件中设置了以下依赖项:buildscript { … dependencies { classpath gradlePlugins.android classpath gradlePlugins.kotlin classpath gradlePlugins.hilt } } -
在同一文件中,将持久化库添加到库映射中:
buildscript { ext { … versions = [ … viewModel : "2.4.0", navigationCompose : "2.4.0-rc01", hiltNavigationCompose: "1.0.0-rc01", … ] … androidx = [ … viewModelKtx : "androidx. lifecycle:lifecycle-viewmodel- ktx:${versions.viewModel}", viewModelCompose : "androidx. lifecycle:lifecycle-viewmodel- compose:${versions.viewModel}", navigationCompose : "androidx. navigation:navigation-compose:$ {versions.navigationCompose}", hiltNavigationCompose : "androidx. hilt:hilt-navigation-compose:$ {versions.hiltNavigationCompose}" ] … } … }
在这里,我们添加了 ViewModel 库以及导航库(将在后续练习中使用)的依赖项。
-
在
presentation-post模块的build.gradle文件中,确保存在以下插件:plugins { id 'com.android.library' id 'kotlin-android' id 'kotlin-kapt' id 'dagger.hilt.android.plugin' } -
在同一文件中,将配置更改为顶级
build.gradle文件中定义的配置:android { compileSdk defaultCompileSdkVersion defaultConfig { minSdk defaultMinSdkVersion targetSdk defaultTargetSdkVersion … } … compileOptions { sourceCompatibility javaCompileVersion targetCompatibility javaCompileVersion } kotlinOptions { jvmTarget = jvmTarget useIR = true } buildFeatures { compose true } composeOptions { kotlinCompilerExtensionVersion versions. compose } }
在这里,我们保持与应用程序中其他模块相同的配置一致,并集成了 Jetpack Compose 配置。
-
在同一文件中,添加网络库和领域模块的依赖项:
dependencies { implementation(project(path: ":domain")) implementation coroutines.coroutinesAndroid implementation androidx.composeUi implementation androidx.composeMaterial implementation androidx.viewModelKtx implementation androidx.viewModelCompose implementation androidx.lifecycleRuntimeKtx implementation androidx.navigationCompose implementation di.hiltAndroid kapt di.hiltCompiler testImplementation test.junit testImplementation test.coroutines testImplementation test.mockito } -
在
presentation-post模块中创建一个名为list的包。 -
在
list包中创建UiState类:sealed class UiState<T : Any> { object Loading : UiState<Nothing>() data class Error<T : Any>(val errorMessage: String) : UiState<T>() data class Success<T : Any>(val data: T) : UiState<T>() } -
在同一包中,创建一个名为
PostListModels的文件。 -
在
PostListModels文件中,创建PostListItemModel类:data class PostListItemModel( val id: Long, val userId: Long, val authorName: String, val title: String ) -
在同一文件中,创建
PostListModel类:data class PostListModel( val headerText: String = "", val items: List<PostListItemModel> = listOf() ) -
在
presentation-post模块的src/main文件夹中,创建一个名为res的文件夹。 -
在
res文件夹中,创建一个名为values的新文件夹。 -
在
values文件夹中,创建一个名为strings.xml的文件。 -
在
strings.xml文件中,添加以下字符串:<?xml version="1.0" encoding="utf-8"?> <resources> <string name="total_click_count">Total click count: %d</string> <string name="author">Author: %s</string> <string name="title">Title: %s</string> </resources> -
在
list包中,创建PostListConverter类:class PostListConverter @Inject constructor(@ApplicationContext private val context: Context) { fun convert(postListResult: Result <GetPostsWithUsersWithInteractionUseCase. Response>): UiState<PostListModel> { return when (postListResult) { is Result.Error -> { UiState.Error(postListResult. exception.localizedMessage.orEmpty()) } is Result.Success -> { UiState.Success(PostListModel( headerText = context.getString( R.string.total_click_count, postListResult.data. interaction.totalClicks ), items = postListResult.data. posts.map { PostListItemModel( it.post.id, it.user.id, context.getString(R.string.author, it.user.name), context.getString(R.string.title, it.post.title) ) } )) } } } }
在这里,我们将Result.Success和Result.Error对象转换为等效的UiState对象,这些对象将用于向用户显示信息。
-
在
list包中,创建PostListViewModel类:@HiltViewModel class PostListViewModel @Inject constructor( private val useCase: GetPostsWithUsersWithInteractionUseCase, private val converter: PostListConverter ) : ViewModel() { private val _postListFlow = MutableStateFlow<UiState <PostListModel>>(UiState.Loading) val postListFlow: StateFlow<UiState<PostListModel>> = _postListFlow fun loadPosts() { viewModelScope.launch { useCase.execute (GetPostsWithUsersWithInteractionUseCase .Request) .map { converter.convert(it) } .collect { _postListFlow.value = it } } } }
在这里,我们从GetPostsWithUsersInteractionUseCase对象获取帖子列表和用户列表,然后将其转换为UiState对象,最后使用UiState对象更新StateFlow。
-
在
list包中,创建一个名为PostListScreen的文件。 -
在
PostListScreen文件中,添加一个用于显示加载小部件和Snackbar方法的方法:@Composable fun Error(errorMessage: String) { Column( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Bottom ) { Snackbar { Text(text = errorMessage) } } } @Composable fun Loading() { Column( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { CircularProgressIndicator() } } -
在同一文件中,添加一个用于显示帖子列表和标题的方法:
@Composable fun PostList( postListModel: PostListModel ) { LazyColumn(modifier = Modifier.padding(16.dp)) { item(postListModel.headerText) { Column(modifier = Modifier.padding(16.dp)) { Text(text = postListModel.headerText) } } items(postListModel.items) { item -> Column( modifier = Modifier .padding(16.dp) ) { Text(text = item.authorName) Text(text = item.title) } } } } -
在同一文件中,添加一个方法来监控
postListFlow的值,并根据状态值调用前面三个方法之一:@Composable fun PostListScreen( viewModel: PostListViewModel ) { viewModel.loadPosts() viewModel.postListFlow.collectAsState().value.let { state -> when (state) { is UiState.Loading -> { Loading() } is UiState.Error -> { Error(state.errorMessage) } is UiState.Success -> { PostList(state.data) } } } } -
在
app模块的build.gradle文件中,确保添加以下插件:plugins { id 'com.android.application' id 'kotlin-android' id 'kotlin-kapt' id 'dagger.hilt.android.plugin' } -
在同一文件中,确保添加以下依赖项:
dependencies { implementation(project(path: ":presentation- post")) implementation(project(path: ":domain")) implementation(project(path: ":data-remote")) implementation(project(path: ":data-local")) implementation(project(path: ":data-repository")) implementation androidx.core implementation androidx.appCompat implementation material.material implementation androidx.composeUi implementation androidx.composeMaterial implementation androidx.composeUiToolingPreview implementation androidx.lifecycleRuntimeKtx implementation androidx.composeActivity implementation androidx.navigationCompose implementation androidx.hiltNavigationCompose implementation di.hiltAndroid kapt di.hiltCompiler testImplementation test.junit } -
在
app模块中,创建一个名为injection的包。 -
在
injection包中,创建一个名为AppModule的类:@Module @InstallIn(SingletonComponent::class) class AppModule { @Provides fun provideUseCaseConfiguration() = UseCase.Configuration(Dispatchers.IO) }
在这里,我们提供了一个 UseCase.Configuration 依赖项,它将被注入到所有的 UseCase 子类中。
-
在
app模块中,创建一个名为PostApplication的类:@HiltAndroidApp class PostApplication : Application() -
将
PostApplication类添加到app模块的AndroidManifest.xml文件中:<application … android:name=".PostApplication" … > -
修改
MainActivity类,使其使用导航库从presentation-post模块导航到PostListScreen功能:@AndroidEntryPoint class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { CleanAppTheme { Surface(color = MaterialTheme. colors.background) { val navController = rememberNavController() App(navController = navController) } } } } } @Composable fun App(navController: NavHostController) { NavHost(navController, startDestination = "/posts") { composable(route = "/posts") { PostListScreen(hiltViewModel()) } } }
如果我们运行应用程序,我们应该看到以下屏幕:

图 8.4 – 练习 08.01 的输出
我们可以看到每篇帖子的标题列表和作者姓名。目前总点击次数为 0,因为我们还没有连接任何逻辑,并且尚未修改该值。我们将在后续的练习中添加该逻辑。如果在加载此列表时发生错误,则将显示一个包含 Exception 对象描述的 snackbar,而在数据加载时,将显示一个不确定的进度条。
在本节中,我们使用 MVVM 架构模式实现了 Android 应用程序的表示层,并将其连接到应用程序的领域层以向用户显示数据。在下一节中,我们将扩展这个层到多个模块,并查看如何在不同的模块之间导航屏幕。
在多个模块中呈现数据
在本节中,我们将探讨如何将表示层分割成多个模块,如何处理这些模块之间的交互,以及它们如何共享相同的数据。
在开发 Android 应用程序时,我们可以将屏幕分组到不同的模块中。例如,我们可以将登录或注册流程分组到一个名为 authentication 的库模块中,或者如果我们有一个设置部分,我们可以将这些屏幕分组到一个单独的模块中。有时这些屏幕将与应用程序的其余部分有共同点,例如使用相同的加载进度条或相同的错误机制。其他时候,这些屏幕必须从其他模块导航到屏幕。我们现在需要问的问题是,如何在两个模块或其他同一级别的模块之间不创建依赖关系的情况下实现这一点。对这些模块有直接的依赖关系将会有创建循环依赖的风险,如下所示:

图 8.5 – 模块循环依赖
在 图 8.5 中,我们展示了如果我们想从 :auth 模块导航到 :settings 模块,反之亦然,可能会发生什么。这目前是不可能的,因为这两个模块之间存在循环依赖。为了解决这个问题,我们需要创建一个新的模块。这个模块将持有两个模块之间共享的常见逻辑和常见数据。这看起来如下所示:
![Figure 8.6 – 常见展示模块
![img/Figure_8.06_B18320.jpg]
图 8.6 – 常见展示模块
在 图 8.6 中,我们添加了 :common 模块,该模块将包含可重用的视图或 @Composable 函数以及应用中的导航数据。随着时间的推移,这个模块将增长,因此它可以拆分为不同的模块,每个模块持有应用的不同常见功能(导航、UI、常见逻辑等)。
如果我们正在使用 Jetpack Compose 为我们的应用程序,那么我们可以依赖在 第三章 的 练习 03.02 – 使用 Jetpack Compose 进行导航 中完成的工作,其中我们为应用导航定义了以下结构:
private const val ROUTE_USERS = "users"
private const val ROUTE_USER = "users/%s"
private const val ARG_USER_NAME = "name"
sealed class AppNavigation(val route: String, val
argumentName: String = "") {
object Users : AppNavigation(ROUTE_USERS)
object User : AppNavigation(String.format(ROUTE_USER,
"{$ARG_USER_NAME}"), ARG_USER_NAME) {
fun routeForName(name: String) =
String.format(ROUTE_USER, name)
}
}
当列表中的用户被点击时,routeForName 方法会从 Users 屏幕调用,然后 NavHost 方法会使用该路由来打开 User 屏幕。在处理多个模块时,将在 :common 模块中存储模块之间共享的路由,以便每个模块都可以访问该路由。然后,具有 NavHost 的 :app 模块将能够导航到每个屏幕。
当涉及到处理不同模块之间的常见逻辑,例如显示相同的错误或加载视图时,我们可以在 :common 模块中声明可组合函数:
@Composable
fun Error(errorMessage: String) {
…
}
@Composable
fun Loading() {
…
}
如果不同模块中的不同屏幕共享相同的状态,我们可以有如下类似的情况:
@Composable
fun <T> CommonScreen(state: State<T>, onSuccess:
@Composable (T) -> Unit) {
when (result) {
is State.Success -> {
onSuccess(result.data)
}
is State.Error -> {
Error(result.errorMessage)
}
is State.Loading -> {
Loading()
}
}
}
在这里,我们将检查当前状态并显示常见的错误和加载视图,而屏幕本身只需关注成功状态。
在本节中,我们探讨了如何将展示层拆分为多个模块,以及如何处理这些模块之间的常见元素。在下一节中,我们将探讨一个练习,说明如何实现这一点。将展示层拆分为多个模块将减少应用程序构建时间,因为 Gradle 缓存只会重新构建包含更改的模块。另一个好处是,它为应用程序的范围划定了界限,这在仅导出应用程序的某些功能时将是有益的。
练习 08.02 – 多模块数据展示
修改 练习 08.01 – 实现 MVVM,以便创建两个新的模块:presentation-post 和 presentation-common。
presentation-common 模块将包含以下内容:
-
将
UiState类从presentation-post模块移动过来。 -
CommonResultConverter将是一个具有两个方法的抽象类:convert是一个将Result对象转换为UiState对象的具体方法,而convertSuccess是一个用于将Result.Success中的数据转换的抽象方法。 -
CommonScreen将包含用于显示不同类型UiState的@Composable方法,以及两个额外的用于显示错误 snackbar 和进度条的方法。这两个方法将从PostListScreen移动过来。 -
AppNavigation将包含导航到帖子列表、单个帖子以及单个用户的路由。 -
presentation-post模块将添加一个额外的包来显示单个帖子的信息,格式如下:标题:x 和正文:y,其中 x 是帖子的标题,y 是帖子的正文。为了显示这些信息,需要创建一个新的ViewModel和Converter类,这些类将把GetPostUseCase中的数据转换过来。当点击作者文本时,应用将导航到用户屏幕;当点击Post列表项时,应用将导航到帖子屏幕。点击其中任何一个,都会调用UpdateInteractionUseCase来增加点击次数,这将随后反映在列表标题中。 -
presentation-user将以以下格式显示单个用户的信息:姓名:x,用户名:y,和电子邮件:z,其中 x、y 和 z 由User实体中的信息表示。用户数据将从GetUserUseCase加载。 -
app模块将更新以处理所有这些屏幕之间的导航。
要完成这个练习,你需要执行以下操作:
-
创建
presentation-common模块。 -
将
UiState类和Error以及Loading@Composable函数移动到CommonScreen文件中,并创建一个新的@Composable函数,该函数将处理CommonScreen文件中每种类型的UiState对象。 -
创建
CommonResultConverter类。 -
创建
AppNavigation类。 -
修改
presentation-post中的类以重用前面的类和方法。 -
创建负责显示单个帖子信息的
PostScreen、PostViewModel、PostConverter和PostModel类。 -
创建
presentation-user模块。 -
创建负责显示单个帖子信息的
UserScreen、UserViewModel、UserConverter和UserModel类。 -
实现屏幕间的导航。
-
在
PostListViewModel中添加更新点击次数的逻辑。
按照以下步骤完成练习:
-
创建
presentation-common和presentation-userAndroid 库模块。 -
将练习 08.01 – 实现 MVVM中的步骤 3-5 应用于这些新模块。
-
在
presentation-post和presentation-user模块的build.gradle文件中,确保添加了对presentation-common的依赖项。dependencies { … implementation(project(path: ":presentation-common")) … } -
在
presentation-common模块中,创建一个新的包,命名为state。 -
将
UiState类移动到前面的包中。 -
在同一个包中,创建
CommonResultConverter类:abstract class CommonResultConverter<T : Any, R : Any> { fun convert(result: Result<T>): UiState<R> { return when (result) { is Result.Error -> { UiState.Error(result.exception. localizedMessage.orEmpty()) } is Result.Success -> { UiState.Success(convertSuccess (result.data)) } } } abstract fun convertSuccess(data: T): R }
在这里,对于任何带有异常信息的 Result.Error 对象,我们返回 UiState.Error,对于 Result.Success,我们返回 UiState.Success 并使用 Result.Success 对象内部的数据的抽象。这代表了一种解决方案,说明了我们如何提取显示错误的公共逻辑。
-
修改
presentation-post模块中的PostListConverter类,使其扩展CommonResultConverter并为convertSuccess方法提供实现:class PostListConverter @Inject constructor (@ApplicationContext private val context: Context) : CommonResultConverter<GetPostsWithUsersWithInteraction UseCase.Response, PostListModel>() { override fun convertSuccess(data: GetPostsWithUsersWithInteractionUseCase. Response): PostListModel { return PostListModel( headerText = context.getString( R.string.total_click_count, data.interaction.totalClicks ), items = data.posts.map { PostListItemModel( it.post.id, it.user.id, context.getString(R.string.author, it.user.name), context.getString(R.string.title, it.post.title) ) } ) } }
在这里,我们只处理将 GetPostsWithUsersWithInteractionUseCase.Response 转换为 PostListModel,允许父类处理错误。
-
在
presentation-common模块的state包中,创建一个名为CommonScreen的新文件。 -
在
CommonScreen文件中,添加一个CommonScreen@Composable方法,它将检查UiState并对UiState.Error调用Error,对UiState.Loading调用Loading:@Composable fun <T : Any> CommonScreen(state: UiState<T>, onSuccess: @Composable (T) -> Unit) { when (state) { is UiState.Loading -> { Loading() } is UiState.Error -> { Error(errorMessage = state.errorMessage) } is UiState.Success -> { onSuccess(state.data) } } } -
将
PostListScreen中的Error和Loading@Composable函数移动到CommonScreen文件中。 -
修改
presentation-post模块中的PostListScreen@Composable方法,使其使用CommonScreen方法:@Composable fun PostListScreen( viewModel: PostListViewModel ) { viewModel.loadPosts() viewModel.postListFlow.collectAsState().value.let { state -> CommonScreen(state = state) { PostList(postListModel = it) } } }
现在,整个转换和显示帖子列表的逻辑将只处理相关对象,将错误和加载场景留在 presentation-common 模块中。
-
在
presentation-common中,创建一个新的包,命名为navigation。 -
在
navigation包中,创建一个名为PostInput的类:data class PostInput(val postId: Long)
这个类旨在表示帖子屏幕加载数据所需的输入。
-
在同一个包中,创建一个名为
UserInput的类:data class UserInput(val userId: Long)
这个类旨在表示用户屏幕加载数据所需的输入。
-
在同一个包中,创建一个新的类,命名为
NavRoutes:private const val ROUTE_POSTS = "posts" private const val ROUTE_POST = "posts/%s" private const val ROUTE_USER = "users/%s" private const val ARG_POST_ID = "postId" private const val ARG_USER_ID = "userId" sealed class NavRoutes( val route: String, val arguments: List<NamedNavArgument> = emptyList() ) { … }
在这里,我们定义了每个屏幕的路径。帖子屏幕将没有参数,但用户和帖子屏幕将需要 postId 和 userId 的值。
-
在
NavRoutes类中创建Posts类:sealed class NavRoutes( val route: String, val arguments: List<NamedNavArgument> = emptyList() ) { object Posts : NavRoutes(ROUTE_POSTS) } -
在
NavRoutes类中创建Post类:sealed class NavRoutes( val route: String, val arguments: List<NamedNavArgument> = emptyList() ) { object Post : NavRoutes( route = String.format(ROUTE_POST, "{$ARG_POST_ID}"), arguments = listOf(navArgument(ARG_POST_ID) { type = NavType.LongType }) ) { fun routeForPost(postInput: PostInput) = String.format(ROUTE_POST, postInput.postId) fun fromEntry(entry: NavBackStackEntry): PostInput { return PostInput(entry.arguments?. getLong(ARG_POST_ID) ?: 0L) } } }
在这里,我们需要将 Post 输入分解为 URL 的参数。routeForPost 方法将为具有 ID 1 的 Post 对象创建一个 /posts/1 URL。fromEntry 方法将从导航条目对象重新组装 PostInput 对象。我们采取这种方法的理由是,导航库不鼓励使用 Parcelable,这意味着在不同屏幕之间传递数据将不得不通过 URL 来完成。为了避免在多个模块之间跟踪参数时出现任何问题,我们可以使用对象,并将从参数中读取和构造参数的逻辑隔离到这个类中。
-
在
NavRoutes类内部创建User类:sealed class NavRoutes( val route: String, val arguments: List<NamedNavArgument> = emptyList() ) { object User : NavRoutes( route = String.format(ROUTE_USER, "{$ARG_USER_ID}"), arguments = listOf(navArgument(ARG_USER_ID) { type = NavType.LongType }) ) { fun routeForUser(userInput: UserInput) = String.format(ROUTE_USER, userInput.userId) fun fromEntry(entry: NavBackStackEntry): UserInput { return UserInput(entry.arguments?.getLong (ARG_USER_ID) ?: 0L) } } }
这里,我们应用与Post类相同的原理。
-
在
presentation-post模块中创建一个名为single的新包: -
在
single包内创建PostModel类:data class PostModel( val title: String, val body: String ) -
在
single包内创建PostConverter类:class PostConverter @Inject constructor(@ApplicationContext private val context: Context) : CommonResultConverter<GetPostUseCase.Response, PostModel>() { override fun convertSuccess(data: GetPostUseCase.Response): PostModel { return PostModel( context.getString(R.string.title, data.post.title), context.getString(R.string.body, data.post.body) ) } } -
将
body字符串添加到presentation-post模块的strings.xml中:<resources> … <string name="body">Body: %s</string> </resources> -
在
single包内创建PostViewModel类:@HiltViewModel class PostViewModel @Inject constructor( private val postUseCase: GetPostUseCase, private val postConverter: PostConverter ) : ViewModel() { private val _postFlow = MutableStateFlow<UiState<PostModel>>(UiState.Loading) val postFlow: StateFlow<UiState<PostModel>> = _postFlow fun loadPost(postId: Long) { viewModelScope.launch { postUseCase.execute(GetPostUseCase. Request(postId)) .map { postConverter.convert(it) } .collect { _postFlow.value = it } } } }
这里,我们使用GetPostUseCase加载特定帖子的信息,并使用之前定义的转换器将数据转换为PostModel,该模型将被设置在Flow对象中。
-
在
single包内创建PostScreen文件,该文件将显示帖子信息:@Composable fun PostScreen( viewModel: PostViewModel, postInput: PostInput ) { viewModel.loadPost(postInput.postId) viewModel.postFlow.collectAsState().value.let { result -> CommonScreen(result) { postModel -> Post(postModel) } } } @Composable fun Post(postModel: PostModel) { Column(modifier = Modifier.padding(16.dp)) { Text(text = postModel.title) Text(text = postModel.body) } }
这里,我们遵循与PostListScreen文件相同的原理,将方法分为两个,PostScreen用于观察UiState对象,PostListScreen用于处理用户界面绘制。
-
在
presentation-user模块中,创建一个名为single的新包: -
在
single包内创建一个名为UserModel的新类:data class UserModel( val name: String, val username: String, val email: String ) -
在
single包内创建一个名为UserConverter的新类:class UserConverter @Inject constructor(@ApplicationContext private val context: Context) : CommonResultConverter<GetUserUseCase.Response, UserModel>() { override fun convertSuccess(data: GetUserUseCase. Response): UserModel { return UserModel( context.getString(R.string.name, data.user.name), context.getString(R.string.username, data.user.username), context.getString(R.string.email, data.user.email) ) } } -
在
presentation-user模块的main文件夹内创建res/values/strings.xml文件:<?xml version="1.0" encoding="utf-8"?> <resources> <string name="name">Name: %s</string> <string name="username">Username: %s</string> <string name="email">Email: %s</string> </resources> -
在
single包内创建UserViewModel:@HiltViewModel class UserViewModel @Inject constructor( private val userUseCase: GetUserUseCase, private val converter: UserConverter ) : ViewModel() { private val _userFlow = MutableStateFlow<UiState<UserModel>> (UiState.Loading) val userFlow: StateFlow<UiState<UserModel>> = _userFlow fun loadUser(userId: Long) { viewModelScope.launch { userUseCase.execute (GetUserUseCase.Request(userId)) .map { converter.convert(it) } .collect { _userFlow.value = it } } } }
这里,我们从GetUserUseCase获取用户数据,使用UserConverter进行转换,并将结果发布在Flow对象中。
-
在
single包内创建UserScreen文件:@Composable fun UserScreen( viewModel: UserViewModel, userInput: UserInput ) { viewModel.loadUser(userInput.userId) viewModel.userFlow.collectAsState().value.let { result -> CommonScreen(result) { userModel -> User(userModel) } } } @Composable fun User(userModel: UserModel) { Column(modifier = Modifier.padding(16.dp)) { Text(text = userModel.name) Text(text = userModel.username) Text(text = userModel.email) } }
这里,我们采用与其他屏幕相同的方法,在一个方法中订阅UiState的变化,在另一个方法中显示用户信息。
-
在
PostListScreen中添加点击监听器:@Composable fun PostList( postListModel: PostListModel, onRowClick: (PostListItemModel) -> Unit, onAuthorClick: (PostListItemModel) -> Unit ) { LazyColumn(modifier = Modifier.padding(16.dp)) { … items(postListModel.items) { item -> Column(modifier = Modifier .padding(16.dp) .clickable { onRowClick(item) }) { ClickableText(text = AnnotatedString( text = item.authorName), onClick = { onAuthorClick(item) }) Text(text = item.title) } } } }
在前面的代码片段中,我们指定了行被点击和作者被点击时的点击监听器。因为我们正在应用状态提升,我们希望将点击监听器传播到PostList方法的调用者。为此,我们为每个点击监听器定义了一个参数,作为一个接受行数据作为输入且不需要返回结果的 lambda 函数。有关 lambda 的更多信息,请参阅此处:kotlinlang.org/docs/lambdas.html#function-types。
-
修改
PostListScreen的@Composable方法,以便当用户点击时,我们导航到用户界面,当行被点击时,我们导航到帖子:@Composable fun PostListScreen( viewModel: PostListViewModel, navController: NavController ) { viewModel.loadPosts() viewModel.postListFlow.collectAsState().value.let { state -> CommonScreen(state = state) { PostList(it, { postListItem -> navController.navigate(NavRoutes.Post. routeForPost(PostInput (postListItem.id))) }) { postListItem -> navController.navigate(NavRoutes.User. routeForUser(UserInput (postListItem.userId))) } } } } -
在
app模块的build.gradle中,确保添加了对presentation-common和presentation-user的依赖:dependencies { … implementation(project(path: ":presentation- user")) implementation(project(path: ":presentation- common")) … } -
在
MainActivity文件中,修改App方法,以便实现不同屏幕之间的导航:@Composable fun App(navController: NavHostController) { NavHost(navController, startDestination = NavRoutes.Posts.route) { composable(route = NavRoutes.Posts.route) { PostListScreen(hiltViewModel(), navController) } composable( route = NavRoutes.Post.route, arguments = NavRoutes.Post.arguments ) { PostScreen( hiltViewModel(), NavRoutes.Post.fromEntry(it) ) } composable( route = NavRoutes.User.route, arguments = NavRoutes.User.arguments ) { UserScreen( hiltViewModel(), NavRoutes.User.fromEntry(it) ) } } }
这里,我们将应用程序中的所有屏幕添加到导航图中,对于UserScreen和PostScreen,我们从导航图条目中提取UserInput和PostInput对象。我们现在需要添加交互。
-
在
PostListModel内部添加一个Interaction字段:data class PostListModel( … val interaction: Interaction ) -
修改
PostListConverter以包含interaction字段:class PostListConverter @Inject constructor (@ApplicationContext private val context: Context) : CommonResultConverter<GetPostsWithUsersWithInteraction UseCase.Response, PostListModel>() { override fun convertSuccess(data: GetPostsWithUsersWithInteractionUseCase. Response): PostListModel { return PostListModel( … interaction = data.interaction ) } } -
在
PostListViewModel中添加对UpdateInteractionUseCase的引用,并添加一个更新交互的方法:@HiltViewModel class PostListViewModel @Inject constructor( … private val updateInteractionUseCase: UpdateInteractionUseCase ) : ViewModel() { … fun updateInteraction(interaction: Interaction) { viewModelScope.launch { updateInteractionUseCase.execute( UpdateInteractionUseCase.Request( interaction.copy( totalClicks = interaction. totalClicks + 1 ) ) ).collect() } } } -
修改
PostListScreen的@Composable方法,使其在每次点击时调用更新交互:@Composable fun PostListScreen( viewModel: PostListViewModel, navController: NavController ) { … viewModel.postListFlow.collectAsState().value.let { state -> CommonScreen(state = state) { PostList(it, { postListItem -> viewModel.updateInteraction(it.interaction) … }) { postListItem -> viewModel.updateInteraction(it.interaction) … } } } }
如果我们运行应用程序,我们将看到如下图的输出:

图 8.7 – 练习 08.02 的输出
从 图 8.7 我们可以看到,当点击行时,我们会进入显示帖子信息的屏幕,当点击作者时,我们会进入用户信息屏幕。通过将 NavRoutes 类放在 presentation-common 模块中,我们可以从位于同一模块(帖子)的屏幕和位于不同模块(用户)的屏幕上导航到帖子列表。创建额外模块的解决方案是避免循环依赖的好方法,这不仅适用于表示层中的模块,也适用于其他层中的模块。
在这个练习中,我们学习了如何将表示层分割成独立的模块,以及我们如何使用公共模块来存储层中所有模块所需的共享逻辑和数据。这是一种技术,如果我们想将应用程序的其他层分割开,我们也可以使用它。
摘要
在本章中,我们探讨了 Android 应用程序的表示层以及实现此层的一些不同方法,例如 MVC、MVP 和 MVVM。我们决定专注于 MVVM 方法,因为它涉及生命周期和与 Jetpack Compose 的兼容性带来了许多好处。然后我们探讨了当我们想要将表示层分割到多个模块时会发生什么,以及我们如何解决这些模块之间的公共逻辑。在下一章中,我们将进一步构建 MVVM 模式,并研究 模型-视图-意图(MVI)模式,该模式进一步利用可观察模式将用户操作纳入可观察的状态。
第九章:第九章:实现 MVI 架构
在本章中,我们将介绍模型-视图-意图(MVI)的概念以及它为管理应用程序状态提供的优势。我们将从分析 MVI 是什么开始,然后继续使用 Kotlin 流来实现它。在本章的练习中,我们将基于上一章的练习,并使用 MVI 模式重新实现它们,以突出这种模式如何集成到具有多个模块的应用程序的表现层。
在本章中,我们将涵盖以下主题:
-
介绍 MVI
-
使用 Kotlin 流实现 MVI
到本章结束时,你将能够使用 Kotlin 流在多模块 Android 应用程序中实现 MVI 架构模式。
技术要求
硬件和软件要求如下:
- Android Studio Arctic Fox 2020.3.1 补丁 3
本章的代码文件可以在以下位置找到:github.com/PacktPublishing/Clean-Android-Architecture/tree/main/Chapter9.
查看以下视频以查看代码的实际应用:bit.ly/3FYZKLn
介绍 MVI
在本节中,我们将探讨 MVI 架构模式是什么,它试图解决的问题,以及它为解决这些问题提供的解决方案。
让我们假设你需要为应用程序开发一个配置屏幕。它将加载现有配置,并需要切换各种开关,以及使用现有数据预填充输入字段。在加载数据之后,用户可以修改这些字段中的每一个。为了实现这一点,你可能需要保留这些字段所表示数据的可变引用,以便当用户更改值时,引用会发生变化。
这可能由于这些字段的可变性而引发问题,尤其是在处理并发操作或它们的顺序时。解决这个问题的一种方法是将数据设置为不可变,并将其组合成一个用户界面可以观察的状态。应用或用户需要对用户界面进行的任何更改将通过响应式数据流来完成。然后,该流将创建一个新的状态来表示更改并更新用户界面。
这基本上是 MVI 的工作方式。在 MVI 中,视图扮演着与 MVP 或 MVVM 中相同的角色,而模型持有用户界面的状态,并且代表单一的真实来源。意图表示对状态的任何更改,这将随后被更新。在图 9.1中,我们可以看到视图将向模型发送意图,然后模型将触发状态的变化,这将更新视图:
注意
在 MVI 的上下文中,术语“意图”与用于与不同 Android 组件交互的 Android Intent类不同。

图 9.1 – MVI 图
这个图中缺少的是 ViewModel 或 Presenter 的等效物。这是因为 MVI 模式不是这些模式的替代品,而是建立在它们之上的。
为了可视化这可能是什么样子,让我们看看一个 ViewModel 的例子:
class MyViewModel @Inject constructor(
private val getMyDataUseCase: GetMyDataUseCase
) : ViewModel() {
private val _myDataFlow =
MutableStateFlow<MyData>(MyData())
val myDataFlow: StateFlow<MyData> = _myDataFlow
var text: String = ""
fun loadMyData() {
viewModelScope.launch {
getMyDataUseCase.execute
(GetPostsWithUsersWithInteractionUseCase.
Request)
.collect {
_myDataFlow.value = it
}
}
}
}
在前面的例子中,我们定义了一个名为 MyViewModel 的类,其中包含一个加载数据的使用案例和一个 text 变量,该变量将在用户更改它时由视图进行更改。我们可以看到,text 变量是一个可变的变量,可以从持有要加载数据的 StateFlow 变量中访问,并且我们有一个加载数据的方法。要将前面的代码过渡到 MVI,我们首先需要定义一个将持有要加载数据和文本的状态。这将代表我们的真相来源。对于前面的例子,这个状态将如下所示:
data class MyState(
val myData: MyData = MyData(),
val text: String = ""
)
在 MyState 类中,我们将要加载数据和要更改的文本移动。现在,我们需要识别动作;在这种情况下,我们有两个动作:加载数据和用用户引入的新值更新文本的值:
sealed class MyAction {
object LoadAction : MyAction()
data class UpdateAction(val text: String) :
MyAction()
}
在前面的例子中,我们将动作表示为一个密封类,并定义了两个动作:加载数据和更新文本。接下来,我们需要创建适当的数据流来处理动作和管理状态:
private val _myStateFlow = MutableStateFlow<MyState>
(MyState())
val myStateFlow: StateFlow<MyState> = _myDataFlow
private val _actionFlow: MutableSharedFlow<MyAction> =
MutableSharedFlow()
在前面的例子中,我们将 StateFlow 变量更改为持有之前定义的状态对象,并添加了一个类似的 SharedFlow 变量,该变量将负责管理用户插入的动作。我们现在需要订阅并处理这些动作:
class MyViewModel @Inject constructor(
private val getMyDataUseCase: GetMyDataUseCase
) : ViewModel() {
…
init {
viewModelScope.launch {
action.collect { action ->
when (action) {
is MyViewModel.MyAction.LoadAction -> {
loadMyData()
}
is MyViewModel.MyAction.UpdateAction -> {
_myDataFlow.emit(_myDataFlow.value.copy(text =
action.text))
}
}
}
}
}
fun submitAction(action: MyAction) {
viewModelScope.launch {
_action.emit(action)
}
}
private fun loadMyData() {
getMyDataUseCase.execute
(GetPostsWithUsersWithInteractionUseCase.
Request)
.collect {
_myDataFlow.value = it
}
}
…
}
在 init 块中,我们正在收集动作,然后,对于每个动作,我们执行所需的操作。视图将调用 submitAction 方法,并传递它希望 ViewModel 执行的动作。对于这个例子,MyAction 在 MVI 上下文中扮演 Intent 的角色,而 ViewModel 将位于视图和模型之间,并负责管理模型和视图之间的数据流,以及管理状态。
当涉及到 MVI 模式的实现时,针对不同的技术和不同的架构模式有许多不同的变体。从 RxJava 到 LiveData,再到流和协程,到 MVVM 和 MVP,都有不同的方法来实现这个模式,并且有不同的变体。
有些是使用诸如状态机等概念构建的,有些使用基本的流,还有些使用第三方开源库。从前面的例子中,我们可以看到这个模式引入了一些样板代码,因此在进行研究并监控任何应用程序中模式的初始引入时非常重要。在接下来的部分中,我们将探讨如何使用 Kotlin 流实现 MVI。
使用 Kotlin 流实现 MVI
在本节中,我们将探讨如何使用 Kotlin 流实现 MVI 架构模式,以及这种方法的优势和劣势。
在前面的章节中,我们定义了一个使用StateFlow和SharedFlow的 MVI 方法,如下例所示:
private val _myStateFlow = MutableStateFlow<MyState>(MyState())
val myStateFlow: StateFlow<MyState> = _myDataFlow
private val actionFlow: MutableSharedFlow<MyAction> = MutableSharedFlow()
在这里使用的不同类型的流服务于不同的目的。MutableStateFlow会发出它持有的最后一个值,这对于用户界面来说很好,因为我们希望它显示最后加载数据,就像LiveData的工作方式一样。SharedFlow没有这个特性,这对于动作来说很有用,因为我们不希望最后一个动作被发射两次。我们还需要考虑的另一个方面是单次事件,这些事件应该使用通道流来发射。这将在视图需要响应通道中的事件以显示吐司警报或处理导航到新屏幕时很有用。我们可以使用以下方式来实现:
class MyViewModel @Inject constructor(
private val getMyDataUseCase: GetMyDataUseCase
) : ViewModel() {
…
private val _myStateFlow = MutableStateFlow<MyState>
(MyState())
val myStateFlow: StateFlow<MyState> = _myDataFlow
private val actionFlow: MutableSharedFlow<MyAction> =
MutableSharedFlow()
private val _myOneOffFlow = Channel<MyOneOffEvent>()
val myOneOffFlow = _myOneOffFlow.receiveAsFlow()
…
}
在前面的例子中,我们将Channel信息与ViewModel的其余部分集成在一起。因为一个应用程序最终会有多个ViewModel,我们可以创建一个将在整个应用程序中使用的模板。我们可以从为每个状态、动作和一次性事件定义抽象开始:
interface UiState
interface UiAction
interface UiSingleEvent
在这里,我们选择了一个简单的接口来表示ViewModel将使用的数据流中的每一个。接下来,我们可以定义ViewModel的模板,该模板可以被应用程序中使用的ViewModel继承:
abstract class MviViewModel<S : UiState, A : UiAction, E : UiSingleEvent> : ViewModel() {
private val _uiStateFlow: MutableStateFlow<S> by lazy {
MutableStateFlow(initState())
}
val uiStateFlow: StateFlow<S> = _uiStateFlow
private val actionFlow: MutableSharedFlow<A> =
MutableSharedFlow()
private val _singleEventFlow = Channel<E>()
val singleEventFlow = _singleEventFlow.receiveAsFlow()
…
}
在前面的例子中,我们为ViewModel将使用的每个流使用了泛型。这为MutableStateFlow创建了一个问题,因为它需要一个初始值。因为我们没有具体的值来初始化,我们需要创建一个提供初始值的抽象方法:
abstract class MviViewModel<S : UiState, A : UiAction, E : UiSingleEvent> : ViewModel() {
…
init {
viewModelScope.launch {
actionFlow.collect {
handleAction(it)
}
}
}
abstract fun initState(): S
abstract fun handleAction(action: A)
}
除了initState抽象之外,我们还添加了handleAction抽象。当因为用户操作或屏幕加载提交新动作时,将调用此方法。由于可变变量被设置为私有,我们需要公开方法来将这些事件发射到这些流中:
abstract class MviViewModel<S : UiState, A : UiAction, E :
UiSingleEvent> : ViewModel() {
…
fun submitAction(action: A) {
viewModelScope.launch {
actionFlow.emit(action)
}
}
fun submitState(state: S) {
viewModelScope.launch {
_uiStateFlow.value = state
}
}
fun submitSingleEvent(event: E) {
viewModelScope.launch {
_singleEventFlow.send(event)
}
}
}
在前面的例子中,我们添加了在每个特定数据流上发射、发送或更改值的方法。为了实现特定场景的模板,我们需要为UiState创建具体实现:
sealed class MyUiState : UiState {
data class Success(val myData: MyData) : MyUiState()
object Error : MyUiState()
object Loading : MyUiState()
}
在前面的例子中,我们定义了屏幕可能具有的不同状态。现在,我们可以为UiAction创建具体实现:
sealed class MyUiAction : UiAction {
object Load : MyUiAction()
object Click : MyUiAction()
}
在这里,我们定义了一个当需要加载数据时的动作,以及当用户界面上点击时的另一个动作:
sealed class MyUiSingleEvent : UiSingleEvent {
data class ShowToast(val text: String) :
MyUiSingleEvent()
}
对于单次触发的事件,我们定义了一个显示吐司警报的事件。最后,我们可以实现ViewModel的具体实现:
class MyViewModel : MviViewModel<MyUiState, MyUiAction,
MyUiSingleEvent>() {
override fun initState(): MyUiState = MyUiState.Loading
override fun handleAction(action: MyUiAction) {
when (action) {
is MyUiAction.Load -> {
viewModelScope.launch {
val state: UiState = // Fetch UI state
submitState(state)
}
}
is MyUiAction.Click -> {
// Handle logic for clicks
submitSingleEvent(MyUiSingleEvent.
ShowToast("Toast"))
}
}
}
}
在前面的示例中,我们扩展了 MviViewModel 类,并为泛型传递了 MyUiState、MyUiAction 和 MyUiSingleEvent。在 initState 方法中,我们返回 Loading 状态,并在 handleAction 方法中检查操作,然后加载数据或处理点击事件,然后提交事件以显示 toast 提醒。
如果我们想要将 ViewModel 与 Jetpack Compose 集成,我们将不得不使用如下示例:
@Composable
fun MyScreen(
viewModel: MyViewModel
) {
viewModel.submitAction(MyUiAction.Load)
viewModel.uiStateFlow.collectAsState().value.let {
state ->
when (state) {
is MyUiState.Loading -> {
}
is MyUiState.Success -> {
MySuccessScreen(state.myData) {
viewModel.submitAction(MyUiAction.
Click)
}
}
is MyUiState.Error -> {
}
}
}
}
我们可以看到,观察 UiState 将与 MVVM 相同;然而,如果我们希望通知 ViewModel 任何更改,我们将需要使用 submitAction 方法。对于 UiSingleEvents 对象,我们需要使用 LaunchedEffect 函数,因为我们不希望 Jetpack Compose 持续重新组合和重新执行相同的代码块;我们只想执行一次,因此我们需要使用如下所示的内容:
@Composable
fun MyScreen(
viewModel: MyViewModel
) {
…
LaunchedEffect(Unit, {
viewModel.singleEventFlow.collectLatest {
when (it) {
is MyUiSingleEvent.ShowToast -> {
// Show Toast
}
}
}
})
}
在此示例中,我们在 LaunchedEffect 方法内部从 Channel 收集数据,并在接收到 ShowToast 事件时显示一个 toast 提醒。LaunchedEffect 还可以用来确保我们不会因为 Jetpack Compose 的重新组合机制而触发多次数据加载:
@Composable
fun MyScreen(
viewModel: MyViewModel
) {
LaunchedEffect(Unit, {
viewModel.submitAction(MyUiAction.Load)
}
}
在前面的代码片段中,我们将对 submitAction 的调用移动到了 LaunchedEffect 内部,以避免多次触发加载。有关 Jetpack Compose 侧效应的更多信息,请参阅此处:developer.android.com/jetpack/compose/side-effects。
在本节中,我们展示了如何将 MVI 架构模式与流程和 Jetpack Compose 集成。我们看到了如何使用 UiAction 接口及其实现将视图和 ViewModel 之间的交互转换为意图。我们还看到了由于添加了样板代码,以及在使用 Jetpack Compose 时需要使用 LaunchedEffect 和 Channel 等方法来发出一次性事件,该模式的一些缺点。在下一节中,我们将创建一个应用程序,我们将迁移一个之前的练习以使用 MVI。
练习 09.01 – 转向 MVI
修改 第八章 08.02 – 多模块数据展示,从 第八章,实现 MVVM 架构,以便表示层使用 MVI 架构模式。UiState 类将保持不变,代表每个屏幕的状态。在 presentation-common 模块中,将添加新的接口来表示操作和一次性事件。在同一模块中,将实现 MviViewModel 抽象类,它将是应用程序中其他 ViewModel 使用的模板。对于 PostListViewModel,我们将创建用于加载数据、点击帖子以及点击作者的新用户操作,并且需要两个新的一次性事件来打开这些屏幕。对于 PostViewModel 和 UserViewModel,我们将创建单个用户操作,该操作将负责在屏幕上加载数据。
要完成这个练习,你需要做以下事情:
-
在
presentation-common中,创建一个名为UiAction的接口和一个名为UiSingleEvent的接口,然后创建MviViewModel模板。 -
在
presentation-post模块的list包中,创建一个名为PostListUiAction的密封类,它将包含三个子类,分别称为Load、UserClick和PostClick。然后,创建一个名为PostListUiSingleEvent的密封类,它将有两个子类,分别称为OpenUserScreen和OpenPostScreen。然后,修改PostListViewModel和PostListScreen以使用指定的操作和事件。 -
在
presentation-post模块的single包中,创建一个名为PostUiAction的密封类,它将有一个名为Load的子类,该子类将包含帖子的 ID。然后,修改PostViewModel和PostScreen以使用指定的操作。 -
在
presentation-user模块的single包中,创建一个名为UserUiAction的密封类,它将有一个名为Load的子类,该子类将包含用户的 ID。然后,修改UserViewModel和UserScreen以使用指定的操作。
按照以下步骤完成练习:
-
在
presentation-common模块的状态包中,创建一个名为UiAction的接口:interface UiAction -
在同一包中,创建一个名为
UiSingleEvent的接口:interface UiSingleEvent -
在同一包中,创建一个名为
MviViewModel的抽象类:abstract class MviViewModel<T : Any, S : UiState<T>, A : UiAction, E : UiSingleEvent> : ViewModel() { }
由于我们正在使用泛型的 UiState 类,因此我们还需要在 MviViewModel 的泛型规范中提供该泛型字段。
-
在
MviViewModel类中,添加必要的流程和通道,以保存状态、操作和事件:abstract class MviViewModel<T : Any, S : UiState<T>, A : UiAction, E : UiSingleEvent> : ViewModel() { private val _uiStateFlow: MutableStateFlow<S> by lazy { MutableStateFlow(initState()) } val uiStateFlow: StateFlow<S> = _uiStateFlow private val actionFlow: MutableSharedFlow<A> = MutableSharedFlow() private val _singleEventFlow = Channel<E>() val singleEventFlow = _singleEventFlow. receiveAsFlow() }
在这个片段中,我们定义了 StateFlow 变量来保存最后发出的值,这将用于管理用户界面的状态,SharedFlow 用于处理用户操作,以及 Channel 用于处理一次性事件。在 MviViewModel 类中,我们还定义了泛型,以便将状态、操作和一次性事件绑定到它们各自的数据类型。
-
在
MviViewModel中添加初始化状态和处理操作的抽象方法:abstract class MviViewModel<T : Any, S : UiState<T>, A : UiAction, E : UiSingleEvent> : ViewModel() { … init { viewModelScope.launch { actionFlow.collect { handleAction(it) } } } abstract fun initState(): S abstract fun handleAction(action: A) }
在这个片段中,我们添加了提供 StateFlow 初始值的抽象,然后处理用户操作的收集,这些操作将在 handleAction 方法中处理。
-
在
MviViewModel中添加提交状态、事件和操作的必需方法:abstract class MviViewModel<T : Any, S : UiState<T>, A : UiAction, E : UiSingleEvent> : ViewModel() { … fun submitAction(action: A) { viewModelScope.launch { actionFlow.emit(action) } } fun submitState(state: S) { viewModelScope.launch { _uiStateFlow.value = state } } fun submitSingleEvent(event: E) { viewModelScope.launch { _singleEventFlow.send(event) } } }
在这个片段中,我们定义了一组方法,用于将数据发射到两个 Flow 对象和一个 Channel 对象。
-
在
presentation-post模块的list包中创建PostListUiAction类及其子类:sealed class PostListUiAction : UiAction { object Load : PostListUiAction() data class UserClick(val userId: Long, val interaction: Interaction) : PostListUiAction() data class PostClick(val postId: Long, val interaction: Interaction) : PostListUiAction() }
在这里,我们定义了一个密封类,用于加载数据和点击用户和帖子。每个都将实现 UiAction 接口。
-
在相同的包中创建
PostListUiAction类及其子类:sealed class PostListUiSingleEvent : UiSingleEvent { data class OpenUserScreen(val navRoute: String) : PostListUiSingleEvent() data class OpenPostScreen(val navRoute: String) : PostListUiSingleEvent() }
在这里,我们定义了一个密封类,用于在需要打开用户和帖子屏幕时发出一次性事件,这就是为什么我们实现了 UiSingleEvent。
-
在相同的包中,修改
PostListViewModel以扩展MviViewModel:@HiltViewModel class PostListViewModel @Inject constructor( private val useCase: GetPostsWithUsersWithInteractionUseCase, private val converter: PostListConverter, private val updateInteractionUseCase: UpdateInteractionUseCase ) : MviViewModel<PostListModel, UiState<PostListModel> , PostListUiAction, PostListUiSingleEvent>() { … }
在这个片段中,我们扩展了 MviViewModel 并将之前定义的类型以及现有的 PostListModel 类型提供给泛型字段。这是因为我们希望这个 ViewModel 能够绑定到在 PostListScreen 中发生的数据、操作和一次性事件。
-
在
PostListViewModel类中实现initState方法:@HiltViewModel class PostListViewModel @Inject constructor( … ) : MviViewModel<PostListModel, UiState<PostListModel> , PostListUiAction, PostListUiSingleEvent>() { override fun initState(): UiState<PostListModel> = UiState.Loading }
在这个片段中,我们实现了 initState 方法,并提供了 UiState.Loading 值,这将反过来使父类的 uiStateFlow 字段初始化为 Loading 值。
-
在
PostListViewModel类中实现handleAction方法:@HiltViewModel class PostListViewModel @Inject constructor( … ) : MviViewModel<PostListModel, UiState<PostListModel> , PostListUiAction, PostListUiSingleEvent>() { … override fun handleAction(action: PostListUiAction) { when (action) { is PostListUiAction.Load -> { loadPosts() } is PostListUiAction.PostClick -> { updateInteraction(action.interaction) submitSingleEvent( PostListUiSingleEvent. OpenPostScreen( NavRoutes.Post.routeForPost( PostInput(action.postId) ) ) ) } is PostListUiAction.UserClick -> { updateInteraction(action.interaction) submitSingleEvent( PostListUiSingleEvent. OpenUserScreen( NavRoutes.User.routeForUser( UserInput(action.userId) ) ) ) } } } }
在这个片段中,我们实现了 handleAction 方法,它将检查我们需要处理哪些操作,并为每个操作执行必要的操作。对于加载,我们将调用 loadPosts 方法;对于点击用户和帖子,我们将调用 updateInteraction 方法,然后提交一个一次性事件以打开用户和帖子屏幕。
-
在
PostListViewModel类中实现loadPosts方法:@HiltViewModel class PostListViewModel @Inject constructor( … ) : MviViewModel<PostListModel, UiState<PostListModel> , PostListUiAction, PostListUiSingleEvent>() { … private fun loadPosts() { viewModelScope.launch { useCase.execute (GetPostsWithUsersWithInteractionUseCase. Request) .map { converter.convert(it) } .collect { submitState(it) } } } }
在这个片段中,我们从 GetPostsWithUsersWithInteractionUseCase 加载数据,并通过从父类继承的 submitState 方法收集并更新 uiStateFlow。
-
在
PostListViewModel类中实现updateInteraction方法:@HiltViewModel class PostListViewModel @Inject constructor( … ) : MviViewModel<PostListModel, UiState<PostListModel> , PostListUiAction, PostListUiSingleEvent>() { … private fun updateInteraction(interaction: Interaction) { viewModelScope.launch { updateInteractionUseCase.execute( UpdateInteractionUseCase.Request( interaction.copy( totalClicks = interaction. totalClicks + 1 ) ) ).collect() } } }
在这个方法中,我们实现了 updateInteraction 方法,它将使用 UpdateInteractionUseCase 提交一个带有递增点击次数的新值。
-
修改
presentation-post模块中list包下的PostListScreen文件中的PostListScreen方法,使其改用submitAction方法:@Composable fun PostListScreen( viewModel: PostListViewModel, navController: NavController ) { LaunchedEffect(Unit) { viewModel.submitAction(PostListUiAction.Load) } viewModel.uiStateFlow.collectAsState().value.let { state -> CommonScreen(state = state) { PostList(it, { postListItem -> viewModel.submitAction (PostListUiAction.PostClick (postListItem.id, it.interaction)) }) { postListItem -> viewModel.submitAction (PostListUiAction.UserClick (postListItem.id, it.interaction)) } } } }
在这里,我们正在改变与PostListViewModel的交互方式。我们不是为加载和更新交互调用每个单独的方法,而是使用MviViewModel中的submitAction方法。为了加载数据,我们使用LaunchedEffect,这样当 Jetpack Compose 触发重新组合时,数据加载不会被重新触发。我们还订阅了uiStateFlow而不是postListFlow,后者不再存在。
-
在同一方法中,订阅
singleEventFlow,以便在接收到适当的事件时打开PostScreen和UserScreen:@Composable fun PostListScreen( viewModel: PostListViewModel, navController: NavController ) { … LaunchedEffect(Unit) { viewModel.singleEventFlow.collectLatest { when (it) { is PostListUiSingleEvent. OpenPostScreen -> { navController.navigate (it.navRoute) } is PostListUiSingleEvent. OpenUserScreen -> { navController.navigate (it.navRoute) } } } } }
在这个片段中,我们需要监控singleEventFlow的事件,然后检查发出的事件并打开适当的屏幕。
-
在
presentation-post模块的single包中,创建PostUiAction类及其子类:sealed class PostUiAction : UiAction { data class Load(val postId: Long) : PostUiAction() } -
在同一包中,修改
PostViewModel使其扩展MviViewModel:@HiltViewModel class PostViewModel @Inject constructor( private val postUseCase: GetPostUseCase, private val postConverter: PostConverter ) : MviViewModel<PostModel, UiState<PostModel>, PostUiAction, UiSingleEvent>() { }
在这里,我们使用新创建的PostUiAction,但由于我们没有一次性事件来订阅,我们将使用UiSingleEvent接口。
-
在同一类中,实现
initState和handleAction方法:@HiltViewModel class PostViewModel @Inject constructor( … ) : MviViewModel<PostModel, UiState<PostModel>, PostUiAction, UiSingleEvent>() { override fun initState(): UiState<PostModel> = UiState.Loading override fun handleAction(action: PostUiAction) { when (action) { is PostUiAction.Load -> { loadPost(action.postId) } } } private fun loadPost(postId: Long) { viewModelScope.launch { postUseCase.execute (GetPostUseCase.Request(postId)) .map { postConverter.convert(it) } .collect { submitState(it) } } } }
在这里,我们实现initState方法并返回UiState.Loading值,以及handleAction方法。对于handleAction,我们只有加载数据的操作,这将使用GetPostUseCase检索帖子数据,然后通过submitState方法更新uiStateFlow。
-
修改
presentation-post模块中single包下的PostScreen文件中的PostScreen方法,使其改用Load操作:@Composable fun PostScreen( viewModel: PostViewModel, postInput: PostInput ) { viewModel.uiStateFlow.collectAsState().value.let { result -> CommonScreen(result) { postModel -> Post(postModel) } } LaunchedEffect(postInput.postId) { viewModel.submitAction(PostUiAction. Load(postInput.postId)) } }
在这个片段中,我们遵循与PostListScreen相同的原理,在那里我们将与PostViewModel的交互替换为使用submitAction方法,并使用LaunchedEffect来隔离数据加载。
-
在
presentation-user模块的single包中,创建UserUiAction类及其子类:sealed class UserUiAction : UiAction { data class Load(val userId: Long) : UserUiAction() } -
在同一包中,修改
UserViewModel使其扩展MviViewModel类:@HiltViewModel class UserViewModel @Inject constructor( private val userUseCase: GetUserUseCase, private val converter: UserConverter ) : MviViewModel<UserModel, UiState<UserModel>, UserUiAction, UiSingleEvent>() { }
在这里,我们使用新创建的UserUiAction,但由于我们没有一次性事件来订阅,我们将使用UiSingleEvent接口。
-
在同一类中,实现
initState和handleAction方法:@HiltViewModel class UserViewModel @Inject constructor( … ) : MviViewModel<UserModel, UiState<UserModel>, UserUiAction, UiSingleEvent>() { override fun initState(): UiState<UserModel> = UiState.Loading override fun handleAction(action: UserUiAction) { when (action) { is UserUiAction.Load -> { loadUser(action.userId) } } } private fun loadUser(userId: Long) { viewModelScope.launch { userUseCase.execute (GetUserUseCase.Request(userId)) .map { converter.convert(it) } .collect { submitState(it) } } } }
在这里,我们遵循与PostViewModel相同的原理,即实现initState方法以返回UiState.Loading,然后在handleAction中检查类型,对于Load操作,我们加载用户信息。
-
修改
presentation-user模块中single包下的UserScreen文件中的UserScreen方法,使其改用Load操作:@Composable fun UserScreen( viewModel: UserViewModel, userInput: UserInput ) { viewModel.uiStateFlow.collectAsState().value.let { result -> CommonScreen(result) { userModel -> User(userModel) } } LaunchedEffect(userInput.userId) { viewModel.submitAction(UserUiAction. Load(userInput.userId)) } }
在这个片段中,我们遵循与PostScreen相同的原理,将UserViewModel的交互替换为使用submitAction方法,并使用LaunchedEffect来隔离数据加载。
如果我们运行应用程序,我们将看到与练习 08.02 – 多模块数据表示相同的输出:

图 9.2 – 练习 09.01 的输出
在将 MVI 引入练习后,我们可以看到由于 Jetpack Compose 要求状态来管理用户界面,我们已经有了一定的基础。这代表了我们在前几章中创建UiState类的原因之一。我们还通过添加样板代码和处理一次性事件观察到了该模式的缺点,后者不仅限于 MVI。MviViewModel的使用展示了我们如何在表示层的不同模块中拥有相同的模板。
从纯净架构的角度来看,我们可以看到我们在表示层所做的更改并没有影响到应用程序的其他层,这是一个表明我们正在走正确道路的迹象。
摘要
在本章中,我们研究了 MVI 架构模式及其为使用反应式数据流的应用程序提供的优势,通过将用户和应用程序操作集中到数据的一个单向流中。
然后,我们探讨了如何使用 Kotlin 流来实现这种模式,以及它与其他模式(如 MVP 和 MVVM)结合时扮演的角色,重点是 MVVM。我们可以在简单的表示中观察到该模式的缺点,但在具有复杂用户界面且接受多个用户输入的应用程序中,这些输入可以改变其他输入的状态,其优势变得更加明显。在章节的练习中,我们探讨了如何将具有 MVVM 的应用程序过渡到 MVI,以及它如何适应纯净架构。
在下一章中,我们将退后一步,回顾到目前为止我们已经实现和研究的成果。我们将看看我们可以如何改进,以及我们如何可以利用应用程序的不同层,以及我们如何为应用程序可能具有的各种配置交换依赖项。
第十章:第十章:整合一切
在本章中,我们将分析前几章中我们所做的工作,并探讨我们可以以不同的方式改进应用程序的层级。稍后,我们将探讨在将仪器测试集成到应用程序中时的清洁架构的好处,我们将用模拟依赖替换数据源依赖,以确保测试的可靠性。
在本章中,我们将涵盖以下主题:
-
检查模块依赖
-
仪器测试
到本章结束时,你将能够识别并删除应用程序用例层中的外部依赖,以强制执行共同封闭原则(CCP),并了解如何在 Android 上使用模拟数据源创建仪器测试。
技术要求
硬件和软件要求如下:
- Android Studio Arctic Fox 2020.3.1 Patch 3
本章的代码文件可以在以下位置找到:github.com/PacktPublishing/Clean-Android-Architecture/tree/main/Chapter10。
查看以下视频,了解代码的实际应用:bit.ly/3sLr0HS
检查模块依赖
在本节中,我们将分析在前几章创建的应用程序中不同模块之间使用的依赖项。
随着从第九章的练习 09.01 – 转向 MVI过渡,我们现在有一个完全功能的应用程序,它被分割成独立的模块,代表不同的层级。我们可以通过查看每个模块中的build.gradle文件中的dependencies块,并特别关注implementation(project(path: "{module}"))行,来分析不同模块之间的关系。如果我们绘制一个图表,它看起来会像以下这样:
![图 10.1 – 练习 09.01 的模块依赖图]
图 10.1 – 练习 09.01 的模块依赖图
在前面的图表中,我们可以看到:domain模块,它是领域层的一部分,位于中心,其他层的模块都依赖于它。:app模块负责组装所有依赖项,这意味着它将依赖于所有其他模块。这意味着我们处于良好的清洁架构位置,因为我们希望实体和用例对其他组件的依赖最小化。如果我们继续分析每个模块的build.gradle文件,并包括外部依赖项,我们将看到每个模块对外部库的额外依赖:
![图 10.2 – 练习 09.01 的模块依赖图,包含外部依赖]
图 10.2 – 练习 09.01 的模块依赖图,包含外部依赖
在 图 10.2 中,我们可以看到我们的模块使用的一些相关外部依赖。:data-remote 使用 Retrofit 和 OkHttp 的依赖进行网络操作,:data-local 模块依赖于 Room 和 DataStore,而表示层模块依赖于 Compose、ViewModel 和 Android 框架。在整个项目中使用的依赖项包括协程、flows 和 Hilt。
对 Hilt 和协程的依赖可能会对 :domain 和 :data-repository 模块造成问题。我们希望这两个模块尽可能稳定,而外部依赖会在我们更新这些库的版本时每次都造成问题。我们决定使用 flows,因为它们具有线程优势、响应式方法,并且因为它们是作为 Kotlin 框架的扩展开发的。如果我们想要使用 Kotlin Multiplatform 对多个平台进行适配,它们可能仍然会存在问题。一个解决方案是开发一个反应式插件,该插件将抽象化 flows 的使用,并在不同的模块中使用这种抽象。这将使我们能够在不更改模块内部代码的情况下交换不同的反应式库。虽然这个解决方案会解决问题,但它带来了很多负担,因为我们需要从 flows 框架中抽象出项目所需的数据流和操作符,这将给我们带来更多的代码需要维护。
当涉及到 Hilt 依赖时,我们可以从 :domain 和 :data-repository 模块中移除对 Hilt 的引用,并将 Hilt 模块移动到 :app 中。另一个解决方案是创建新的 Gradle 模块,这些模块将负责提供必要的依赖。例如,可以创建一个 :domain-hilt 模块,其中包含一个 @Module 注解的类,该类将提供 :domain 模块所需的所有依赖项。这种方法可以用于其他我们希望导出到使用不同依赖注入框架的应用程序中的模块,以避免在这些项目中依赖 Hilt。
随着应用程序开发新功能和演进,模块依赖将增加;这意味着我们应该花时间评估项目中的依赖项。这将帮助我们识别潜在问题,并确定我们是否可以正确地扩展应用程序。我们还应该考虑外部依赖,并分析它们对我们项目的影响。在下一节中,我们将探讨一个关于如何减少领域和仓库模块对 Hilt 依赖的练习。
练习 10.01 – 减少依赖
修改 第九章 中 练习 09.01 – 转向 MVI,实现 MVI 架构,以便 domain 和 data-repository 模块将不再依赖于 Hilt,而是从 app 模块内部提供这些模块的依赖项。
在完成这个练习之前,你需要做以下事情:
-
从
domain模块中移除 Hilt。 -
从
GetPostsWithUsersWithInteractionUseCase、GetPostUseCase、GetUserUseCase和UpdateInteractionUseCase类中删除@Inject注解。 -
将
AppModule类重命名为UseCaseModule,并使用@Provides为前面的对象提供依赖。 -
从
data-repository模块中移除 Hilt,并删除@Inject注解的引用。 -
将
RepositoryModule从data-repository模块移动到app模块,并使用@Provides为PostRepository、UserRepository和InteractionRepository提供依赖。
按照以下步骤完成练习:
-
在
domain模块的build.gradle文件中,移除对kapt和 Hilt 插件的引用:plugins { id 'com.android.library' id 'kotlin-android' } -
在同一个文件中,从
dependencies块中删除 Hilt 的引用:dependencies { implementation coroutines.coroutinesAndroid testImplementation test.junit testImplementation test.coroutines testImplementation test.mockito } -
从
GetPostsWithUsersWithInteractionUseCase中删除@Inject的引用:class GetPostsWithUsersWithInteractionUseCase( configuration: Configuration, private val postRepository: PostRepository, private val userRepository: UserRepository, private val interactionRepository: InteractionRepository ) : UseCase<GetPostsWithUsersWithInteractionUseCase. Request, GetPostsWithUsersWithInteractionUseCase. Response>(configuration) { … } -
从
GetPostUseCase中删除@Inject的引用:class GetPostUseCase( configuration: Configuration, private val postRepository: PostRepository ) : UseCase<GetPostUseCase.Request, GetPostUseCase. Response>(configuration) { … } -
从
GetUserUseCase中删除@Inject的引用:class GetUserUseCase( configuration: Configuration, private val userRepository: UserRepository ) : UseCase<GetUserUseCase.Request, GetUserUseCase. Response>(configuration) { … } -
从
UpdateInteractionUseCase中删除@Inject的引用:class UpdateInteractionUseCase( configuration: Configuration, private val interactionRepository: InteractionRepository ) : UseCase<UpdateInteractionUseCase.Request, UpdateInteractionUseCase.Response>(configuration) { … } -
在 app 模块中,将
AppModule重命名为UseCaseModule。 -
在
UseCaseModule类中的 app 模块,为GetPostsWithUsersWithInteractionUseCase提供依赖:@Module @InstallIn(SingletonComponent::class) class UseCaseModule { … @Provides fun provideGetPostsWithUsersWithInteractionUseCase( configuration: UseCase.Configuration, postRepository: PostRepository, userRepository: UserRepository, interactionRepository: InteractionRepository ): GetPostsWithUsersWithInteractionUseCase = GetPostsWithUsersWithInteractionUseCase( configuration, postRepository, userRepository, interactionRepository ) }
在这里,我们需要使用@Provides,因为我们不再处于同一个模块中,这意味着我们应该将其视为外部依赖,这需要@Provides注解,类似于我们提供 Room 和 Retrofit 依赖的方式。
-
在同一个类中,为
GetPostUseCase提供依赖:@Module @InstallIn(SingletonComponent::class) class UseCaseModule { … @Provides fun provideGetPostUseCase( configuration: UseCase.Configuration, postRepository: PostRepository ): GetPostUseCase = GetPostUseCase( configuration, postRepository ) }
在这个片段中,我们遵循上一步的方法。
-
在同一个类中,为
GetUserUseCase提供依赖:@Module @InstallIn(SingletonComponent::class) class UseCaseModule { … @Provides fun provideGetUserUseCase( configuration: UseCase.Configuration, userRepository: UserRepository ): GetUserUseCase = GetUserUseCase( configuration, userRepository ) }
在这个片段中,我们遵循上一步的方法。
-
在同一个类中,为
UpdateInteractionUseCase提供依赖:@Module @InstallIn(SingletonComponent::class) class UseCaseModule { … @Provides fun provideUpdateInteractionUseCase( configuration: UseCase.Configuration, interactionRepository: InteractionRepository ): UpdateInteractionUseCase = UpdateInteractionUseCase( configuration, interactionRepository ) }
在这个片段中,我们遵循上一步的方法。
-
在
data-repository模块的build.gradle文件中,移除对kapt和 Hilt 插件的引用:plugins { id 'com.android.library' id 'kotlin-android' } -
在同一个文件中,从
dependencies块中删除 Hilt 的引用:dependencies { implementation(project(path: ":domain")) implementation coroutines.coroutinesAndroid testImplementation test.junit testImplementation test.coroutines testImplementation test.mockito } -
将
RepositoryModule类从data-repository模块的注入包移动到app模块的注入包,并使该类非抽象。 -
从
InteractionRepositoryImpl中删除@Inject的引用:class InteractionRepositoryImpl( private val interactionDataSource: LocalInteractionDataSource ) : InteractionRepository { … } -
从
PostRepositoryImpl中删除@Inject的引用:class PostRepositoryImpl( private val remotePostDataSource: RemotePostDataSource, private val localPostDataSource: LocalPostDataSource ) : PostRepository { … } -
从
UserRepositoryImpl中删除@Inject的引用:class UserRepositoryImpl( private val remoteUserDataSource: RemoteUserDataSource, private val localUserDataSource: LocalUserDataSource ) : UserRepository { … } -
在
RepositoryModule类中,将bindPostRespository方法替换为@Provides方法:@Module @InstallIn(SingletonComponent::class) abstract class RepositoryModule { @Provides fun providePostRepository( remotePostDataSource: RemotePostDataSource, localPostDataSource: LocalPostDataSource ): PostRepository = PostRepositoryImpl( remotePostDataSource, localPostDataSource ) … }
这里,我们不再能够使用@Binds注解,因为我们从PostRepositoryImpl类中移除了@Inject注解,并且因为它是一个外部依赖,我们将需要使用@Provides。
-
在同一个文件中,将
bindUserRepository方法替换为@Provides方法:@Module @InstallIn(SingletonComponent::class) abstract class RepositoryModule { … @Provides fun provideUserRepository( remoteUserDataSource: RemoteUserDataSource, localUserDataSource: LocalUserDataSource ): UserRepository = UserRepositoryImpl( remoteUserDataSource, localUserDataSource ) … } -
在同一个文件中,将
bindInteractionRepositorymethod替换为@Provides方法:@Module @InstallIn(SingletonComponent::class) abstract class RepositoryModule { … @Provides fun provideInteractionRepository( interactionDataSource: LocalInteractionDataSource ): InteractionRepository = InteractionRepositoryImpl( interactionDataSource ) … }
如果我们运行应用程序,我们应该看到与练习 09.01 – 转换到 MVI中相同的输出:

图 10.3 – 练习 10.01 的输出
项目现在处于domain和data-repository模块不再依赖于 Hilt 的状态。这意味着所有依赖这两个模块的其他模块将较少暴露于由 Hilt 更新引起的潜在问题。这也意味着,在未来,如果我们想要更改应用程序中使用的依赖注入框架,domain和data-repository模块将不会受到影响。在接下来的部分中,我们将探讨如何使用模拟数据创建仪器测试来测试模块是否良好集成以及传递的数据是否得到适当处理。
仪器测试
在本节中,我们将探讨如何对 Android 应用程序进行仪器测试,以及我们如何利用依赖注入注入模拟数据或添加与测试相关的逻辑,而无需修改应用程序代码的结构。
仪器测试是一组在 Android 设备或模拟器上运行的测试,由androidTest目录中的测试表示。就像 Android 开发的其它部分一样,仪器测试在多年中不断发展,以提高测试代码的质量,并提供了创建更好测试和断言的能力。最初,测试是通过使用如ActivityTestCase、ContentProviderTestCase和ServiceTestCase等测试类来完成的,这些类主要用于在隔离状态下测试应用程序的各个组件。Espresso 测试库的添加使我们能够轻松地测试用户可能执行的多项活动作为旅程的一部分。
为了将 Espresso 及其相关库添加到项目中,需要在任何模块的build.gradle文件中添加以下内容:
dependencies {
…
androidTestImplementation "androidx.test:core:1.4.0"
androidTestImplementation "androidx.test:runner:1.4.0 "
androidTestImplementation "androidx.test:rules:1.4.0 "
androidTestImplementation
"androidx.test.ext:junit:1.1.3 "
androidTestImplementation
"androidx.test.espresso:espresso-core:3.4.0 "
androidTestImplementation "androidx.test.espresso.
idling:idling-concurrent:3.4.0 "
}
以下是一个使用 Espresso 编写的测试示例:
@Test
fun myTest(){
ActivityScenario.launch(MainActivity::class.java).
moveToState(Lifecycle.State.RESUMED)
onView(withId(R.id.my_id))
.perform(click())
.check(isDisplayed())
}
在前面的例子中,我们使用ActivityScenario启动方法来启动MainActivity并将Activity状态转换为RESUMED。然后我们使用onView,它需要ViewMatcher,并通过withId根据其 ID 查找View并返回包含该信息的ViewMatcher。然后我们有使用perform的选项,它需要ViewAction。这是当我们想要与某些视图交互时的情况。我们还可以使用check方法执行ViewAssertion。在这种情况下,我们正在检查视图是否显示。
另一个有助于测试的有用补充是协调器。当我们需要删除测试生成的数据,这些数据可能保存在内存中或持久化在设备上,进而可能影响其他测试并导致它们故障时,协调器非常有用。协调器的作用是在每次执行测试之前卸载应用程序,这样每个测试都将在一个新安装的应用程序上进行。为了将协调器添加到应用程序中,您需要在模块的build.gradle文件中添加它:
android {
…
defaultConfig {
…
testInstrumentationRunnerArguments
clearPackageData: 'true'
testOptions {
execution 'ANDROIDX_TEST_ORCHESTRATOR'
}
…
}
}
这将在测试执行中添加协调器配置,并在每次测试后传递删除应用程序数据的指令。要将协调器依赖项添加到项目中,需要以下操作:
dependencies {
…
androidTestUtil "androidx.test:orchestrator: 1.4.1"
}
Espresso 还附带了许多扩展,其中之一是IdlingResource的概念。当运行本地测试(在开发机上运行的测试)和仪器化测试时,它们会在专门的一组线程上运行。Espresso 测试库将监控应用程序的主线程,当它空闲时,将执行所需的断言。如果应用程序使用后台线程,Espresso 需要一种方式来通知这一点。我们可以使用IdlingResource来指示 Espresso 在继续执行之前等待某个动作完成。IdlingResource的一个例子是CountingIdlingResource,它将为 Espresso 需要等待的每个操作保持一个计数器。在每次长时间运行的操作之前,计数器会增加,操作完成后会减少。在每次测试之前,IdlingResource需要注册,测试完成后注销:
class MyClass(private val countingIdlingResource:
CountingIdlingResource) {
fun doOperation() {
countingIdlingResource.increment()
// Perform long running operation
countingIdlingResource.decrement()
}
}
在前面的例子中,我们在doOperation方法开始时增加CountingIdlingResource的计数,在执行我们打算进行的长时间操作之后减少。为了注册和注销IdlingResource,我们可以执行以下操作:
lateinit var countingIdlingResource : CountingIdlingResource
@Before
fun setUp(){
IdlingRegistry.getInstance().register
(countingIdlingResource)
}
@After
fun tearDown(){
IdlingRegistry.getInstance().
unregister(countingIdlingResource)
}
在这个例子中,我们在setUp方法中注册IdlingResource,由于@Before注解,该方法在每次测试之前被调用,并在tearDown方法中注销它,由于@After注解,该方法在每次测试之后被调用。
因为IdlingResource是 Espresso 的一部分,但在执行应用程序代码内的操作时需要使用它,所以我们希望避免在相关代码中使用IdlingResource。为此,我们可以通过装饰包含操作的类,然后使用依赖注入将装饰过的依赖注入到测试中。为了装饰代码,我们需要有一个操作抽象。以下是一个例子:
interface MyInterface {
fun doOperation()
}
class MyClass : MyInterface {
override fun doOperation() {
// Implement long running operation
}
}
在前面的例子中,我们创建了一个定义doOperation方法的接口,然后我们通过将长时间运行的操作实现为类来使用该接口。现在我们可以创建一个属于androidTest文件夹的类,它将装饰当前类的实现:
class MyDecoratedClass(
private val myInterface: MyInterface,
private val countingIdlingResource:
CountingIdlingResource
) : MyInterface {
override fun doOperation() {
countingIdlingResource.increment()
myInterface.doOperation()
countingIdlingResource.decrement()
}
}
在这里,我们实现了 MyInterface 的另一个版本,它将持有对抽象和 CountingIdlingResource 的引用。当调用 doOperation 时,我们将增加 IdlingResource,调用操作,然后,当操作完成时,减少 IdlingResource。
如果我们想要将新依赖项注入到测试中,我们首先需要定义一个新的扩展 Application 的类,该类将包含包含测试依赖项的依赖项图。如果我们使用 Hilt,它已经以 HiltTestApplication 的形式提供了这样的类。如果我们想要将 Hilt 集成到受测测试中,我们需要将以下依赖项添加到模块的 build.gradle 文件中:
dependencies {
androidTestImplementation "com.google.dagger:hilt-
android-testing:2.40.5"
kaptAndroidTest "com.google.dagger:hilt-android-
compiler: 2.40.5"
}
要将 HiltTestApplication 类提供给测试,我们需要更改受测测试运行器。一个新测试运行器的示例如下:
class MyTestRunner : AndroidJUnitRunner() {
override fun newApplication(cl: ClassLoader?, name:
String?, context: Context?): Application {
return super.newApplication(cl,
HiltTestApplication::class.java.name, context)
}
}
在此示例中,我们扩展了 AndroidJUnitRunner,并在 newApplication 方法中调用 super 方法,并将 HiltTestApplication 作为 name 传递。这意味着当测试执行时,将使用 HiltTestApplication 而不是我们在主代码中定义的 Application 类。我们现在需要更改模块的 build.gradle 文件中的配置以使用前面的运行器:
android {
…
defaultConfig {
…
testInstrumentationRunner "com.test.MyTestRunner"
…
}
}
}
这允许受测测试使用我们创建的运行器。现在假设我们有一个以下模块,它将提供初始依赖项:
@Module
@InstallIn(SingletonComponent::class)
abstract class MyModule {
@Binds
abstract fun bindMyClass(myClass: MyClass): MyInterface
}
在这里,我们使用简单的绑定将实现连接到抽象。在 androidTest 文件夹中,我们可以创建一个新的模块,在其中用装饰实例替换此实例:
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [MyModule::class]
)
class MyDecoratedModule {
@Provides
fun provideIdlingResource() =
CountingIdlingResource("my-idling-resource")
@Provides
fun provideMyDecoratedClass(countingIdlingResource:
CountingIdlingResource) =
MyDecoratedClass(MyClass(), countingIdlingResource)
}
在此示例中,我们使用了 @TestInstallIn 注解,这将使此模块中的依赖项与测试应用程序的生命周期保持一致,并替换上一个模块中的依赖项。然后我们可以为 IdlingResource 和 MyDecoratedClass 提供依赖项,后者将包装 MyClass 并使用 IdlingResource。如果我们想要这些更改在测试中生效,我们需要以下更改:
@HiltAndroidTest
class MyActivityTest {
@get:Rule(order = 0)
var hiltAndroidRule = HiltAndroidRule(this)
@Inject
lateinit var idlingResource: CountingIdlingResources
@Before
fun setUp(){
hiltAndroidRule.inject()
IdlingRegistry.getInstance().register
(idlingResource)
}
@After
fun tearDown(){
IdlingRegistry.getInstance().unregister
(idlingResource)
}
}
在此示例中,我们使用了 @HiltAndroidTest 注解,因为我们想要将 CountingIdlingResources 注入到测试中。然后我们使用了 HiltAndroidTestRule 来执行注入。我们还给它赋予了测试规则执行顺序中的最高优先级。最后,我们能够在类中的每个测试中注册和注销 CountingIdlingResources。
Jetpack Compose 自带测试库,需要将以下配置添加到模块的 build.gradle 文件中:
dependencies {
androidTestImplementation "androidx.compose.ui:ui-test-
junit4:1.0.5"
debugImplementation "androidx.compose.ui:ui-test-
manifest:1.0.5"
}
要为 Jetpack Compose 组件编写测试,当我们想要测试单个可组合方法时,需要使用 createComposeRule 定义一个 Compose 测试规则,或者如果我们想要测试整个活动的 Compose 内容,则使用 createAndroidComposeRule。一个示例如下:
class MyTest {
@get:Rule
var composeTestRule = createAndroidComposeRule
(MyActivity::class.java)
}
在前面的例子中,我们定义了一个测试规则,该规则将负责测试 MyActivity 内部的 Compose 内容。如果我们想让测试与用户界面交互或断言它显示正确的信息,我们将有如下结构:
@Test
fun testDisplayList() {
composeTestRule.onNode()
.assertIsDisplayed()
.performClick()
}
在这个例子中,我们使用 onNode 方法来定位特定的元素,例如 Text 或 Button。然后我们有 assertIsDisplayed 方法,用于检查节点是否显示。最后,我们有 performClick 方法,它将点击元素。Jetpack Compose 使用它自己的 IdlingResource 类型,可以在 Compose 测试规则中注册,类似于以下示例:
lateinit var idlingResource: IdlingResource
@Before
fun setUp() {
composeTestRule.registerIdlingResource
(idlingResource)
}
@After
fun tearDown() {
composeTestRule.unregisterIdlingResource
(idlingResource)
}
从干净架构的角度来看,我们应该努力使我们的应用程序代码尽可能可测试。这适用于本地测试,如单元测试和仪器测试。我们希望确保测试是可靠的;这通常意味着我们需要移除对网络调用的依赖,这意味着我们需要提供一种方法将模拟数据注入应用程序而不修改应用程序的代码。我们还需要能够将 IdlingResources 注入到应用程序中,或者使用装饰依赖项来验证用户插入的数据是否是数据层接收到的正确数据。这也涉及到装饰这些依赖项以添加额外逻辑的能力,而不修改应用程序的代码。在下一节中,我们将查看一个练习,我们将向应用程序中注入包含测试逻辑的各种依赖项,并评估引入它们的难度。
练习 10.02 – 仪器测试
向 Exercise 10.01 – 减少依赖 添加一个仪器测试,该测试将断言以下数据显示在屏幕上:
![Figure 10.4 – 练习 10.02 的预期输出
![img/Figure_10.04_B18320.jpg]
Figure 10.4 – 练习 10.02 的预期输出
为了实现这一点,你需要创建一个新的 RemotePostDataSource 实现,该实现将返回四个帖子;其中两个帖子属于一个用户,另外两个帖子属于另一个用户。同样,对于返回两个用户的 RemoteUserDataSource,也需要执行同样的操作。这些实现需要注入到测试中。为了确保测试将等待后台工作完成,你需要用 IdlingResource 装饰每个仓库,这些 IdlingResource 也需要注入到测试中。
在完成这个练习之前,你需要做以下事情:
-
将测试库集成到应用模块中。
-
创建
PostAppTestRunner,它将被用来向 Android 仪器测试运行器提供HiltTestApplication。 -
创建一个
ComposeCountingIdlingResource类,它将包装一个 Espresso 的CountingIndlingResource并实现 Compose 的IdlingResource。 -
创建
MockRemotePostDataSource和MockRemoteUserDataSource,它们将负责返回在图 10.4中展示的用户和帖子。 -
创建
IdlingInteractionRepository、IdlingUserRepository和IdlingPostRepository,这些类将装饰InteractionRepository、UserRepository和PostRepository,并使用ComposeCountingIdlingResource,当加载数据时将增加计数,当数据加载完成时将减少计数。 -
创建
IdlingRepositoryModule和MockRemoteDataSourceModule,它们将分别在测试中替换RepositoryModule和RemoteDataSourceModule。 -
创建
MainActivityTest,它将有一个测试,并使用createAndroidComposeRule来断言显示模拟数据列表。
按照以下步骤完成练习:
-
在顶级
build.gradle文件中,添加以下库版本:buildscript { ext { … versions = [ … androidTestCore : "1.4.0", androidTestJunit : "1.1.3", orchestrator : "1.4.1" ] … } -
在相同的文件中,确保添加以下
androidTest依赖项:buildscript { ext { … androidTest = [ junit : "androidx.test.ext :junit:${versions.espressoJunit}", espressoCore : "androidx.test. espresso:espresso-core:${versions. espressoCore}", idlingResource : "androidx.test. espresso:espresso-idling-resource :${versions.espressoCore}", composeUiTestJunit: "androidx.compose. ui:ui-test-junit4:$ {versions.compose}", composeManifest : "androidx.compose .ui:ui-test-manifest:$ {versions.compose}", hilt : "com.google. dagger:hilt-android-testing:$ {versions.hilt}", hiltCompiler : "com.google. dagger:hilt-android-compiler:$ {versions.hilt}", core : "androidx.test: core:${versions.androidTestCore}", runner : "androidx.test: runner:$ {versions.androidTestCore}", rules : "androidx.test: rules:${versions.androidTestCore}", orchestrator : "androidx.test: orchestrator:$ {versions.orchestrator}" ] } … }
在这里,我们正在定义所有将要使用的测试库的映射,以便它们可以在多个模块中使用。
-
在应用模块的
build.gradle文件中,添加所需的测试依赖项:dependencies{ … androidTestImplementation androidTest.junit androidTestImplementation androidTest.espressoCore androidTestImplementation androidTest.idlingResource androidTestImplementation androidTest.core androidTestImplementation androidTest.rules androidTestImplementation androidTest.runner androidTestImplementation androidTest.hilt kaptAndroidTest androidTest.hiltCompiler androidTestImplementation androidTest.composeUiTestJunit debugImplementation androidTest.composeManifest androidTestUtil androidTest.orchestrator } -
在应用模块的
androidTest文件夹中,在java/{package-name}文件夹内创建PostAppTestRunner类:class PostAppTestRunner : AndroidJUnitRunner() { override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application { return super.newApplication(cl, HiltTestApplication::class.java.name, context) } } -
在应用模块的
build.gradle文件中,设置以下测试配置。确保将{package-name}替换为PostAppTestRunner所在的包:android { … defaultConfig { … testInstrumentationRunner "{package-name}. PostAppTestRunner" testInstrumentationRunnerArguments clearPackageData: 'true' testOptions { execution 'ANDROIDX_TEST_ORCHESTRATOR' } } } -
在应用模块的
androidTest文件夹中,在java/{package-name}文件夹内创建以下包 –idling、injection、remote、repository和test。 -
在
idling包内,创建一个名为ComposeCountingIdlingResource的新类:class ComposeCountingIdlingResource(name: String) : IdlingResource { private val countingIdlingResource = CountingIdlingResource(name) override val isIdleNow: Boolean get() = countingIdlingResource.isIdleNow fun increment() = countingIdlingResource. increment() fun decrement() = countingIdlingResource. decrement() }
在这里,我们使用了 Espresso 的CountingIdlingResource类来执行增加、减少和通过isIdleNow方法提供其当前空闲状态逻辑,该方法由 Jetpack Compose 使用。
-
在相同的包中,创建一个名为
IdlingUtils的文件,包含以下方法:fun <T> Flow<T>.attachIdling( countingIdlingResource: ComposeCountingIdlingResource ): Flow<T> { return onStart { countingIdlingResource.increment() }.onEach { countingIdlingResource.decrement() } }
这是一个扩展函数,我们可以在收集Flow之前增加IdlingResource,在Flow的第一个值被发射时减少它。
-
在
repository包中,创建一个名为IdlingInteractionRepository的类:class IdlingInteractionRepository( private val interactionRepository: InteractionRepository, private val countingIdlingResource: ComposeCountingIdlingResource ) : InteractionRepository { override fun getInteraction(): Flow<Interaction> { return interactionRepository.getInteraction() .attachIdling(countingIdlingResource) } override fun saveInteraction(interaction: Interaction): Flow<Interaction> { return interactionRepository. saveInteraction(interaction) .attachIdling(countingIdlingResource) } }
这个类有一个对ComposeCountingIdlingResource对象的引用和之前创建的attachIdling方法,当数据加载或保存时增加计数,当完成这些操作时减少计数。
-
在相同的包中,创建一个名为
IdlingPostRepository的类:class IdlingPostRepository( private val postRepository: PostRepository, private val countingIdlingResource: ComposeCountingIdlingResource ) : PostRepository { override fun getPosts(): Flow<List<Post>> = postRepository.getPosts().attachIdling (countingIdlingResource) override fun getPost(id: Long): Flow<Post> = postRepository.getPost(id). attachIdling(countingIdlingResource) }
在这个片段中,我们遵循上一步的方法。
-
在相同的包中,创建一个名为
IdlingUserRepository的类:class IdlingUserRepository( private val userRepository: UserRepository, private val countingIdlingResource: ComposeCountingIdlingResource ) : UserRepository { override fun getUsers(): Flow<List<User>> = userRepository.getUsers() .attachIdling(countingIdlingResource) override fun getUser(id: Long): Flow<User> = userRepository.getUser(id) .attachIdling(countingIdlingResource) }
在这个片段中,我们遵循上一步的方法。
-
在
injection包中,创建IdlingRepositoryModule类:@Module @TestInstallIn( components = [SingletonComponent::class], replaces = [RepositoryModule::class] ) class IdlingRepositoryModule { } -
在
IdlingRepositoryModule类中,提供一个对ComposeCountingIdlingResource的依赖,这将是一个跨所有仓库的单例:@Module @TestInstallIn( components = [SingletonComponent::class], replaces = [RepositoryModule::class] ) class IdlingRepositoryModule { @Singleton @Provides fun provideIdlingResource(): ComposeCountingIdlingResource = ComposeCountingIdlingResource ("repository-idling") }
在这个片段中,我们提供了一个 ComposeCountingIdlingResource 的单例实例,以便当多个仓库同时加载数据时,将使用相同的计数器为它们所有。
-
在同一文件中,提供一个对
IdlingPostRepository的依赖:@Module @TestInstallIn( components = [SingletonComponent::class], replaces = [RepositoryModule::class] ) class IdlingRepositoryModule { … @Provides fun providePostRepository( remotePostDataSource: RemotePostDataSource, localPostDataSource: LocalPostDataSource, countingIdlingResource: ComposeCountingIdlingResource ): PostRepository = IdlingPostRepository( PostRepositoryImpl( remotePostDataSource, localPostDataSource ), countingIdlingResource ) }
在这个片段中,我们提供了一个 IdlingPostRepository 的实例,它将包装一个 PostRepositoryImpl 的实例,并且有一个对之前定义的 ComposeCountingIdlingResource 实例的引用。
-
在同一文件中,提供一个对
IdlingUserRepository的依赖:@Module @TestInstallIn( components = [SingletonComponent::class], replaces = [RepositoryModule::class] ) class IdlingRepositoryModule { … @Provides fun provideUserRepository( remoteUserDataSource: RemoteUserDataSource, localUserDataSource: LocalUserDataSource, countingIdlingResource: ComposeCountingIdlingResource ): UserRepository = IdlingUserRepository( UserRepositoryImpl( remoteUserDataSource, localUserDataSource ), countingIdlingResource ) }
在这个片段中,我们提供了一个 IdlingUserRepository 的实例,它将包装一个 UserRepositoryImpl 的实例,并且有一个对之前定义的 ComposeCountingIdlingResource 实例的引用。
-
在同一文件中,提供一个对
IdlingInteractionRepository的依赖:@Module @TestInstallIn( components = [SingletonComponent::class], replaces = [RepositoryModule::class] ) class IdlingRepositoryModule { … @Provides fun provideInteractionRepository( interactionDataSource: LocalInteractionDataSource, countingIdlingResource: ComposeCountingIdlingResource ): InteractionRepository = IdlingInteractionRepository( InteractionRepositoryImpl( interactionDataSource ), countingIdlingResource ) }
在这个片段中,我们提供了一个 IdlingInteractionRepository 的实例,它将包装一个 InteractionRepositoryImpl 的实例,并且有一个对之前定义的 ComposeCountingIdlingResource 实例的引用。
-
在
remote包中,创建一个名为MockRemoteUserDataSource的类,并创建一个代表测试数据的User对象列表:class MockRemoteUserDataSource @Inject constructor() : RemoteUserDataSource { private val users = listOf( User( id = 1L, name = "name1", username = "username1", email = "email1" ), User( id = 2L, name = "name2", username = "username2", email = "email2" ) ) override fun getUsers(): Flow<List<User>> = flowOf (users) override fun getUser(id: Long): Flow<User> = flowOf(users[0]) }
在这里,我们创建了一个列表,其中返回两个用户,并将其放入 Flow 的 getUsers 方法中。
-
在同一包中,创建一个名为
MockRemotePostDataSource的类,并创建一个代表测试数据的Post对象列表:class MockRemotePostDataSource @Inject constructor() : RemotePostDataSource { private val posts = listOf( Post( id = 1L, userId = 1L, title = "title1", body = "body1" ), Post( id = 2L, userId = 1L, title = "title2", body = "body2" ), Post( id = 3L, userId = 2L, title = "title3", body = "body3" ), Post( id = 4L, userId = 2L, title = "title4", body = "body4" ) ) override fun getPosts(): Flow<List<Post>> = flowOf(posts) override fun getPost(id: Long): Flow<Post> = flowOf(posts[0]) }
与我们对用户所做的一样,我们创建了一个帖子列表,并将前两个帖子连接到第一个用户,将最后两个帖子连接到第二个用户。
-
在
injection包中,创建一个名为MockRemoteDataSourceModule的类,它将负责将之前的两个实现绑定到抽象:@Module @TestInstallIn( components = [SingletonComponent::class], replaces = [RemoteDataSourceModule::class] ) abstract class MockRemoteDataSourceModule { @Binds abstract fun bindPostDataSource( postDataSourceImpl: MockRemotePostDataSource): RemotePostDataSource @Binds abstract fun bindUserDataSource(userDataSourceImpl : MockRemoteUserDataSource): RemoteUserDataSource } -
在
test包中,创建一个名为MainActivityTest的类:@HiltAndroidTest class MainActivityTest { @get:Rule(order = 0) var hiltAndroidRule = HiltAndroidRule(this) @get:Rule(order = 1) var composeTestRule = createAndroidComposeRule (MainActivity::class.java) @Inject lateinit var idlingResource: ComposeCountingIdlingResource @Before fun setUp() { hiltAndroidRule.inject() composeTestRule. registerIdlingResource(idlingResource) } @After fun tearDown() { composeTestRule.unregisterIdlingResource (idlingResource) } }
在这里,我们正在初始化我们的测试规则,这些规则是针对 Hilt 和 Compose 的,并且按照这个确切顺序。然后,我们将 ComposeCountingIdlingResource 注入到测试类中,以便我们可以将其注册到 Compose 测试规则中。
-
在
MainActivityTest类中,添加一个测试,该测试将断言所需的数据显示在屏幕上:@HiltAndroidTest class MainActivityTest { … @Test fun testDisplayList() { composeTestRule.onNodeWithText("Total click count: 0") .assertIsDisplayed() composeTestRule.onAllNodesWithText("Author: name1") .assertCountEquals(2) composeTestRule.onAllNodesWithText("Author: name2") .assertCountEquals(2) composeTestRule.onNodeWithText("Title: title1") .assertIsDisplayed() composeTestRule.onNodeWithText("Title: title2") .assertIsDisplayed() composeTestRule.onNodeWithText("Title: title3") .assertIsDisplayed() composeTestRule.onNodeWithText("Title: title4") .assertIsDisplayed() } }
在这里,我们添加了一个测试,断言标题文本被显示,每个用户都显示在其帖子中,并且每个帖子都被显示。
如果我们运行测试,我们应该看到以下输出:

图 10.5 – 练习 10.02 的测试输出
作为这项练习的一部分,我们通过更改远程数据源并基于现有功能添加IdlingResources到我们的仓库中,能够在不更改应用现有代码的情况下向应用提供模拟数据。这两种技术都使用了依赖注入,并且由于抽象的存在,我们在执行依赖反转时引入了应用的不同层。这使得应用代码可测试,并为我们提供了测试不同场景和创建各种类型测试的机会,以确保不同组件的集成。
摘要
在本章中,我们分析了之前章节中进行的练习,并发现了应用模块依赖中可能存在的问题。我们研究了这些问题的潜在解决方案。然后,我们探讨了清洁架构的实际应用,即实现仪器化测试,以及我们如何更改应用的数据源以确保测试的可靠性。我们探讨了如何使用 Jetpack Compose 和 Hilt 实现仪器化测试,并提供依赖注入,然后在一个练习中应用了它们,该练习中我们更改了测试的依赖项。这仅仅是一个清洁架构好处的例子。在其他情况下,当使用多个版本发布类似的应用并希望为每个要构建的应用注入不同的实现或配置时,也会有其他好处。另一个好处是在处理多个平台(如 Android 和 iOS)时,我们可以使用跨平台框架无差别地定义实体、用例和仓库,然后注入每个平台检索和持久化数据的具体实现。
在第九章《实现 MVI 架构》中,我们展示了如何在不影响其他层的情况下更改应用的表现层。在清洁应用中,数据层也应该能够做到这一点。我们看到了库是如何随着时间的推移而改变和演化的。当网络库发生变化时,我们应该能够过渡到新的库,而不会在其他应用模块中引起问题。同样的原则也可以应用于本地存储。我们应该能够从 Room 转换到其他本地数据持久化方式。一个关于模块应该如何创建的好规则是,将每个模块视为一个可以发布的库,并想象自己是最终用户。你现在应该对清洁架构应该如何工作、它试图解决的问题以及如何将其应用于 Android 应用有一个很好的了解。


浙公网安备 33010602011771号