常见的模块化模式

没有一种适合所有项目的 模块化 策略。由于 Gradle 的灵活特性,在组织项目时几乎没有限制。本页概述了在开发多模块 Android 应用时可以使用的一些通用规则和常见模式。

高内聚低耦合原则

描述模块化代码库的一种方法是使用 **耦合** 和 **内聚** 属性。耦合衡量模块之间相互依赖的程度。在此上下文中,内聚衡量单个模块的元素在功能上的相关性。一般来说,您应该努力实现低耦合和高内聚

  • **低耦合** 表示模块应该尽可能相互独立,因此对一个模块的更改对其他模块的影响为零或最小。**模块不应该了解其他模块的内部工作机制**。
  • **高内聚** 表示模块应该包含充当系统的代码集合。它们应该具有明确定义的职责,并保持在特定领域知识的边界内。考虑一个示例电子书应用程序。将书籍和付款相关的代码混合在一个模块中可能不合适,因为它们是两个不同的功能领域。

模块类型

模块的组织方式主要取决于您的应用架构。以下是您在应用中引入的一些常见模块类型,同时遵循我们的 推荐的应用架构

数据模块

数据模块通常包含存储库、数据源和模型类。数据模块的三个主要职责是

  1. 封装特定领域的全部数据和业务逻辑:每个数据模块都应该负责处理代表特定领域的数据。只要它们相关,它就可以处理多种类型的数据。
  2. 将存储库公开为外部 API:数据模块的公共 API 应该是一个存储库,因为它们负责将数据公开到应用的其余部分。
  3. 将所有实现细节和数据源隐藏在外部:数据源只能被同一模块中的存储库访问,对外部保持隐藏。可以使用 Kotlin 的 privateinternal 可见性关键字来强制执行此操作。
图 1. 示例数据模块及其内容。

功能模块

功能是应用程序功能的独立部分,通常对应于一个屏幕或一系列密切相关的屏幕,例如注册或结账流程。如果您的应用程序具有底部栏导航,则每个目标很可能都是一个功能。

图 2. 此应用程序的每个选项卡都可以定义为一个功能。

功能与应用程序中的屏幕或目标相关联。因此,它们很可能具有相关的 UI 和 ViewModel处理它们的逻辑和状态。单个功能不必限于单个视图或导航目标。功能模块依赖于数据模块。

图 3. 示例功能模块及其内容。

应用程序模块

应用程序模块是应用程序的入口点。它们依赖于功能模块,通常提供根导航。单个应用程序模块可以由于 构建变体 而被编译成多个不同的二进制文件。

图 4. *Demo* 和 *Full* 产品风格模块依赖关系图。

如果您的应用程序针对多种设备类型,例如汽车、可穿戴设备或电视,请为每种类型定义一个应用程序模块。这有助于分离特定于平台的依赖项。

图 5. 可穿戴应用程序依赖关系图。

通用模块

通用模块,也称为核心模块,包含其他模块经常使用的代码。它们减少冗余,并且不代表应用程序体系结构中的任何特定层。以下是通用模块的示例

  • UI 模块:如果您的应用程序使用自定义 UI 元素或详细的品牌,您应该考虑将小部件集合封装到一个模块中,供所有功能重复使用。这可以帮助您的 UI 在不同的功能之间保持一致。例如,如果您的主题是集中的,那么当重新品牌时,您可以避免痛苦的重构。
  • 分析模块:跟踪通常由业务需求决定,很少考虑软件体系结构。分析跟踪器经常用于许多不相关的组件。如果这对您来说是这种情况,那么拥有一个专门的分析模块可能是一个好主意。
  • 网络模块:当许多模块需要网络连接时,您可能需要考虑拥有一个专门提供 http 客户端的模块。当您的客户端需要自定义配置时,它特别有用。
  • 实用程序模块:实用程序,也称为帮助程序,通常是跨应用程序重复使用的小段代码。实用程序的示例包括测试帮助程序、货币格式函数、电子邮件验证器或自定义运算符。

测试模块

测试模块 是 Android 模块,仅用于测试目的。这些模块包含测试代码、测试资源和测试依赖项,这些依赖项仅在运行测试时需要,在应用程序运行时不需要。创建测试模块是为了将特定于测试的代码与主应用程序分离,从而使模块代码更易于管理和维护。

测试模块的用例

以下示例展示了实现测试模块可能特别有益的情况

  • 共享测试代码:如果您的项目中有多个模块,并且一些测试代码适用于多个模块,则可以创建一个测试模块来共享代码。这可以帮助减少重复并使您的测试代码更容易维护。共享测试代码可以包括实用程序类或函数,例如自定义断言或匹配器,以及测试数据,例如模拟的 JSON 响应。

  • 更清晰的构建配置:测试模块允许您拥有更清晰的构建配置,因为它们可以有自己的 build.gradle 文件。您不必将应用程序模块的 build.gradle 文件弄得乱七八糟,因为这些配置只与测试相关。

  • 集成测试:测试模块可用于存储用于测试应用程序不同部分之间交互的集成测试,包括用户界面、业务逻辑、网络请求和数据库查询。

  • 大型应用程序:对于具有复杂代码库和多个模块的大型应用程序,测试模块特别有用。在这种情况下,测试模块可以帮助提高代码组织和可维护性。

图 6. 测试模块可用于隔离原本相互依赖的模块。

模块间通信

模块很少完全分离,并且经常依赖于其他模块并与其通信。即使模块协同工作并频繁交换信息,也必须保持低耦合。有时,两个模块之间的直接通信要么不可取,例如在体系结构约束的情况下。它也可能不可能,例如在循环依赖的情况下。

图 7. 由于循环依赖,模块之间的直接双向通信是不可能的。需要一个中介模块来协调两个其他独立模块之间的数据流。

为了克服这个问题,您可以在两个其他模块之间有一个第三个模块 中介。中介模块可以监听来自这两个模块的消息,并在需要时转发它们。在我们的示例应用程序中,结账屏幕需要知道要购买哪本书,即使该事件起源于属于不同功能的另一个屏幕。在这种情况下,中介是拥有导航图的模块(通常是应用程序模块)。在示例中,我们使用导航将数据从主页功能传递到结账功能,使用 导航 组件。

navController.navigate("checkout/$bookId")

结账目标接收一个书籍 ID 作为参数,它用于获取有关书籍的信息。您可以使用 保存状态句柄 在目标功能的 ViewModel 中检索导航参数。

class CheckoutViewModel(savedStateHandle: SavedStateHandle, …) : ViewModel() {

   val uiState: StateFlow<CheckoutUiState> =
      savedStateHandle.getStateFlow<String>("bookId", "").map { bookId ->
          // produce UI state calling bookRepository.getBook(bookId)
      }
      …
}

您不应该将对象作为导航参数传递。相反,请使用简单的 ID,功能可以使用这些 ID 从数据层访问和加载所需资源。这样,您可以保持低耦合,并且不会违反单一事实来源原则。

在下面的示例中,两个功能模块都依赖于同一个数据模块。这使得可以最大程度地减少中介模块需要转发的數據量,并保持模块之间的低耦合。模块不应该传递对象,而应该交换原始 ID 并从共享数据模块加载资源。

图 8. 两个依赖于共享数据模块的功能模块。

依赖倒置

依赖倒置是当您组织代码时,抽象与具体实现分离。

  • 抽象:一个定义应用程序中的组件或模块如何相互交互的契约。抽象模块定义了系统的 API,并包含接口和模型。
  • 具体实现:依赖于抽象模块并实现抽象行为的模块。

依赖于抽象模块中定义的行为的模块应该只依赖于抽象本身,而不是依赖于特定的实现。

图 9. 高级模块不直接依赖于低级模块,而是高级模块和实现模块都依赖于抽象模块。

示例

想象一个需要数据库才能工作的功能模块。功能模块并不关心数据库是如何实现的,无论是本地 Room 数据库还是远程 Firestore 实例。它只需要存储和读取应用程序数据。

为了实现这一点,功能模块依赖于抽象模块,而不是依赖于特定的数据库实现。此抽象定义了应用程序的数据库 API。换句话说,它设置了与数据库交互的规则。这使功能模块可以使用任何数据库,而无需了解其底层实现细节。

具体实现模块提供了抽象模块中定义的 API 的实际实现。为了做到这一点,实现模块也依赖于抽象模块。

依赖项注入

到目前为止,您可能想知道功能模块是如何与实现模块连接的。答案是 依赖注入。功能模块不会直接创建所需的数据库实例。相反,它指定了它需要哪些依赖项。这些依赖项随后在外部提供,通常在 应用程序模块 中。

releaseImplementation(project(":database:impl:firestore"))

debugImplementation(project(":database:impl:room"))

androidTestImplementation(project(":database:impl:mock"))

好处

将 API 与其实现分离的好处如下

  • 可互换性:通过清晰地分离 API 和实现模块,您可以为同一个 API 开发多个实现,并在它们之间切换,而无需更改使用该 API 的代码。这在您希望在不同上下文中提供不同的功能或行为的情况下特别有用。例如,用于测试的模拟实现与用于生产的真实实现。
  • 解耦:分离意味着使用抽象的模块不依赖于任何特定技术。如果您选择稍后将数据库从 Room 更改为 Firestore,这将更容易,因为更改只会发生在执行此任务的特定模块(实现模块)中,不会影响使用数据库 API 的其他模块。
  • 可测试性:将 API 与其实现分离可以极大地促进测试。您可以针对 API 契约编写测试用例。您还可以使用不同的实现来测试各种场景和边缘情况,包括模拟实现。
  • 改进构建性能:当您将 API 及其实现分离到不同的模块中时,实现模块中的更改不会强制构建系统重新编译依赖于 API 模块的模块。这会导致更快的构建时间和更高的生产力,尤其是在构建时间可能很长的大型项目中。

何时分离

在以下情况下,将 API 与其实现分离将是有益的

  • 多种功能:如果您可以在多种方式下实现系统的一部分,那么清晰的 API 允许不同实现的可互换性。例如,您可能有一个使用 OpenGL 或 Vulkan 的渲染系统,或者一个与 Play 或您的内部计费 API 协同工作的计费系统。
  • 多个应用程序:如果您正在为不同的平台开发多个具有共享功能的应用程序,则可以定义通用 API 并为每个平台开发特定实现。
  • 独立团队:分离允许不同的开发人员或团队同时处理代码库的不同部分。开发人员应该专注于理解 API 契约并正确使用它们。他们不需要担心其他模块的实现细节。
  • 大型代码库:当代码库很大或很复杂时,将 API 与实现分离可以使代码更易于管理。它允许您将代码库分解为更细粒度、更易理解和更易维护的单元。

如何实现?

要实现依赖倒置,请执行以下步骤

  1. 创建抽象模块:此模块应包含定义功能行为的 API(接口和模型)。
  2. 创建实现模块:实现模块应依赖于 API 模块并实现抽象的行为。
    Instead of high level modules depending on low level modules directly, high level and implementation modules depend on the abstraction module.
    图 10. 实现模块依赖于抽象模块。
  3. 使高级模块依赖于抽象模块:不要直接依赖于特定实现,而是使您的模块依赖于抽象模块。高级模块不需要了解实现细节,它们只需要契约(API)。
    High level modules depend on abstractions, not implementation.
    图 11. 高级模块依赖于抽象,而不是实现。
  4. 提供实现模块:最后,您需要为依赖项提供实际的实现。具体的实现取决于您的项目设置,但 应用程序模块 通常是一个不错的选择。要提供实现,请将其指定为 所选构建变体或测试源集的依赖项
    App module provides actual implementation.
    图 12. 应用程序模块提供实际实现。

一般最佳实践

如开头所述,没有一种单一正确的开发多模块应用程序的方法。就像存在许多软件架构一样,也存在许多模块化应用程序的方法。但是,以下一般建议可以帮助您使代码更易读、更易维护和更易测试。

保持配置一致

每个模块都会引入配置开销。如果模块数量达到一定阈值,管理一致的配置将成为一项挑战。例如,模块使用相同版本的依赖项很重要。如果您需要更新大量模块只是为了升级依赖项版本,这不仅需要付出努力,而且还可能出错。为了解决这个问题,您可以使用 Gradle 的工具之一来集中管理您的配置

  • 版本目录 是 Gradle 在同步过程中生成的一组类型安全的依赖项。它是一个声明所有依赖项的中心位置,可供项目中的所有模块使用。
  • 使用 约定插件 在模块之间共享构建逻辑。

尽可能少地公开

模块的公共接口应该最小,并且只公开必需的内容。它不应该将任何实现细节泄露到外部。尽可能缩小一切范围。使用 Kotlin 的 privateinternal 可见性范围使声明对模块私有。当 在模块中声明依赖项 时,优先使用 implementation 而不是 api。后者将传递依赖项公开给模块的使用者。使用 implementation 可以缩短构建时间,因为它减少了需要重新构建的模块数量。

优先使用 Kotlin 和 Java 模块

Android Studio 支持三种基本类型的模块

  • 应用程序模块是应用程序的入口点。它们可以包含源代码、资源、资产和 AndroidManifest.xml。应用程序模块的输出是 Android App Bundle (AAB) 或 Android Application Package (APK)。
  • 库模块 的内容与应用程序模块相同。它们被其他 Android 模块用作依赖项。库模块的输出是 Android Archive (AAR),其结构与应用程序模块相同,但它们被编译为 Android Archive (AAR) 文件,该文件随后可以被其他模块用作 依赖项。库模块使跨多个应用程序模块封装和重用相同的逻辑和资源成为可能。
  • Kotlin 和 Java 库 不包含任何 Android 资源、资产或清单文件。

由于 Android 模块会带来开销,因此您最好尽可能使用 Kotlin 或 Java 模块。