当一个 Android 工程的 app 模块塞进几百个源文件时,问题会同时从三个方向冒出来:编译一次要等好几分钟,改一行代码触发全量重编;两个人动同一块代码就冲突不断;想复用某个页面到另一个 App 只能靠复制粘贴。多模块化就是用来同时解决这三个问题的。
本文按「先拆得对,再跑得快」的顺序展开:先讲模块拆分与依赖可见性,再讲版本统一与构建逻辑共享,最后讲 Gradle 层面的性能开关与 AGP 8.x 的迁移要点。
一、为什么需要多模块
单体 app 模块的核心矛盾是:Gradle 的增量编译以模块为单位。当所有代码都在一个模块里,改一个工具类就可能触发整个模块的重新编译、重新 dex、重新打包。
| 维度 | 单体 app 模块 | 多模块工程 |
|---|---|---|
| 增量编译粒度 | 整个模块,改动即大范围重编 | 按模块,只重编受影响模块 |
| 并行构建 | 单模块内串行 | 模块间可并行,--parallel 生效 |
| 代码边界 | 靠包名约定,无强制力 | 靠依赖图强制,越界直接编译失败 |
| 复用能力 | 复制粘贴 | 直接依赖,或独立发布 AAR |
| 团队协作 | 同文件冲突频繁 | 按模块分工,冲突面收敛 |
但多模块不是免费的。模块越多,配置阶段(configuration phase)的耗时越长,依赖声明、版本对齐、构建逻辑重复都会变成新成本。所以拆分要按边界拆,而不是按文件夹拆。
一句话总结: 多模块的第一收益是把 Gradle 的增量编译粒度从「整个 App」降到「单个模块」,第二收益才是架构约束与复用;拆分必须跟着真实边界走,否则只是在给配置阶段加负担。
二、api 与 implementation 的可见性差异
模块拆分最先要搞懂的一对配置是 api 和 implementation。区别不在「能不能用」,而在「会不会传递出去」。
// feature/home 模块的 build.gradle.kts
dependencies {
// implementation:只在当前模块可见,不传递给依赖本模块的人
implementation("androidx.recyclerview:recyclerview:1.3.2")
implementation(libs.kotlinx.coroutines.android)
// api:会传递出去,依赖 :feature:home 的模块也能看到这些类型
api(project(":core:model"))
api(libs.androidx.lifecycle.viewmodel.ktx)
compileOnly(libs.javax.inject) // 仅编译期可见,不进入运行时
debugImplementation(libs.leakcanary.android) // 仅 debug 构建可见
}
| 配置 | 是否传递 | 典型用途 | 风险 |
|---|---|---|---|
implementation | 否 | 绝大多数依赖的默认选择 | 无,最安全 |
api | 是 | 公开 API 中暴露的类型(如 Flow、领域模型) | 泄漏实现细节,下游被迫跟着升级 |
compileOnly | 否(不打包) | 注解、编译期依赖 | 运行时缺类会崩 |
runtimeOnly | 否(仅运行时) | 驱动、实现类 | 编译期无法引用 |
debugImplementation | 否(仅 debug) | LeakCanary、调试面板 | release 构建缺类 |
判断规则很简单:如果一个类型出现在本模块公开方法签名或公开属性里,它所属的依赖才配用 api;其余一律 implementation。 例如 core:model 里的 Article 领域模型被 feature:home 的公开函数签名引用,core:model 就要用 api 暴露;而 feature:home 内部用的 Retrofit、Moshi 全部 implementation,外部模块完全看不到。
滥用 api 的后果很直接:整个工程被透传依赖串成一张稠密的网,任何一个库升级都会引发大面积重编,构建缓存命中率也随之下降。
一句话总结: 默认全用
implementation,只有当某个类型出现在本模块的公开 API 签名里时才升级为api;这条纪律直接决定了依赖图是稀疏还是稠密。
三、模块拆分策略:core 与 feature 分层
一套可维护的模块图通常分三层:app(组装层)、feature(业务层)、core(基础设施层),依赖方向永远单向:app → feature → core。
模块图示例:
:app 组装、导航、依赖注入聚合
├── :feature:home 首页业务
├── :feature:detail 详情页业务
├── :core:ui 通用 Compose 组件、主题
├── :core:model 领域模型(纯 Kotlin,无 Android 依赖)
├── :core:data 仓库实现、Room、Retrofit
├── :core:network 网络客户端、拦截器
└── :core:common 工具类、扩展函数
依赖规则(必须写进 CI 检查):
feature 之间禁止互相依赖,只能依赖 core
core 之间只允许单向依赖:data → network → common
core 禁止依赖 feature
core:model 建议做成纯 Kotlin JVM 模块(应用 org.jetbrains.kotlin.jvm 插件)而不是 Android 库模块:它不依赖 Android SDK,编译极快,还能被后端 Kotlin 代码复用。core:ui、core:designsystem 这类含 Android 资源的模块才需要 com.android.library。
3.1 循环依赖治理
循环依赖在 Gradle 里是硬错误(Circular dependency between the following tasks),一旦出现整个构建就停摆。
| 成因 | 现象 | 解法 |
|---|---|---|
| 两个 feature 互相调用 | :feature:a 依赖 :feature:b,反之亦然 | 抽公共部分到 :core:ui,或由 :app 做事件中转 |
| core 反向依赖 feature | :core:data 引用了 feature 的实体 | 把被依赖类型下移到 :core:model |
通过 api 隐式成环 | 依赖图看不出,编译期才报 | 用 :app:dependencies 或 --scan 的依赖图检查 |
用 implementation 掩盖 | 编译过了,运行期 NoClassDefFoundError | 禁止用隐藏依赖绕过,必须结构性拆开 |
./gradlew :app:dependencies --configuration releaseRuntimeClasspath | grep "project :" # 定位项目间依赖
一句话总结: 模块图的价值在于**「依赖方向单向、feature 之间互不可见」这两条纪律**;循环依赖不要靠隐藏依赖绕过,要从结构上把公共类型下沉到 core 层。
四、Version Catalog 统一版本
Gradle 7.4 起内置的 Version Catalog 用一份 gradle/libs.versions.toml 管理全工程版本,取代散落在各模块里的硬编码坐标。
[versions]
agp = "8.7.3"
kotlin = "2.1.0"
ksp = "2.1.0-1.0.29"
composeBom = "2024.12.01"
lifecycle = "2.8.7"
room = "2.6.1"
hilt = "2.52"
[libraries]
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
[plugins]
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" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
使用时的 alias 语法让 IDE 可以补全,也让「哪个模块用了哪个库」一目了然:
// feature/home/build.gradle.kts
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.ksp)
}
dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
ksp(libs.androidx.room.compiler)
}
4.1 动态版本禁令
| 写法 | 问题 | 建议 |
|---|---|---|
1.2.+ | 每次构建可能解析到不同版本,构建不可复现 | 绝对禁止 |
latest.release | 同上,且联网解析慢 | 绝对禁止 |
[1.0, 2.0) | 区间解析,缓存与 CI 结果不稳定 | 禁止 |
1.2.3(固定) | 可复现 | 唯一推荐 |
一句话总结: Version Catalog 把版本收敛到一个文件、一次修改、全局生效;配合「禁止动态版本」的纪律,才能让构建结果可复现,也让升级动作变成可审查的 PR。
五、构建逻辑共享:buildSrc 与 build-logic
当十几个模块的 build.gradle.kts 出现大段重复配置时,就该把公共构建逻辑抽出来。有两条路:buildSrc 和 build-logic included build。
| 维度 | buildSrc | build-logic included build |
|---|---|---|
| 生效方式 | 自动包含 | 需在 settings.gradle.kts 里 includeBuild |
| 构建缓存 | 改动后整个 buildSrc 重编,并让所有模块失效 | 可作为独立构建被缓存,影响面更小 |
| 编译时机 | 先于主构建,串行 | 可并行,且能复用 Build Cache |
| 适用规模 | 小工程、简单插件 | 中大型工程、多插件、多约定 |
| 组合能力 | 弱 | 强,可多个约定插件互相组合 |
中大型工程推荐 build-logic。它本身是一个独立的 Gradle 构建,产物是约定插件(convention plugin)。
// build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt
import com.android.build.api.dsl.LibraryExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension
class AndroidLibraryConventionPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
pluginManager.apply("com.android.library")
pluginManager.apply("org.jetbrains.kotlin.android")
extensions.configure<LibraryExtension> {
// AGP 8.x 起 namespace 必须显式声明,不再从 manifest 推断
namespace = "com.example.${path.replace(":", ".").removePrefix(".")}"
compileSdk = 35
defaultConfig { minSdk = 24 }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures { compose = true; buildConfig = true }
}
extensions.configure<KotlinAndroidProjectExtension> { jvmToolchain(17) }
}
}
// settings.gradle.kts
pluginManagement {
includeBuild("build-logic")
repositories { google(); mavenCentral(); gradlePluginPortal() }
}
之后每个模块的构建脚本就缩成几行:应用 id("example.android.library") 约定插件,再声明本模块自己的 implementation(project(":core:model")) 即可,重复的 compileSdk、jvmToolchain、buildFeatures 全部由约定插件注入。
一句话总结: 小工程用
buildSrc足够,中大型工程应选build-logicincluded build——它让构建逻辑本身可缓存、可并行、可组合,代价是多一层目录结构与includeBuild配置。
六、Gradle 构建性能开关
多模块把编译粒度降下来了,但如果 Gradle 层面的开关没开,收益会被配置阶段吃掉。
org.gradle.parallel=true # 并行执行任务(多模块收益最大)
org.gradle.caching=true # 构建缓存:跨机器、跨分支复用任务输出
org.gradle.configureondemand=true # 只配置本次构建真正用到的模块
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+UseParallelGC -Dfile.encoding=UTF-8
android.nonTransitiveRClass=true # 非传递 R 类,减少膨胀与重编范围
org.gradle.configuration-cache=true # 开启 Configuration Cache
org.gradle.configuration-cache.problems=warn
kotlin.incremental=true
kotlin.daemon.jvmargs=-Xmx3g
ksp.incremental=true
6.1 Configuration Cache 与 Build Cache
这两个缓存经常被混为一谈,但它们缓存的东西完全不同:
| 缓存 | 缓存内容 | 命中收益 | 失效条件 |
|---|---|---|---|
| Configuration Cache | 配置阶段的产物(任务图、已解析的 Task 对象) | 省掉整个配置阶段,通常几秒到几十秒 | 构建脚本、插件、环境变量变化 |
| Build Cache | 任务输出(class 文件、dex、资源) | 跳过任务执行 | 输入(源码、参数、依赖)变化 |
./gradlew :app:assembleDebug --scan # 生成构建扫描,Build Cache 命中会显示 FROM-CACHE
--scan 生成的 Build Scan 是诊断构建性能最有效的工具,重点看四块:Performance(配置/执行/依赖解析三段耗时)、Timeline(关键路径与并行度)、Build Cache(命中率与未命中原因)、Dependencies(依赖解析耗时与动态版本告警)。
一句话总结: Configuration Cache 缓存任务图、Build Cache 缓存任务输出,两者互补;先用
--scan定位瓶颈在配置阶段还是执行阶段,再决定开哪个、改哪里。
七、依赖约束与 BOM
模块数量上去后,「同一个库被不同模块引入不同版本」会成为高频问题。解决方案是 BOM 加依赖约束。
// app/build.gradle.kts
dependencies {
// Compose BOM:统一所有 androidx.compose.* 的版本,声明后无需写版本号
implementation(platform(libs.androidx.compose.bom))
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.ui:ui-tooling-preview")
// 对非 BOM 库使用依赖约束,强制全工程统一版本
constraints {
implementation("com.squareup.okhttp3:okhttp") {
version { strictly("4.12.0") }
because("统一 OkHttp 版本,避免不同小版本导致的方法签名冲突")
}
}
}
| 手段 | 作用范围 | 典型场景 |
|---|---|---|
platform(bom) | 该模块的依赖声明 | Compose、Firebase、Ktor 等官方 BOM |
enforcedPlatform(bom) | 全工程强制 | 需要绝对锁定时使用,慎用 |
constraints | 依赖图中的版本决策 | 统一第三方库版本,附 because 说明 |
resolutionStrategy.force | 全局强制覆盖 | 历史遗留的救火手段,不推荐新工程使用 |
一句话总结: BOM 解决「同一套库的版本对齐」,
constraints解决「零散第三方库的版本收敛」;能用 BOM 就不手写版本号,能用约束就不上force。
八、KSP 与 KAPT
注解处理器(Room、Hilt、Moshi)是构建耗时的常客。KAPT 走的是「生成 Java 存根再交给 javac」的老路,KSP 直接以 Kotlin 语法树为输入,速度快数倍。
| 维度 | KAPT | KSP |
|---|---|---|
| 原理 | 生成 Java stub,跑 javac 注解处理 | 直接解析 Kotlin 源码,无 stub |
| 速度 | 慢,通常是 Kotlin 编译耗时的主要来源 | 快,官方数据可达 2 倍以上提升 |
| 增量支持 | 有限 | 原生增量,改一个文件只重跑相关处理 |
| Kotlin 版本耦合 | 松 | 严格,KSP 版本必须匹配 Kotlin 版本 |
| 兼容性 | 老库仍只支持 KAPT | 主流库均已支持(Room、Hilt、Moshi) |
plugins { alias(libs.plugins.ksp) }
dependencies {
implementation(libs.androidx.room.runtime)
ksp(libs.androidx.room.compiler) // 原为 kapt(...)
implementation(libs.hilt.android)
ksp(libs.hilt.compiler) // Hilt 2.48+ 已支持 KSP
}
ksp { arg("room.schemaLocation", "$projectDir/schemas") }
注意 KSP 版本号格式是 Kotlin版本-ksp版本,例如 Kotlin 2.1.0 对应 2.1.0-1.0.29。Kotlin 与 KSP 版本不匹配会直接构建失败,这也是升级 Kotlin 时最容易踩的坑。
一句话总结: 只要库支持就优先 KSP 而不是 KAPT,它免去 Java stub 生成,是注解处理耗时最直接的优化;代价是 KSP 版本必须与 Kotlin 版本严格配对。
九、AGP 8.x 迁移与 Compose 编译器插件
AGP 8.x 带来了几处必须改的破坏性变更,升级前先对照清单。
// 迁移前(AGP 7.x 风格)
android {
compileSdkVersion 33
composeOptions { kotlinCompilerExtensionVersion = "1.5.14" } // 极易与 Kotlin 版本错配
}
// 迁移后(AGP 8.x 风格)
android {
namespace = "com.example.app" // 必填,取代 manifest 的 package
compileSdk = 35
defaultConfig {
applicationId = "com.example.app"
targetSdk = 35
minSdk = 24
}
buildFeatures {
compose = true
buildConfig = true // AGP 8.0 起默认关闭,用到 BuildConfig 必须显式开
}
}
Kotlin 2.0 起,Compose 编译器随 Kotlin 版本发布,不再需要手动指定编译器扩展版本,改为应用 org.jetbrains.kotlin.plugin.compose 插件。
plugins {
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) // org.jetbrains.kotlin.plugin.compose
}
| 变更点 | AGP 7.x | AGP 8.x | 影响 |
|---|---|---|---|
| 包名声明 | manifest package | namespace DSL | 不迁移直接构建失败 |
BuildConfig | 默认开启 | 默认关闭 | 需 buildFeatures.buildConfig = true |
compileSdkVersion | 方法调用 | compileSdk 属性 | 旧写法已废弃 |
| Compose 编译器 | composeOptions 指定版本 | Kotlin Compose 插件 | 版本对齐问题消失 |
| 变体 API | applicationVariants | androidComponents | 自定义变体需重写 |
一句话总结: AGP 8.x 升级要盯住三件事:
namespace必填、BuildConfig默认关闭、Compose 编译器改为随 Kotlin 发布的插件;前两个是编译错误级的硬变更,第三个是版本管理方式的根本改变。
十、常见坑清单
| 坑 | 现象 | 对策 |
|---|---|---|
滥用 api | 依赖透传成网,改动触发大面积重编 | 默认 implementation,公开 API 才用 api |
| feature 互相依赖 | 循环依赖报错,或被迫抽公共模块 | feature 之间禁止直接依赖,公共部分下沉 core |
| 动态版本 | 构建结果不可复现,CI 时好时坏 | 全部固定版本,用 Renovate 开 PR 升级 |
| KSP 版本错配 | 构建失败,报找不到处理器 | KSP 版本严格匹配 Kotlin 版本 |
buildSrc 滥用 | 改一行构建逻辑,全工程重新配置 | 中大型工程改用 build-logic included build |
| Configuration Cache 未开 | 配置阶段白白耗时 | 开启并逐步修掉不兼容的插件 |
buildConfig 未开 | BuildConfig 找不到符号 | buildFeatures.buildConfig = true |
namespace 缺失 | AGP 8.x 构建直接失败 | 每个模块显式声明 namespace |
| 依赖未做约束 | 同名库多版本共存,运行时行为不定 | BOM 或 constraints 统一版本 |
一句话总结: 坑的本质都是**「边界没定死」或「版本没收敛」**;先用 Version Catalog 收敛版本,再用依赖方向规则定死边界,性能优化才有稳定地基。
小结
| 主题 | 关键结论 |
|---|---|
| 拆分动机 | 增量编译粒度从 App 降到模块,附带边界与复用收益 |
| 依赖可见性 | 默认 implementation,公开 API 才用 api |
| 模块图 | app → feature → core 单向,feature 之间互不可见 |
| 版本管理 | Version Catalog 单点收敛,禁止动态版本 |
| 构建逻辑 | 中大型工程用 build-logic included build |
| 缓存 | Configuration Cache 缓存任务图,Build Cache 缓存任务输出 |
| 依赖对齐 | BOM 对齐同一套库,constraints 收敛零散库 |
| 注解处理 | 能 KSP 就不 KAPT,版本必须与 Kotlin 配对 |
| AGP 8.x | namespace 必填、buildConfig 显式开、Compose 插件随 Kotlin |
一句话记住:多模块化的目标是「让改一行代码只重编一个模块」,Gradle 优化的目标是「让没变的模块彻底不参与构建」;两者叠加,才把大工程的构建从「等几分钟」拉回「等几秒」。模块图想清楚之后,依赖注入的边界划分可以参考 Hilt 依赖注入实践 ,而模块拆分带来的构建收益最终要落到启动速度上,见 Android 性能优化与启动加速 ;想从字节码层面理解类加载与模块边界的关系,Java 类加载与字节码 提供了更底层的视角。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。