没有一种 模块化 策略适用于所有项目。由于 Gradle 的灵活性,您在组织项目时几乎没有限制。本页面概述了开发多模块 Android 应用时可以采用的一些通用规则和常见模式。
高内聚低耦合原则
描述模块化代码库的一种方式是使用耦合和内聚属性。耦合衡量模块之间相互依赖的程度。在此上下文中,内聚衡量单个模块的元素在功能上的相关性。一般来说,您应该努力实现低耦合和高内聚。
- 低耦合意味着模块之间应尽可能相互独立,以便一个模块的更改对其他模块的影响为零或最小。模块不应了解其他模块的内部工作原理。
- 高内聚意味着模块应包含一个作为系统运行的代码集合。它们应该具有明确定义的职责,并保持在特定领域知识的范围内。考虑一个电子书示例应用。将图书和支付相关的代码混合在同一个模块中可能不合适,因为它们是两个不同的功能领域。
模块类型
您组织模块的方式主要取决于您的应用架构。下面是一些在遵循我们推荐的应用架构时,您可以在应用中引入的常见模块类型。
数据模块
数据模块通常包含仓库(repository)、数据源(data sources)和模型类(model classes)。数据模块的三个主要职责是
- 封装特定领域的所有数据和业务逻辑:每个数据模块都应该负责处理代表特定领域的数据。只要数据相关,它就可以处理多种类型的数据。
- 将仓库公开为外部 API:数据模块的公共 API 应该是一个仓库,因为它们负责将数据公开给应用的其他部分。
- 向外部隐藏所有实现细节和数据源:数据源应该只能由同一模块中的仓库访问。它们对外部保持隐藏。您可以通过使用 Kotlin 的
private
或internal
可见性关键字来强制执行此操作。

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

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

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

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

公共模块
公共模块,也称为核心模块,包含其他模块经常使用的代码。它们减少了冗余,并且不代表应用架构中的任何特定层。以下是公共模块的示例:
- UI 模块:如果您在应用中使用自定义 UI 元素或精致的品牌标识,您应该考虑将您的小部件集合封装到一个模块中,以便所有功能重用。这有助于使您的 UI 在不同功能之间保持一致。例如,如果您的主题设置是集中式的,那么在品牌重塑时可以避免痛苦的重构。
- 分析模块:跟踪通常由业务需求决定,很少考虑软件架构。分析跟踪器常用于许多不相关的组件。如果是这种情况,那么拥有一个专门的分析模块可能是一个好主意。
- 网络模块:当许多模块需要网络连接时,您可以考虑拥有一个专门用于提供 HTTP 客户端的模块。当您的客户端需要自定义配置时,它特别有用。
- 实用工具模块:实用工具,也称为辅助工具,通常是应用中重复使用的小段代码。实用工具的示例包括测试辅助工具、货币格式化函数、电子邮件验证器或自定义操作符。
测试模块
测试模块是仅用于测试目的的 Android 模块。这些模块包含仅运行测试所需的测试代码、测试资源和测试依赖项,在应用运行时不需要。创建测试模块是为了将特定于测试的代码与主应用分离,使模块代码更易于管理和维护。
测试模块的用例
以下示例展示了实现测试模块特别有益的情况:
共享测试代码:如果您的项目中有多个模块,并且某些测试代码适用于多个模块,您可以创建一个测试模块来共享代码。这有助于减少重复并使您的测试代码更易于维护。共享测试代码可以包括实用工具类或函数,例如自定义断言或匹配器,以及测试数据,例如模拟的 JSON 响应。
更清晰的构建配置:测试模块允许您拥有更清晰的构建配置,因为它们可以有自己的
build.gradle
文件。您不必用仅与测试相关的配置来混淆您的应用模块的build.gradle
文件。集成测试:测试模块可用于存储集成测试,这些测试用于测试应用不同部分之间的交互,包括用户界面、业务逻辑、网络请求和数据库查询。
大型应用:测试模块对于具有复杂代码库和多个模块的大型应用特别有用。在这种情况下,测试模块可以帮助改进代码组织和可维护性。

模块间通信
模块很少完全独立存在,它们通常依赖其他模块并与它们通信。即使模块协同工作并频繁交换信息,保持低耦合也很重要。有时,两个模块之间的直接通信是不可取的,例如在存在架构约束的情况下。它也可能是不可能的,例如存在循环依赖时。

为了解决这个问题,您可以让第三个模块在另外两个模块之间充当中介。中介模块可以监听来自两个模块的消息并根据需要转发它们。在我们的示例应用中,结账屏幕需要知道要购买哪本书,尽管该事件源自属于不同功能的单独屏幕。在这种情况下,中介是拥有导航图的模块(通常是应用模块)。在示例中,我们使用导航通过Navigation组件将数据从主页功能传递到结账功能。
navController.navigate("checkout/$bookId")
结账目的地接收一个图书 ID 作为参数,它使用该 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 并从共享数据模块加载资源。

依赖倒置
依赖倒置是指您组织代码的方式使得抽象与具体实现分离。
- 抽象:定义应用中组件或模块之间如何交互的契约。抽象模块定义了系统的 API,并包含接口和模型。
- 具体实现:依赖于抽象模块并实现抽象行为的模块。
依赖于抽象模块中定义的行为的模块应仅依赖于抽象本身,而不是具体的实现。

示例
想象一个功能模块需要数据库才能工作。功能模块不关心数据库是如何实现的,无论是本地 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 与实现分离会使代码更易于管理。它允许您将代码库分解为更精细、更易理解和更易维护的单元。
如何实现?
要实现依赖倒置,请遵循以下步骤:
- 创建抽象模块:此模块应包含定义您的功能行为的 API(接口和模型)。
- 创建实现模块:实现模块应依赖于 API 模块并实现抽象行为。
图 10. 实现模块依赖于抽象模块。 - 使高层模块依赖于抽象模块:不要直接依赖于特定的实现,而是让您的模块依赖于抽象模块。高层模块不需要知道实现细节,它们只需要契约(API)。
图 11. 高层模块依赖于抽象,而不是实现。 - 提供实现模块:最后,您需要为您的依赖项提供实际实现。具体实现取决于您的项目设置,但应用模块通常是一个很好的选择。要提供实现,请将其指定为您选择的构建变体或测试源集的依赖项。
图 12. 应用模块提供实际实现。
一般最佳实践
正如开头所提到的,开发多模块应用没有唯一正确的方法。正如存在许多软件架构一样,模块化应用也有多种方式。尽管如此,以下一般建议可以帮助您使代码更具可读性、可维护性和可测试性。
保持配置一致
每个模块都会引入配置开销。如果您的模块数量达到一定阈值,管理一致的配置将成为一项挑战。例如,模块使用相同版本的依赖项非常重要。如果您只需要为了升级一个依赖项版本而更新大量模块,这不仅耗费精力,而且容易出错。为了解决这个问题,您可以使用 Gradle 的工具之一来集中管理您的配置:
尽可能少地暴露
模块的公共接口应该最小化,只暴露最基本的内容。它不应该向外泄露任何实现细节。尽可能将所有内容限定在最小范围内。使用 Kotlin 的 private
或 internal
可见性范围来使声明模块私有。在声明依赖项时,优先使用 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 类型的模块。