组件化 Gradle 依赖治理:Version Catalogs、api/implementation 边界与循环依赖根治
当一个 Android 工程从单模块演变为拥有 20、50 甚至上百个模块的组件化架构时,依赖管理就从一个"配置项"升级为一个"架构问题"。模块 A 用了 Retrofit 2.9,模块 B 还停留在 2.7;core:network 升级了一个内部依赖,牵连 15 个下游模块全部重新编译;两个业务模块互相依赖导致构建失败——这些都不是 Bug,而是依赖治理缺位的系统性症状。
本文将从 Gradle 依赖系统的内部机制出发,深入解析三个核心课题:api / implementation 等配置作用域的编译器级原理、Version Catalogs 的工作机制与最佳实践,以及循环依赖的架构级根治方案。
Gradle 依赖配置的底层模型:Configuration 与 Classpath
要理解 api 和 implementation 的区别,不能停留在"一个传递、一个不传递"的表层——需要先理解 Gradle 依赖系统的底层抽象:Configuration。
Configuration:依赖的"管道"
在 Gradle 的内部模型中,Configuration 是一个核心概念。它既不是一个简单的列表,也不是一个文件夹——更像是一条管道,连接着"依赖声明"和"构建任务"。
Configuration 就像供水系统里的管道网络。
implementation、api、compileOnly是不同的进水口(声明型管道),开发者往这些进水口里投放依赖;compileClasspath和runtimeClasspath是出水口(解析型管道),编译器和运行时从出水口取用所需的库。进水口和出水口之间通过extendsFrom机制连接——哪些进水口的水能流到哪个出水口,决定了依赖在不同阶段的可见性。
Gradle 将 Configuration 分为两类:
| 类型 | 角色 | 属性 | 示例 |
|---|---|---|---|
| 声明型(Declarable) | 供开发者声明依赖 | canBeResolved = false |
implementation、api、compileOnly、runtimeOnly |
| 解析型(Resolvable) | 供构建任务消费依赖 | canBeResolved = true |
compileClasspath、runtimeClasspath |
开发者永远不会直接操作解析型 Configuration——它们由 Gradle 插件(如 java-library、com.android.library)在幕后通过 extendsFrom 自动组装。
extendsFrom:管道的拓扑连接
Gradle 的 Java Library 插件使用 extendsFrom 构建出以下拓扑关系:
声明型管道 解析型管道
compileOnly ─────────────────→ compileClasspath
↑
api ──────────────────────────→ compileClasspath
│
└──────────────────────────→ runtimeClasspath
↑
implementation ──────────────→ compileClasspath (仅当前模块可见)
│
└──────────────────────────→ runtimeClasspath
↑
runtimeOnly ─────────────────→ runtimeClasspath
这段拓扑揭示了一个核心设计:implementation 声明的依赖会同时进入当前模块的编译和运行类路径,但不会泄漏到下游模块的编译类路径。这才是 implementation 的精确语义。
在 Gradle 源码中,这个拓扑由 DefaultConfiguration 类管理。每个 Configuration 维护一个 extendsFrom 集合,解析时递归收集所有上游管道中的依赖,汇总后进行版本冲突解决。
api vs implementation:编译隔离的工程威力
在概览文章中已经介绍了 api 和 implementation 的基本区别。这里从 Gradle 构建引擎的视角,彻底讲清它们在多模块场景下对编译行为的影响。
编译类路径(Compile Classpath)的差异
考虑一个三层依赖链:模块 A → 模块 B → 模块 C。
场景一:B 使用 api(C)
A 的 compileClasspath: [B 的类] + [C 的类] ← A 编译时能看到 C
A 的 runtimeClasspath: [B 的类] + [C 的类] ← A 运行时能用 C
→ A 的代码可以直接 import C 的类
→ C 的任何公共 API 变更,都会触发 A 的重新编译
场景二:B 使用 implementation(C)
A 的 compileClasspath: [B 的类] ← A 编译时看不到 C
A 的 runtimeClasspath: [B 的类] + [C 的类] ← A 运行时仍然能用 C
→ A 的代码不能 import C 的类(编译报错)
→ C 的内部变更,不会触发 A 的重新编译
为什么运行时类路径总是包含所有依赖? 因为 JVM 在运行时需要加载所有实际使用的类。即使 A 不直接引用 C,B 的代码在运行时会调用 C,所以 C 的 .class 文件必须在最终的 APK 中。implementation 隔离的是编译时的可见性,不是运行时的存在性。
ABI 变更检测:implementation 加速编译的底层机制
Gradle 的增量编译引擎使用 ABI(Application Binary Interface)变更检测来判断是否需要重新编译下游模块。ABI 包含一个模块所有公共 API 的"指纹"——公共类、公共方法签名、公共字段类型等。
C 的源码发生修改
│
▼
Gradle 提取 C 的新 ABI 指纹
│
▼
ABI 指纹是否与上次不同?
╱ ╲
是 否
↓ ↓
B 需要重新编译 B 不需要重新编译
│ (增量跳过)
▼
B 使用了 api(C) 还是 implementation(C)?
╱ ╲
api implementation
↓ ↓
A 也要重新编译 A 不需要重新编译
(因为 A 可能 (因为 Gradle 知道
引用了 C 的类) A 看不到 C 的类)
在一个拥有 50 个模块的工程中,如果所有模块都使用 api 传递依赖,底层基础库的一次修改可能触发全部 50 个模块的重新编译。而正确使用 implementation,可以将影响范围限制在直接相关的少数模块中——这就是组件化工程中编译速度差异 10 倍的根本原因。
什么时候必须用 api?
规则很简单:当你的公共 API 的参数类型、返回值类型、或继承的基类/接口来自某个依赖时,必须用 api。
// :core:network 模块
// 情况一:必须用 api
// 因为 getUsers() 的返回类型 Flow 来自 kotlinx-coroutines
// 依赖 :core:network 的模块必须在编译时看到 Flow 的类型定义
class UserRepository {
fun getUsers(): Flow<List<User>> = flow { /* ... */ }
// ↑ Flow 来自 kotlinx-coroutines-core
}
// 情况二:应该用 implementation
// 因为 OkHttp 只在内部使用,不暴露给外部
// 依赖 :core:network 的模块不需要知道底层用了 OkHttp
internal class NetworkClient {
private val client = OkHttpClient() // OkHttp 是内部实现细节
// ↑ 外部看不到这个类型
}
对应的 build.gradle.kts:
// :core:network/build.gradle.kts
dependencies {
// Flow 出现在公共 API 中 → 必须用 api
api(libs.kotlinx.coroutines.core)
// OkHttp 只在 internal 类中使用 → 用 implementation
implementation(libs.okhttp)
}
全量依赖配置作用域详解
除了 api 和 implementation,Gradle 还提供了一组精细化的依赖作用域。理解每种作用域的语义,才能做出正确的依赖声明。
六种核心配置的交叉对比
| 配置 | 加入 compileClasspath? | 加入 runtimeClasspath? | 打入最终 APK? | 典型场景 |
|---|---|---|---|---|
api |
✅ | ✅ | ✅ | 公共 API 暴露的类型 |
implementation |
✅(仅本模块) | ✅ | ✅ | 大多数依赖的默认选择 |
compileOnly |
✅ | ❌ | ❌ | 编译时注解、Provided API |
runtimeOnly |
❌ | ✅ | ✅ | 日志实现、数据库驱动 |
ksp / annotationProcessor |
仅编译器阶段 | ❌ | ❌ | 代码生成(Room、Hilt) |
testImplementation |
✅(仅测试) | ✅(仅测试) | ❌ | 测试框架(JUnit、Mockk) |
compileOnly 的精确语义
compileOnly 告诉 Gradle:这个依赖只在编译时需要,运行时由宿主环境提供(或者根本不需要)。
dependencies {
// Lombok:编译时生成 getter/setter 代码,运行时不需要 Lombok 的类
compileOnly(libs.lombok)
// JSR 305 注解(@Nullable 等):仅用于静态分析,运行时不需要
compileOnly(libs.findbugs.jsr305)
}
一个易犯的错误是把 Compose Compiler 插件声明为 compileOnly。Compose 编译器确实只在编译时工作,但它通过 Gradle 插件机制(plugins {} 块)接入,不需要也不应该出现在 dependencies 块中。
runtimeOnly 的精确语义
runtimeOnly 是 compileOnly 的镜像——编译时不可见,运行时才注入。这个配置的典型场景是接口-实现分离模式:
dependencies {
// SLF4J API:编译时引用接口
implementation(libs.slf4j.api)
// Logback:运行时注入实现,代码中不直接引用 Logback 的类
runtimeOnly(libs.logback.classic)
}
ksp vs annotationProcessor vs kapt
代码生成工具的配置经历了三代演进:
annotationProcessor (Java APT)
↓
kapt (Kotlin 桥接方案:先生成 Java 存根,再走 APT)
↓
ksp (Kotlin 原生方案:直接分析 Kotlin 符号表)
| 维度 | annotationProcessor |
kapt |
ksp |
|---|---|---|---|
| 语言支持 | 仅 Java | Java + Kotlin | Java + Kotlin |
| 实现机制 | Java 编译器 APT | 生成 Java 存根 → APT | 直接分析 Kotlin Compiler IR |
| 编译速度 | 基准 | 比 APT 慢 30~50% | 比 kapt 快 2x |
| 增量编译 | 有限支持 | 不支持(全量) | 完整支持(增量) |
| 状态 | Java 项目仍可用 | 官方标记为维护模式 | 推荐方案 |
在 Kotlin 项目中,应该将所有支持 KSP 的库从 kapt 迁移到 ksp:
dependencies {
// ✗ 旧方案:kapt 需要生成 Java 存根,编译慢
// kapt(libs.room.compiler)
// kapt(libs.hilt.compiler)
// ✓ 新方案:KSP 直接分析 Kotlin 符号,编译快 2x
ksp(libs.room.compiler)
ksp(libs.hilt.compiler)
}
Version Catalogs 深度解析:统一版本管理的工业级方案
在组件化工程中,50 个模块各自硬编码依赖版本——这是一个定时炸弹。模块 A 用 Retrofit 2.9.0,模块 B 用 Retrofit 2.7.2,运行时可能出现诡异的 NoSuchMethodError。Version Catalogs 正是 Gradle 提供的统一解药。
Version Catalogs 是什么?
Version Catalogs 是 Gradle 从 7.0 开始引入(7.4 正式稳定)的集中式依赖版本声明机制。它用一个 TOML 格式的文件——gradle/libs.versions.toml——作为整个工程的依赖版本真相来源(Single Source of Truth)。
Version Catalogs 就像一个中央药房。以前每个科室(模块)自己采购药品(依赖),品牌不同、剂量不同、过期时间不同。引入中央药房后,所有药品由药房统一采购、统一管理版本、统一分发——每个科室只需要在处方(
build.gradle.kts)上写药品名,药房自动配药。
TOML 文件的四大区段
libs.versions.toml 由四个区段组成,各司其职:
# ═══════════════════════════════════════════
# [versions] — 版本号声明区
# 集中管理所有版本号,避免「同一个库在不同模块中版本不一致」
# ═══════════════════════════════════════════
[versions]
kotlin = "2.0.21"
agp = "8.7.3"
compose-bom = "2024.12.01"
retrofit = "2.11.0"
okhttp = "4.12.0"
room = "2.6.1"
hilt = "2.52"
coroutines = "1.9.0"
navigation = "2.8.5"
# ═══════════════════════════════════════════
# [libraries] — 库坐标声明区
# 将 GAV(Group:Artifact:Version)坐标映射为语义化的别名
# version.ref 引用 [versions] 区的变量,实现版本复用
# ═══════════════════════════════════════════
[libraries]
# AndroidX 核心
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version = "1.15.0" }
androidx-lifecycle-runtime = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version = "2.8.7" }
# Compose BOM:通过 BOM 统一 Compose 生态的所有版本
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
compose-ui = { group = "androidx.compose.ui", name = "ui" }
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
# 网络层
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
# 数据层
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
# DI
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
# Kotlin
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
# ═══════════════════════════════════════════
# [bundles] — 依赖分组区
# 将逻辑上经常一起使用的库打包为一个 bundle
# 模块中可以一行引入整组依赖
# ═══════════════════════════════════════════
[bundles]
compose = ["compose-ui", "compose-material3"]
retrofit = ["retrofit", "retrofit-gson", "okhttp-logging"]
room = ["room-runtime", "room-ktx"]
coroutines = ["kotlinx-coroutines-core", "kotlinx-coroutines-android"]
# ═══════════════════════════════════════════
# [plugins] — 插件声明区
# 集中管理 Gradle 插件的 ID 和版本
# ═══════════════════════════════════════════
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
ksp = { id = "com.google.devtools.ksp", version = "2.0.21-1.0.28" }
内部工作机制:从 TOML 到类型安全访问器
Version Catalogs 的魔力在于:定义了文本格式的 TOML 文件后,Gradle 自动生成了类型安全的 Kotlin 访问器,提供完整的 IDE 补全和编译时检查。
其内部工作流程如下:
gradle/libs.versions.toml
│
▼
① Gradle 在 settings 阶段解析 TOML
└─ 构建 VersionCatalog 内部模型
│
▼
② 别名归一化处理
└─ kebab-case (retrofit-gson)
→ dot-notation (libs.retrofit.gson)
└─ 分隔符 -、_、. 统一转换为属性访问的层级
│
▼
③ 代码生成阶段(Configuration Phase)
└─ 生成 LibrariesForLibs 类(类型安全访问器)
└─ 每个别名映射为一个属性方法
└─ 返回 MinimalExternalModuleDependency 对象
│
▼
④ 模块的 build.gradle.kts 中使用
└─ libs.retrofit → "com.squareup.retrofit2:retrofit:2.11.0"
└─ libs.bundles.room → [room-runtime, room-ktx]
└─ 完整的 IDE 自动补全 + 编译时类型检查
为什么选择 TOML 格式? TOML(Tom's Obvious Minimal Language)是一种设计为"天然映射到哈希表"的配置格式。相比 JSON 它支持注释,相比 YAML 它的规则简单不易出错。Gradle 团队选择 TOML 正是因为依赖版本管理的数据结构天然适合键值对映射。
在模块中使用 Version Catalogs
有了 TOML 文件后,模块的 build.gradle.kts 变得极其简洁:
// :feature:order/build.gradle.kts
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.hilt.android)
alias(libs.plugins.ksp)
}
android {
namespace = "com.example.feature.order"
}
dependencies {
// 单个库的引用
implementation(libs.androidx.core.ktx)
// 通过 bundle 一次引入一组相关依赖
implementation(libs.bundles.compose)
implementation(libs.bundles.retrofit)
// 使用 BOM 对齐 Compose 生态的所有版本
implementation(platform(libs.compose.bom))
// DI
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
// Room
implementation(libs.bundles.room)
ksp(libs.room.compiler)
}
BOM(Bill of Materials):生态级版本对齐
Bundle 解决的是"打包引入一组库",BOM 解决的是"确保一组库的版本互相兼容"。
dependencies {
// 引入 Compose BOM 作为 platform
// BOM 自身不会被添加到依赖中,它只提供版本约束
implementation(platform(libs.compose.bom))
// 引入 Compose 库时不需要指定版本——版本由 BOM 统一管理
implementation("androidx.compose.ui:ui") // 版本由 BOM 决定
implementation("androidx.compose.material3:material3") // 版本由 BOM 决定
implementation("androidx.compose.ui:ui-tooling-preview") // 版本由 BOM 决定
}
BOM 在 Gradle 内部的机制是:platform() 将 BOM 声明为一个虚拟平台,其 <dependencyManagement> 区段中声明的版本被当作**依赖约束(Dependency Constraints)**注入到解析过程中。这些约束的优先级低于显式声明的版本,但高于传递依赖携带的版本。
BOM 声明的版本约束
↓
┌─────────────────────────────────────┐
│ Gradle 依赖解析器(Resolution Engine)│
│ │
│ 输入: │
│ ① 模块直接声明的依赖和版本 │
│ ② 传递依赖携带的版本 │
│ ③ BOM 注入的版本约束 │
│ │
│ 规则(优先级从高到低): │
│ 1. strictly() 强制版本 │
│ 2. 模块直接声明的版本 │
│ 3. BOM 约束 │
│ 4. 传递依赖的版本(取最高) │
└─────────────────────────────────────┘
Gradle 依赖解析引擎:冲突解决的内部算法
当多个模块声明了同一个库的不同版本时,Gradle 必须选择一个最终版本。这个决策过程由依赖解析引擎执行。
默认策略:"最高版本胜出"
Gradle 的默认冲突解决策略是选择图中出现的最高版本(Newest Wins)。
:feature:login ──→ retrofit:2.9.0
:feature:order ──→ retrofit:2.11.0
:core:network ──→ retrofit:2.10.0
Gradle 解析结果:retrofit:2.11.0(最高版本胜出)
这个策略基于一个假设:高版本通常向后兼容。大多数情况下这个假设成立,但当高版本引入了 Breaking Change 时,你的 :feature:login 可能在运行时崩溃——因为它的代码是基于 2.9.0 的 API 编写的。
Rich Version 声明:精细化版本控制
Gradle 提供了一套"富版本声明"语法,允许比"写一个版本号"更精细的控制:
dependencies {
implementation("com.squareup.retrofit2:retrofit") {
version {
// strictly:最强约束——必须使用这个范围,否则构建失败
// 用于锁定版本或阻止不兼容的高版本
strictly("[2.9, 2.12[") // ≥ 2.9 且 < 2.12
// require:标准约束——至少需要这个版本,但允许更高
require("2.11.0")
// prefer:柔性约束——优先选择这个版本,但不强制
prefer("2.11.0")
// reject:排除特定版本
reject("2.10.0") // 已知这个版本有 Bug
// because:记录约束原因——出现在错误信息中
because("2.10.0 存在序列化 Bug,2.12+ 的 API 不兼容")
}
}
}
四种约束的优先级关系:
strictly > require > prefer > 自动解析(最高版本)
strictly ──→ "必须是"(违反则构建失败)
require ──→ "至少是"(允许更高,但不低于此版本)
prefer ──→ "最好是"(仅作为偏好提示)
reject ──→ "绝对不是"(排除黑名单版本)
依赖约束(Dependency Constraints)
除了在具体依赖声明中控制版本,Gradle 还支持通过约束块统一施加版本限制:
dependencies {
constraints {
// 为所有模块统一设置 OkHttp 的最低版本
implementation("com.squareup.okhttp3:okhttp:4.12.0") {
because("4.11.0 存在连接泄漏问题")
}
}
}
约束和直接依赖的区别在于:约束不会引入依赖,只在依赖已经被引入后影响版本选择。这适用于在根项目或 Convention Plugin 中统一管理传递依赖的版本。
dependencyInsight:依赖解析的 X 光透视
当依赖版本不符合预期时,dependencyInsight 是最强大的诊断工具:
# 查看 retrofit 的版本是如何被解析的
./gradlew :feature:order:dependencyInsight \
--dependency retrofit \
--configuration compileClasspath
输出会显示完整的"判决书"——谁请求了什么版本,最终选了哪个版本,以及为什么:
com.squareup.retrofit2:retrofit:2.11.0
variant "apiElements" [
org.gradle.status = release
]
com.squareup.retrofit2:retrofit:2.11.0
\--- compileClasspath
com.squareup.retrofit2:retrofit:2.9.0 -> 2.11.0
\--- project :feature:login
\--- compileClasspath
com.squareup.retrofit2:retrofit:2.10.0 -> 2.11.0
\--- project :core:network
\--- project :feature:order
\--- compileClasspath
-> 箭头清晰地展示了版本升级路径::feature:login 请求了 2.9.0,但最终被解析为 2.11.0。
依赖锁定(Dependency Locking):确保构建可重现
在 CI 环境中,即使你的 build.gradle.kts 没变,如果某个传递依赖发布了新版本,构建结果可能不同。依赖锁定通过记录每次成功构建时的完整依赖版本快照,确保构建的可重现性(Reproducibility)。
// 在根项目的 build.gradle.kts 中启用锁定
dependencyLocking {
lockAllConfigurations()
}
# 生成锁文件
./gradlew dependencies --write-locks
# 生成的 gradle.lockfile 内容示例:
# com.squareup.retrofit2:retrofit:2.11.0=compileClasspath,runtimeClasspath
# com.squareup.okhttp3:okhttp:4.12.0=compileClasspath,runtimeClasspath
# ...
锁文件应该提交到 Git。启用锁定后,如果实际解析的版本与锁文件不一致,构建会直接失败——这迫使开发者必须有意识地更新依赖,而不是被传递依赖的静默升级"偷袭"。
Convention Plugins 与 build-logic:构建配置的 DRY 原则
当 50 个模块都需要配置相同的 compileSdk、minSdk、Kotlin 编译选项、Compose 编译器参数——每个模块的 build.gradle.kts 都重复这些代码,违反了 **DRY(Don't Repeat Yourself)**原则。Convention Plugins 是解决这个问题的工业级方案。
buildSrc 的陷阱与 build-logic 的优势
Gradle 最初推荐的共享构建逻辑方案是 buildSrc 目录。但在大型工程中,buildSrc 有一个致命缺陷:buildSrc 中任何文件的修改都会使整个工程的构建缓存失效,触发全量重新配置。
build-logic 通过 Gradle 的**组合构建(Composite Build)**机制解决了这个问题:
buildSrc(旧方案) build-logic(新方案)
│ │
▼ ▼
作为工程的特殊子目录 作为独立的 Gradle 构建
编译产物没有缓存隔离 通过 includeBuild 引入
任何文件修改 → 全量配置 有独立的构建缓存
文件修改 → 增量配置
build-logic 的项目结构
项目根目录/
├── build-logic/
│ ├── settings.gradle.kts # build-logic 自己的 settings
│ └── convention/
│ ├── build.gradle.kts # 声明对 AGP、Kotlin 插件的依赖
│ └── src/main/kotlin/
│ ├── AndroidLibraryConventionPlugin.kt
│ ├── AndroidApplicationConventionPlugin.kt
│ ├── AndroidComposeConventionPlugin.kt
│ └── KotlinAndroidConventionPlugin.kt
├── gradle/
│ └── libs.versions.toml
├── settings.gradle.kts # 主项目的 settings
│ └── pluginManagement {
│ includeBuild("build-logic") ← 关键:引入组合构建
│ }
├── feature/
│ ├── login/build.gradle.kts
│ └── order/build.gradle.kts
└── core/
├── network/build.gradle.kts
└── database/build.gradle.kts
Convention Plugin 的典型实现
// build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt
import com.android.build.gradle.LibraryExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension
/**
* Android Library 模块的约定插件
* 统一配置 compileSdk、minSdk、Kotlin 编译选项等
* 所有 Library 模块只需 apply 此插件,无需重复配置
*/
class AndroidLibraryConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
// 统一应用 Android Library 和 Kotlin 插件
pluginManager.apply("com.android.library")
pluginManager.apply("org.jetbrains.kotlin.android")
// 统一配置 Android 构建参数
extensions.configure<LibraryExtension> {
compileSdk = 35
defaultConfig {
minSdk = 24
testInstrumentationRunner =
"androidx.test.runner.AndroidJUnitRunner"
// 消费者混淆规则——被其他模块引用时自动生效
consumerProguardFiles("consumer-rules.pro")
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
// 统一配置 Kotlin 编译选项
extensions.configure<KotlinAndroidProjectExtension> {
compilerOptions {
jvmTarget.set(
org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
)
// 将所有警告视为错误——强制代码质量基线
allWarningsAsErrors.set(true)
// 启用实验性 API 的 opt-in
freeCompilerArgs.addAll(
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlinx.coroutines.FlowPreview",
)
}
}
}
}
}
// build-logic/convention/src/main/kotlin/AndroidComposeConventionPlugin.kt
/**
* Compose 的约定插件
* 所有使用 Compose 的模块额外叠加此插件
*/
class AndroidComposeConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
// 应用 Kotlin Compose 编译器插件
pluginManager.apply("org.jetbrains.kotlin.plugin.compose")
// 配置 Compose 编译器
extensions.configure<ComposeCompilerGradlePluginExtension> {
// 启用强跳过模式——优化 Compose 重组性能
enableStrongSkippingMode.set(true)
// 生成 Compose 编译器报告(用于分析重组性能)
reportsDestination.set(
layout.buildDirectory.dir("compose_reports")
)
}
}
}
}
注册与使用 Convention Plugin
在 build-logic/convention/build.gradle.kts 中注册:
// build-logic/convention/build.gradle.kts
plugins {
`kotlin-dsl`
}
dependencies {
// Convention Plugin 需要在编译时访问 AGP 和 Kotlin Plugin 的 API
compileOnly(libs.android.gradle.plugin)
compileOnly(libs.kotlin.gradle.plugin)
compileOnly(libs.compose.compiler.gradle.plugin)
}
gradlePlugin {
plugins {
register("androidLibrary") {
id = "example.android.library"
implementationClass = "AndroidLibraryConventionPlugin"
}
register("androidApplication") {
id = "example.android.application"
implementationClass = "AndroidApplicationConventionPlugin"
}
register("androidCompose") {
id = "example.android.compose"
implementationClass = "AndroidComposeConventionPlugin"
}
}
}
使用后,业务模块的 build.gradle.kts 从几十行缩减到个位数行:
// :feature:login/build.gradle.kts
plugins {
id("example.android.library") // 应用 Android Library 约定
id("example.android.compose") // 叠加 Compose 约定
alias(libs.plugins.hilt.android)
alias(libs.plugins.ksp)
}
android {
namespace = "com.example.feature.login"
}
dependencies {
implementation(project(":core:designsystem"))
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
}
循环依赖:病因、症状与根治方案
循环依赖是组件化工程中最棘手的架构问题之一。当模块 A 依赖 B、B 又依赖 A 时,Gradle 无法确定构建顺序,构建直接失败。
循环依赖的本质
Gradle 在构建之前会执行拓扑排序——将所有模块按依赖关系排成一条线性序列,被依赖的模块先编译。如果存在循环,就无法排序。
拓扑排序
✓ 可排序(DAG) ✗ 不可排序(有环)
A → B → C → D A → B → C
↑ │
└───────┘
构建顺序:D → C → B → A Gradle 报错:Circular dependency
循环依赖的三种典型模式及根治方案
模式一:双向业务依赖
两个业务模块互相调用对方的功能。
:feature:login ←──→ :feature:order
login 需要检查用户是否有未完成订单
order 需要在未登录时跳转到登录页
根治方案:接口下沉 + 依赖倒置
将双方需要的契约接口提取到独立的 -api 模块中:
修复前: 修复后:
login ←──→ order login ──→ order-api ←── order
↑ │
└── login-api ←────────┘
// :feature:order-api(纯接口模块,无实现)
interface IOrderService {
suspend fun hasPendingOrder(userId: String): Boolean
}
// :feature:login-api(纯接口模块,无实现)
interface ILoginNavigator {
fun navigateToLogin(context: Context)
}
// :feature:login 依赖 order-api(检查订单),实现 login-api(提供登录导航)
// :feature:order 依赖 login-api(跳转登录),实现 order-api(提供订单服务)
依赖方向变为单向:业务模块 → API 模块。API 模块之间互不依赖,循环打破。
模式二:基础层互相引用
两个基础模块互相调用。
:core:network ←──→ :core:auth
network 需要 auth 提供 Token 来设置请求头
auth 需要 network 来调用登录/刷新 Token 的 API
根治方案:提取公共契约到更底层的模块
修复前: 修复后:
network ←──→ auth network ──→ core:auth-api
↑ ↑
auth ──→ core:auth-api
│
└──→ network
更优雅的方案是使用回调/策略模式,在 network 中定义 Token 提供者的抽象:
// :core:network 模块
/**
* Token 提供者接口——由 network 模块定义,由 auth 模块实现
* 这是依赖倒置原则的教科书应用:
* 高层模块定义抽象(network 定义接口),低层模块实现抽象(auth 实现接口)
*/
interface TokenProvider {
/** 获取当前有效的 Access Token */
suspend fun getAccessToken(): String?
/** 刷新 Token 并返回新 Token */
suspend fun refreshToken(): String?
}
/**
* 认证拦截器——使用 TokenProvider 获取 Token
* 不依赖 auth 模块的任何具体类
*/
class AuthInterceptor(
private val tokenProvider: TokenProvider,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val token = runBlocking { tokenProvider.getAccessToken() }
val request = chain.request().newBuilder()
.header("Authorization", "Bearer $token")
.build()
return chain.proceed(request)
}
}
// :core:auth 模块——实现 TokenProvider 接口
// auth 依赖 network(用于网络请求),但 network 不依赖 auth
internal class AuthTokenProvider @Inject constructor(
private val authApi: AuthApi, // 来自 network/Retrofit
private val tokenStore: TokenStore,
) : TokenProvider {
override suspend fun getAccessToken(): String? =
tokenStore.getToken()
override suspend fun refreshToken(): String? {
val newToken = authApi.refresh(tokenStore.getRefreshToken())
tokenStore.saveToken(newToken)
return newToken.accessToken
}
}
此时依赖关系变为:auth → network(单向),network 通过 TokenProvider 接口回调 auth 的实现——没有反向依赖。
模式三:共享工具类导致的隐式耦合
两个模块都需要一些公共的工具方法或数据类。
:feature:a 和 :feature:b 都需要 DateUtils 和 MoneyUtils
直接互相引用导致循环
根治方案:提取到公共模块
// :core:common 模块(纯 Kotlin,无 Android 依赖)
// 所有纯工具类、扩展函数、数据模型的归宿
object DateUtils {
fun formatDate(timestamp: Long): String = /* ... */
}
object MoneyUtils {
fun formatCents(cents: Long): String = /* ... */
}
// :feature:a 和 :feature:b 都依赖 :core:common
// 两者之间无直接依赖
依赖方向的铁律
在组件化架构中,依赖的合法流向是一棵有向无环图(DAG):
合法方向(从上到下):
:app(壳工程)
↓
:feature:*(业务组件层)
↓
:feature:*-api(契约层)
↓
:core:*(基础组件层)
禁止方向:
✗ 同层业务组件互相依赖
✗ 下层模块依赖上层模块
✗ 任何形成闭环的依赖关系
依赖图的自动化检查
人工维护依赖方向在大型工程中不可持续。可以在 CI 中加入自动化检查:
// build.gradle.kts(根项目)
/**
* 自定义 Task:检查模块间的依赖方向是否合法
* 在 CI 构建中运行,防止违规依赖被合入主分支
*/
tasks.register("checkModuleDependencies") {
doLast {
val violations = mutableListOf<String>()
subprojects.forEach { project ->
project.configurations
.filter { it.isCanBeResolved }
.forEach { config ->
config.dependencies
.filterIsInstance<ProjectDependency>()
.forEach { dep ->
// 规则:feature 模块不能依赖其他 feature 模块
if (project.path.startsWith(":feature:") &&
dep.dependencyProject.path.startsWith(":feature:") &&
!dep.dependencyProject.path.endsWith("-api")
) {
violations.add(
"${project.path} → ${dep.dependencyProject.path}"
)
}
// 规则:core 模块不能依赖 feature 模块
if (project.path.startsWith(":core:") &&
dep.dependencyProject.path.startsWith(":feature:")
) {
violations.add(
"${project.path} → ${dep.dependencyProject.path}"
)
}
}
}
}
if (violations.isNotEmpty()) {
throw GradleException(
"发现违规的模块依赖:\n${violations.joinToString("\n")}"
)
}
}
}
依赖治理的工程实践清单
将前文的所有知识汇总为一份可执行的工程实践清单:
依赖声明规范
| 规则 | 说明 | 违反后果 |
|---|---|---|
默认使用 implementation |
除非公共 API 暴露了依赖的类型 | 不必要的编译传播,编译速度下降 |
代码生成器使用 ksp |
取代已废弃的 kapt |
编译速度降低 30~50% |
所有版本集中到 libs.versions.toml |
禁止在 build.gradle.kts 中硬编码版本号 |
版本不一致,运行时 NoSuchMethodError |
| 使用 BOM 管理组件生态版本 | Compose BOM、Firebase BOM 等 | 生态组件版本不兼容 |
使用 because() 记录约束原因 |
特别是 strictly 和 reject |
后续接手者不知为何限制版本 |
架构约束规范
| 规则 | 说明 |
|---|---|
业务模块间通过 -api 模块通信 |
不直接 implementation(project(":feature:xxx")) |
| 基础模块禁止反向依赖业务模块 | :core:* → :feature:* 是架构违规 |
| 循环依赖在 CI 中自动检测 | 用自定义 Task 或 module-graph 插件 |
| Convention Plugin 统一构建配置 | 禁止在业务模块中重复配置 SDK 版本等 |
| 依赖锁定用于 CI/Release 构建 | 确保构建可重现 |
依赖诊断工具箱
| 工具 / 命令 | 用途 |
|---|---|
./gradlew :module:dependencies |
查看模块的完整依赖树 |
./gradlew :module:dependencyInsight --dependency xxx |
查看特定依赖的版本解析路径 |
./gradlew build --scan |
生成 Gradle Build Scan 报告 |
| module-graph 插件 | 生成模块依赖关系的 Mermaid 图 |
./gradlew dependencies --write-locks |
生成/更新依赖锁定文件 |
依赖治理的核心思想可以浓缩为一句话:让依赖关系成为有意识的架构决策,而不是无意识的代码耦合。implementation 建立编译边界,Version Catalogs 统一版本真相,Convention Plugins 消除配置重复,依赖锁定保障构建可重现,自动化检查防止架构腐化——这些机制协同工作,才能支撑一个百模块级别的组件化工程健康演进。