务实的模块化:连接模块(wiring modules)的妙用

本文译自「Pragmatic Modularization: The Case for Wiring Modules」,原文链接proandroiddev.com/pragmatic-m...,由Alex Krafts发布于2025年11月22日。

如果你正在经历漫长的模块化之旅,你应该已经阅读过官方的Google 开发者指南。你可能已经见过关于提供依赖项的这种具体建议:

"......app 模块通常是添加依赖项的好地方。要提供实现,请将其指定为所选构建变体或测试源集的依赖项。"

下图展示了这种模式的一个常见示例,其中 :app 模块使用 :database:impl:room 作为其 main 源集,并使用 :database:impl:mock 作为 androidTest 源集。这是一种强大且正确的测试依赖项管理方法。

但这种模式存在一个微妙之处 。当这种逻辑应用于大型项目中所有功能实现时,可能会无意中造成构建速度瓶颈,尤其是在处理遗留应用模块时。

kotlin 复制代码
// :app/build.gradle.kts
dependencies {
    implementation(project(":database:api"))
    implementation(project(":database:impl:room")) // <--- This is the trap for incremental builds
}

代码是模块化的,但你的 :app 模块现在与 :database:impl:room 模块构建耦合 。这意味着任何资源更改 (例如添加字符串)或对实现自身的公共接口 的任何更改(例如在 :impl 内部添加一个不属于官方 :api 模块的新的 public 辅助类)都会破坏编译避免机制,并强制整个 :app 模块重新编译,从而抵消模块化带来的主要速度优势。

即使启用了非传递 R 类等优化,直接依赖关系通常也会强制构建系统验证使用者是否与生产者的新资源符号匹配。布线模块充当防火墙,防止频繁的资源检查影响到庞大的 App 模块。

"精简应用"的理想状态与"臃肿应用"的现实

官方建议让 :app 模块提供实现,这非常有效,尤其是在 Google 指南中展示的场景下:为不同的构建版本(例如 mainandroidTest)替换依赖项。

但是,如果将此模式误用作所有功能实现的_通用规则_,尤其是在包含**"臃肿应用"模块**的项目中,就会出现一个微妙的陷阱。

官方指南通常会隐含地假设一种架构理想:"精简应用"模块 。"精简应用"只是一个轻量级的汇编器。它几乎不包含任何代码或资源。它_唯一_的任务是应用 com.android.application 插件并将所有功能模块打包成一个 APK 文件。如果你的 :app 模块很精简,重新编译它既快速又便宜,因此它依赖于 :impl 模块并无大碍。

但对我们大多数人来说,现实情况是**"厚应用"模块**。它是最初的单体应用,仍然充斥着遗留代码、资源和半模块化的功能。

当你的"厚应用"模块直接依赖于某个功能的 :impl 时,例如 :app -> ":database:impl:room",你就创建了一个会拖慢构建速度的瓶颈。你最大、最复杂的模块现在与该功能的内部细节**构建耦合**了。任何对 :impl中资源或实现自身公共接口的更改都会破坏编译规避机制,并强制整个:app` 模块重新编译。

"现在,一个纯粹主义者可能会说:'你应该更加自律,永远不要让你的 :app 模块从 :impl 导入任何东西,即使是公共的。'

他们的说法没错。但是,在一个拥有数十位贡献者的'厚应用'单体应用中,'自律'是一种脆弱的防御手段。而连接模块模式**在构建系统层面强制执行了这种自律。**它使得开发者_不可能_意外地将 :app 模块与某个功能的实现细节耦合在一起,从而默认地保证了构建速度。"

"理想"方案 vs. "赋能"方案

这让团队面临两条道路:

  1. "理想"(但缓慢)方案:纯粹主义者的解决方案是将"臃肿的应用"模块重构为真正"精简"的模块......这才是正确的长期目标,但这需要数年时间,循序渐进。
  2. "赋能"(但快速)方案:这是"连接模块"模式。这种模式并非理想方案的替代方案,而是实现理想方案的催化剂。它提供了团队所需的构建速度和稳定性,使团队有时间和信心真正执行长期的增量重构。

虽然这会增加"模块蔓延",但它以架构的纯粹性换取了工程速度。在大型团队中,速度通常是更关键的指标。

但这种观点将问题简单地二元化了。实际上,这种模式是一种务实的权衡 。你是在有意识地做出权衡。你接受了新的、可控的成本(增加模块数量和配置),以解决一个令人头疼的日常问题(缓慢的增量构建)。这种新的、明确的"连接"债务通常远比"臃肿应用"的单体架构债务成本低得多,后者正在扼杀团队的开发速度。这种构建速度往往正是团队能够拥有时间和稳定性来进行更大规模、更长期的重构的关键所在。

像 Slack、Spotify 和 Uber 这样的大型 Android 代码库都公开讨论过一些模式,这些模式大量依赖于将实现细节隐藏在稳定的 API 之后。这并非权宜之计;这是大型团队保持构建速度快和代码库可维护性的根本方法。

解决方案:"连接模块"模式

这种模式有时被称为聚合模块,但我们更倾向于使用"连接",因为它更能体现其在连接架构中的积极作用。

此解决方案引入了一个新的轻量级**"连接模块"**(例如,:database:wiring)。该模块充当"守门人"或该功能的稳定外观。

其原理很简单:

  1. :app 模块_仅_依赖于新的、稳定的 :database:wiring 模块。
  2. 连接模块_是唯一_了解其自身内部 :impl(以及 :ui:domain 等)模块的模块。
kotlin 复制代码
// :app/build.gradle.kts (Corrected)
dependencies {
    // ...
    implementation(project(":database:wiring")) // NOW it's decoupled!
    implementation(project(":feature:home"))
}
kotlin 复制代码
// :database:wiring/build.gradle.kts (The new "Wiring Module")
plugins {
    id("com.android.library")
}
android {
    namespace = "com.example.database.wiring"
}
dependencies {
    // Link the bindings internally without exposing the API transitively
    implementation(project(":database:api"))

    // Hide the implementation details from the :app module
    implementation(project(":database:impl:room"))
}

这种模式并非免费。这是一个务实的步骤,它能实现你承诺的快速增量构建,赋予你的团队速度和稳定性,从而支持那些规模更大、周期更长的重构。

现在,:app 模块仅依赖于稳定的 wiring 模块。

关于依赖注入的说明:

这个"wiring 模块"不仅仅是一个提升构建速度的技巧;它是你功能依赖注入绑定 (例如 Dagger 或 Hilt 的 @Module)的理想存放位置。它的唯一职责就是将抽象的 :api 连接到具体的 :impl,这正是清晰依赖注入 (DI) 的精髓所在。

这种模式能够确保良好的架构,而构建速度的提升则是这种清晰分离带来的一个绝佳附加效果。

为什么速度如此之快:理解编译避免

这种结构并非权宜之计;它是启用 Gradle 最强大的构建速度提升功能------编译避免------的_正确_方法。

Gradle 团队在其精彩博文"我们实现更快编译的方法"中对此进行了解释。以下是关键要点:

  1. Gradle 检查的是 ABI,而不是实现: 正如博文所述,Gradle 检查的是依赖项的应用程序二进制接口 (ABI) 。ABI 是模块的"公共契约"或"公共结构"------即其公共类和方法签名。它包含方法体等私有实现细节。
  2. 旧方法会破坏这一点: 当你的 :app 模块直接依赖于 :database:impl:room 时,你实际上是在强制 Gradle 将整个实现都视为应用构建的一部分。正如我们之前提到的,:database:impl:room 中任何资源更改 或对实现本身的公共接口 (例如新增的 public 辅助函数)的更改,对于 :app 来说都是 ABI 不兼容的更改,从而导致需要完全重新编译。
  3. 新方法可以解决这个问题: 使用我们的 :database:wiring 模块后:
  • 开发人员更改了 :database:impl:room 中的一个文件。
  • :database:wiring 模块重新编译(由于它很小,所以速度很快)。
  • 至关重要的是 ,正如 Gradle 博客文章中所解释的,这是一个 ABI 兼容的更改 。连接模块的_公共结构_(即其 api 依赖项)并未改变。
  • Gradle 会检查 :app,发现其依赖项的 ABI 完全相同,因此完全跳过重新编译 :app

这篇博文将此称为编译避免(Compilation Avoidance)(完全跳过模块的编译),并将其与_增量编译_(重新编译模块中的_某些_文件)区分开来。这种模式是实现主应用程序模块真正避免编译的最有效方法之一。

实际应用:效果如何

这并非纸上谈兵。我们在生产应用中使用 Gradle Profiler 来测试这种模式旨在解决的具体场景:在某个功能的 :impl 模块中进行破坏 ABI 的更改(例如,添加一个新的公共方法),然后运行增量 :app:assembleDebug 构建。

我们对八个不同的功能模块重复了此操作,并在引入连接模块前后分别对每个功能模块进行了平均 10 次运行。在旧配置中,:app 直接依赖于 :feature:impl,这些增量构建平均耗时约 99 秒 。引入连接模块后,:app 仅依赖于 :feature:wiring,相同场景下的平均耗时降至约 63 秒

在这八个功能模块中,速度提升幅度约为 29%45% ,在这种特定的增量构建场景下,平均提升幅度约为 36%

在进行此次重构时,我恰好在研究是否值得将我的 MacBook 升级到更新的 Apple Silicon 机型。这个对比让我恍然大悟:对于这种工作负载,此次连接模块的更改所带来的构建速度提升,与升级到性能更强劲的高端 MacBook 机型**的效果不相上下。区别在于,这种重构能够有效地让参与项目的每个开发者都感受到"新机器"般的体验------而这仅仅是通过一个小的、有针对性的 Gradle 修改实现的,而非硬件升级。

要点

官方文档的建议在理论上是正确的,但理解实践中的权衡取舍至关重要。

对于任何实际的**"厚应用"模块**而言,直接依赖 :impl 模块通常会导致构建速度瓶颈,因为它将最大的模块与某个功能的内部变更耦合在一起。

保护构建速度至关重要。"模块连接"模式是一种非常实用且低成本的重构方式,它能显著提升日常工作流程的构建速度,而成本通常仅为完整 :app 模块重构的一小部分。

欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!

保护原创,请勿转载!

相关推荐
ji_shuke2 小时前
opencv-mobile 和 ncnn-android 环境配置
android·前端·javascript·人工智能·opencv
sunnyday04264 小时前
Spring Boot 项目中使用 Dynamic Datasource 实现多数据源管理
android·spring boot·后端
幽络源小助理6 小时前
下载安装AndroidStudio配置Gradle运行第一个kotlin程序
android·开发语言·kotlin
inBuilder低代码平台6 小时前
浅谈安卓Webview从初级到高级应用
android·java·webview
豌豆学姐6 小时前
Sora2 短剧视频创作中如何保持人物一致性?角色创建接口教程
android·java·aigc·php·音视频·uniapp
白熊小北极6 小时前
Android Jetpack Compose折叠屏感知与适配
android
HelloBan6 小时前
setHintTextColor不生效
android
洞窝技术8 小时前
从0到30+:智能家居配网协议融合的实战与思考
android