Skip to content Skip to footer

常见模块化模式

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

注意: 本页中的建议和最佳实践可应用于各种应用,使其能够扩展、提高质量和稳健性,并更易于测试。但是,您应将其视为指导方针,并根据需要调整以满足您的要求。

高内聚和低耦合原则

表征模块化代码库的一种方法是使用耦合和内聚属性。耦合衡量的是模块之间相互依赖的程度。在此上下文中,内聚衡量的是单个模块中的元素在功能上的关联程度。作为一般规则,你应该追求低耦合和高内聚。

低耦合意味着模块之间应尽可能保持独立,以便对一个模块的更改对其他模块的影响为零或最小。模块不应了解其他模块的内部工作原理。

高内聚意味着模块应包含作为系统运作的代码集合。它们应该有明确定义的职责,并保持在特定领域知识的范围内。以一个电子书应用示例为例,将书籍和支付相关的代码混合在同一个模块中可能不合适,因为它们是两个不同的功能领域。

提示:如果两个模块严重依赖于彼此的知识,这可能是一个很好的信号,表明它们实际上应该作为一个系统运作。相反,如果一个模块的两个部分不经常相互交互,它们可能应该作为单独的模块存在。

模块类型

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

注意:本节假设你熟悉我们应用架构指南中概述的概念。

数据模块

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

封装特定领域的所有数据和业务逻辑:每个数据模块都应负责处理代表特定领域的数据。只要数据相关,它可以处理多种类型的数据。

将存储库作为外部 API 公开:数据模块的公共 API 应该是存储库,因为它们负责将数据公开给应用的其余部分。

向外部隐藏所有实现细节和数据源:数据源只能由同一模块中的存储库访问。它们对外部保持隐藏。你可以通过使用 Kotlin 的 private 或 internal 可见性关键字来强制执行此操作。

图 1. 示例数据模块及其内容。

功能模块

功能(Feature)是应用功能的一个隔离部分,通常对应于一个屏幕或一系列紧密相关的屏幕,例如注册或结账流程。如果你的应用有底部导航栏,那么每个目标页面很可能就是一个功能。

关键术语:“功能模块”(Feature module)一词也用于 Play Feature Delivery,描述可以有条件交付或按需下载的模块。但是,在本指南的上下文中,功能模块是一个封装了应用程序功能的独特部分的模块。

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

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

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

应用模块

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

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

如果你的应用针对多种设备类型(例如 Android Auto、Wear 或 TV),请为每种设备定义一个应用模块。这有助于分离平台特定的依赖项。

图 5. Android Auto 应用依赖关系图。

通用模块

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

UI 模块:如果你在应用中使用自定义 UI 元素或复杂的品牌标识,你应该考虑将小部件集合封装到一个模块中,供所有功能重用。这有助于使你的 UI 在不同功能之间保持一致。例如,如果你的主题是集中管理的,那么当品牌重塑时,你可以避免痛苦的重构。

分析模块:跟踪往往由业务需求决定,几乎不考虑软件架构。分析跟踪器通常在许多不相关的组件中使用。如果是这种情况,建立一个专门的分析模块可能是一个好主意。

网络模块:当许多模块需要网络连接时,你可以考虑拥有一个专门提供 http 客户端的模块。当你的客户端需要自定义配置时,这特别有用。

工具模块:工具(也称为辅助程序)通常是在应用程序中重用的小段代码。工具的示例包括测试辅助程序、货币格式化函数、电子邮件验证器或自定义运算符。

测试模块

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

测试模块的使用场景

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

共享测试代码:如果你的项目中包含多个模块,并且某些测试代码适用于多个模块,你可以创建一个测试模块来共享这些代码。这有助于减少重复并使你的测试代码更易于维护。共享测试代码可以包含工具类或函数(例如自定义断言或匹配器),以及测试数据(例如模拟的 JSON 响应)。

更简洁的构建配置:测试模块允许你拥有更简洁的构建配置,因为它们可以有自己的 build.gradle 文件。你不必用仅与测试相关的配置来弄乱应用模块的 build.gradle 文件。

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

大型应用程序:测试模块对于代码库复杂且包含多个模块的大型应用程序特别有用。在这种情况下,测试模块有助于改善代码组织和可维护性。

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

模块间通信

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

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

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

navController.navigate("checkout/$bookId")

结账目标页面接收一个书籍 ID 作为参数,并使用它获取有关该书籍的信息。你可以使用 已保存状态句柄(saved state handle) 来检索目标功能 ViewModel 中的导航参数。

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

val uiState: StateFlow =

savedStateHandle.getStateFlow("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"))

注意:你可以为不同的构建类型定义不同的依赖项。例如,发布版本可以使用 Firestore 实现,调试版本可以依赖于本地 Room 数据库,而插桩测试可以采用模拟实现。

优势

分离 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 的工具之一来集中管理你的配置:

版本目录 (Version catalogs) 是 Gradle 在同步期间生成的类型安全依赖项列表。它是声明项目中所有依赖项的中心位置,并可供项目中的所有模块使用。

使用约定插件 (convention plugins) 在模块之间共享构建逻辑。

尽量减少公开内容

模块的公共接口应尽可能小,且仅公开必要的内容。它不应将任何实现细节泄露到外部。将所有内容的作用域限制在尽可能小的范围内。使用 Kotlin 的 private 或 internal 可见性范围将声明设为模块私有。在模块中声明依赖项时,优先使用 implementation 而不是 api。后者会将传递依赖项暴露给模块的消费者。使用 implementation 可以缩短构建时间,因为它减少了需要重建的模块数量。

优先使用 Kotlin 和 Java 模块

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

应用模块是应用程序的入口点。它们可以包含源代码、资源、资产和 AndroidManifest.xml。应用模块的输出是 Android App Bundle (AAB) 或 Android 应用程序包 (APK)。

库模块的内容与应用模块相同。它们被其他 Android 模块用作依赖项。库模块的输出是 Android Archive (AAR),在结构上与应用模块相同,但它们被编译为 Android Archive (AAR) 文件,随后可以由其他模块用作依赖项。库模块使得在多个应用模块之间封装和重用相同的逻辑和资源成为可能。

Kotlin 和 Java 库不包含任何 Android 资源、资产或清单文件。

由于 Android 模块带有开销,建议尽可能多地使用 Kotlin 或 Java 类型的模块。

Copyright © 2088 神州国际网游活动库 All Rights Reserved.
友情链接