Android 多模块架构与 Gradle 优化

模块拆得好不好,直接决定 Android 项目的编译速度与协作效率。本文讲多模块架构与构建优化:接口暴露与实现隐藏的边界、按功能与核心分层、约定插件与构建逻辑独立构建、版本目录统一依赖、配置缓存与构建缓存、注解处理并行,以及新版本构建插件的命名空间迁移与声明式界面编译器插件。文末附依赖约束与循环依赖治理清单,帮你把构建时间压下来。

当一个 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。

维度buildSrcbuild-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-logic included 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 语法树为输入,速度快数倍。

维度KAPTKSP
原理生成 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.xAGP 8.x影响
包名声明manifest packagenamespace DSL不迁移直接构建失败
BuildConfig默认开启默认关闭需 buildFeatures.buildConfig = true
compileSdkVersion方法调用compileSdk 属性旧写法已废弃
Compose 编译器composeOptions 指定版本Kotlin Compose 插件版本对齐问题消失
变体 APIapplicationVariantsandroidComponents自定义变体需重写

一句话总结: 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.xnamespace 必填、buildConfig 显式开、Compose 插件随 Kotlin

一句话记住:多模块化的目标是「让改一行代码只重编一个模块」,Gradle 优化的目标是「让没变的模块彻底不参与构建」;两者叠加,才把大工程的构建从「等几分钟」拉回「等几秒」。模块图想清楚之后,依赖注入的边界划分可以参考 Hilt 依赖注入实践 ,而模块拆分带来的构建收益最终要落到启动速度上,见 Android 性能优化与启动加速 ;想从字节码层面理解类加载与模块边界的关系,Java 类加载与字节码 提供了更底层的视角。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「Android 开发」更多文章

  1. Kotlin Multiplatform 跨平台共享
  2. Android R8 混淆与 Baseline Profile
  3. Android 测试体系:单元测试、Espresso 与 Compose 测试