资讯详情

Gradle多项目构建与自动化部署:从工程化到CI落地

📅 2026/10/6 19:13:58 | 华诺云谱 👁 阅读
Gradle多项目构建与自动化部署:从工程化到CI落地
Gradle 这东西做移动端的基本天天见但真正把它玩明白的并不多。尤其是项目一多、模块一拆构建脚本就成了玄学今天张三改了个版本号明天李四那边编译就炸了本地跑得好好的到了打包机上又给你闹脾气。这篇文章我就围绕“Gradle 多项目构建的自动化部署”这条主线把这几年在 Android 工程化上踩过的坑、沉淀下来的方案一次性讲透。我默认读者是已经写过几个独立 App、开始接触多模块工程的移动端开发者或者是在小团队里被迫兼任“打包专员”的同学。看完之后你至少能理清下面几件事为什么要拆多项目、拆完之后构建脚本怎么组织才不失控、怎么把“手动点 Build”变成脚本一键出包、以及 CI 上跑 Gradle 任务时常见的坑怎么排查。1. 多项目构建不是炫技是工程化的必然1.1 什么时候你该考虑拆项目很多团队是这么过来的一开始一个 app 模块业务代码全往里塞。等到第 N 个迭代之后开始出现几个信号全量编译从几十秒涨到几分钟改一行代码也要等半天。想复用某个业务模块只能复制粘贴代码改了副本又没法同步。团队人多了git 冲突频繁每次 merge 都在解决同一个文件的冲突。这时候拆模块就是刚需。Gradle 的多项目构建multi-project build解决的核心问题是把一个大而全的应用拆成多个可以独立构建、独立演进、按需组合的子项目。拆完之后每个子项目可以单独编译、单独测试公共代码下沉到 library 模块业务模块按层级依赖最终在 app 壳工程里组装起来。我在实际拆解的时候习惯按三层来规划基础层网络库封装、图片加载、通用 UI 组件、工具类。这层不依赖任何业务模块是所有模块的地基对应 Gradle 里通常是:lib-base、:lib-ui这类命名。业务层登录、首页、商城、订单等业务模块。每个模块只依赖基础层模块之间不直接互相依赖通信走路由这类机制对应:feature-login、:feature-home。应用层app 壳工程负责把业务模块组装起来配置路由表、初始化 SDK最终产出可安装的 APK 或 AAB。这三层拆完之后构建图非常清晰改feature-login的代码只需要编登录模块和 app 壳基础层没有变动就不需要全量编译。1.2 Gradle 是怎么组织这些子项目的多项目构建的根目录里settings.gradle就是整个构建的总指挥。你在里面声明要包含哪些子项目Gradle 就会根据声明去创建对应的 Project 对象。我见过很多新手直接把子项目全写在settings.gradle里几十行 include 堆在一起看起来也没啥问题。但项目规模上来之后我建议用一种更结构化的方式按目录组织用正则或枚举的方式批量 include。拿一个实际项目的settings.gradle.kts举例pluginManagement { repositories { maven(https://maven.aliyun.com/repository/public) maven(https://maven.aliyun.com/repository/google) google() mavenCentral() gradlePluginPortal() } } dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { maven(https://maven.aliyun.com/repository/public) maven(https://maven.aliyun.com/repository/google) google() mavenCentral() } } rootProject.name MyApp listOf( lib-base, lib-ui, lib-network, feature-login, feature-home, feature-order, app ).forEach { include(:$it) }两个细节值得注意一是FAIL_ON_PROJECT_REPOS它强制所有子模块的依赖库都走根目录统一配置的仓库避免每个模块自己声明一个仓库地址后期维护时那叫一个酸爽二是pluginManagement里也要配仓库因为 AGP 插件本身也是从仓库里下载的很多坑都是插件下载失败导致的后面在排查部分我会专门讲。1.3 模块之间的依赖关系怎么写子项目声明好了依赖关系是在各个子项目的build.gradle里通过project(:xxx)来建立的。比如 app 模块依赖业务层业务层依赖基础层// app/build.gradle.kts dependencies { implementation(project(:feature-login)) implementation(project(:feature-home)) implementation(project(:lib-base)) } // feature-login/build.gradle.kts dependencies { implementation(project(:lib-base)) implementation(project(:lib-ui)) }这里有一个关键原则能用implementation就别用api。implementation是传递依赖的边界比如feature-login用implementation依赖lib-network其他模块即使间接引用到了lib-network的类编译期也拿不到必须在自己的 gradle 文件里显式声明。这一条在项目初期可能觉得多此一举等模块多起来就会发现它逼着你把依赖关系显式化不会出现 A 能编过 B 编不过的诡异问题。2. 构建脚本的工程化改造让依赖和配置告别混乱2.1 版本号统一管理的两种主流姿势项目模块多了之后最怕的就是同一个依赖在十几个模块里各写各的版本。今天升级个OkHttp要找找哪些模块引了它漏一个就等着运行期崩溃。Gradle 生态里目前主流两种方案buildSrc和 Version Catalog。buildSrc的思路是搞一个特殊的 Gradle 项目里面写一个Dependencies.kt类把所有依赖坐标集中管理。好处是写起来直白支持 IDE 的自动提示坏处是buildSrc的任何变更都会导致整个构建重新执行稍微大点的项目每次改依赖都全量刷新时间成本比较高。Version Catalog 是 Gradle 官方力推的方式核心是一个libs.versions.toml文件放在根目录的gradle文件夹下。我用它替换掉 buildSrc 之后最大的感受是改依赖版本再也不会触发全量构建了。gradle/libs.versions.toml的内容大致长这样[versions] agp 8.1.4 kotlin 1.9.20 okhttp 4.12.0 [libraries] okhttp { group com.squareup.okhttp3, name okhttp, version.ref okhttp } [plugins] android-application { id com.android.application, version.ref agp }然后在模块里这么用// 根目录 build.gradle.kts plugins { alias(libs.plugins.android.application) apply false } // app/build.gradle.kts plugins { alias(libs.plugins.android.application) } dependencies { implementation(libs.okhttp) }那个version.ref的语法是引用上面[versions]里定义的值这样版本和依赖分离升级版本号只需要改 toml 文件一处所有模块自动同步。2.2 签名、BuildConfig 和多环境配置自动化部署里最绕不开的是签名配置。我见过不少团队把签名文件直接提交到 git 仓库里密码明文写在 gradle 文件里这属于给自己埋雷。比较稳妥的做法是签名文件不出仓库通过环境变量或者本地的keystore.properties文件注入这个文件加入.gitignore。// keystore.properties本地文件不入库 storeFile/path/to/release.jks storePasswordxxxx keyAliasxxx keyPasswordyyyy // app/build.gradle.kts import java.util.Properties val keystoreProps Properties().apply { val f rootProject.file(keystore.properties) if (f.exists()) load(f.inputStream()) } android { signingConfigs { create(release) { storeFile file(keystoreProps.getProperty(storeFile)) storePassword keystoreProps.getProperty(storePassword) keyAlias keystoreProps.getProperty(keyAlias) keyPassword keystoreProps.getProperty(keyPassword) } } buildTypes { release { signingConfig signingConfigs.getByName(release) } } }BuildConfig 字段是另一个高频需求不同的环境要配不同的 API 地址。我用的是buildConfigField在debug和release里分别注入buildTypes { debug { buildConfigField(String, API_BASE_URL, \https://test-api.example.com\) } release { buildConfigField(String, API_BASE_URL, \https://api.example.com\) } }注意 AGP 8.0 之后 BuildConfig 默认是关闭的需要在buildFeatures { buildConfig true }里手动打开这个坑不少人踩过。2.3 产物统一输出打包不再翻半天目录默认打包好的 APK 都在app/build/outputs/apk/debug/这种目录下文件名还带了一堆app-debug.apk的默认规则版本号也不直观。我习惯配置输出文件名把版本号、渠道、构建时间都塞进名字里这样从 CI 上下载产物的时候一目了然。方案是用applicationVariants遍历批量重命名android { applicationVariants.all { outputs.all { val output this as com.android.build.gradle.internal.api.BaseVariantOutputImpl val versionName versionName ?: 1.0.0 val variantName name output.outputFileName MyApp_v${versionName}_${variantName}.apk } } }原理也不复杂applicationVariants是 AGP 暴露出来的变体集合每打一个渠道包或者构建类型都会形成一个 variant这里在构建任务执行前把输出文件的名称改掉。这样最终的产物就是MyApp_v2.3.0_release.apk这种格式。由于 AGP 版本不同具体的脚本写法可能略有差异不过核心思路一致在产出 APK 的阶段之前把输出文件名格式化。3. 自动化部署落地从命令行到一键出包3.1 命令行构建的基本功自动化的前提是你得能脱离 Android Studio 的绿色小锤子直接在命令行里完成构建。Gradle 的命令行操作是一个合格的移动端开发必须掌握的# 打 debug 包 ./gradlew app:assembleDebug # 打 release 包 ./gradlew app:assembleRelease # 打某个渠道的包 ./gradlew app:assembleRelease -PbuildTyperelease # 先清理再打包 ./gradlew clean app:assembleRelease这里说个小技巧如果你只需要某一个模块的编译结果比如只编lib-network用./gradlew :lib-network:compileDebugKotlin就能精准编译不必全量。这在本地开发时能省下大量时间在 CI 上也能配合缓存加速。构建类型和渠道相乘会生成一大堆任务你要是不知道当前工程具体有哪些任务可以跑用./gradlew tasks看列出来的清单比瞎猜强。3.2 封装部署脚本把重复劳动交给机器命令行是基础但正经干活的时候我还是建议写一个脚本。比如测试同学过来说“帮我打个带版本号、带日期的测试包装到我桌面上”你不想每次都敲一长串命令也不想记 APK 输出到哪个目录。我常用的做法是写一个 Python 脚本放在项目根目录的scripts/下面做这么几件事清理旧的构建产物。执行 Gradle 构建任务可以指定模块和构建类型。把产出的 APK 复制到一个约定的输出目录统一按日期_应用名_版本号.apk命名。如果有多个 APK多 abi、多渠道自动压缩成一个 zip 包方便传输。核心逻辑大致是这样#!/usr/bin/env python3 import os import shutil import glob import time import subprocess import zipfile OUTPUT_DIR os.path.join(os.path.dirname(__file__), ../build_outputs) MODULE app VARIANT os.environ.get(VARIANT, assembleRelease) def main(): os.makedirs(OUTPUT_DIR, exist_okTrue) # 1. 执行 gradle 构建 result subprocess.run( [./gradlew, f{MODULE}:{VARIANT}, --no-daemon, --stacktrace], cwdos.path.dirname(__file__) /.., ) if result.returncode ! 0: raise SystemExit(构建失败脚本退出) # 2. 找到 APK 产物 apk_files glob.glob( os.path.join(os.path.dirname(__file__), ../**/build/outputs/apk/**/*.apk), recursiveTrue, ) if not apk_files: raise SystemExit(未找到 APK 产物) timestamp time.strftime(%Y%m%d_%H%M) os.makedirs(f{OUTPUT_DIR}/{timestamp}, exist_okTrue) # 3. 复制并重命名 for apk in apk_files: filename os.path.basename(apk) target f{OUTPUT_DIR}/{timestamp}/{filename} shutil.copy2(apk, target) print(f已复制: {target}) # 4. 如果需要传输打成 zip with zipfile.ZipFile(f{OUTPUT_DIR}/release_{timestamp}.zip, w) as zf: for apk in apk_files: zf.write(apk, os.path.basename(apk)) if __name__ __main__: main()注意我在执行 Gradle 的时候加了--no-daemon可能有人问不是都说开 daemon 才快吗在本地开发确实是 daemon 更省时间但在自动化脚本里每跑完一次任务 daemon 还会驻留一段时间挂着不退出会占用内存在一些内存敏感的 CI 机器上反而会拖慢后续任务。所以我脚本里故意关掉换去 CI 配置里单独开。3.3 多渠道批量打包的提速方案移动应用分发经常要面对各种渠道国内安卓生态更是绕不开。Gradle 里用productFlavors定义渠道android { productFlavors { create(tencent) { dimension channel } create(huawei) { dimension channel } create(xiaomi) { dimension channel } } }配合flavorDimensions来区分维度。如果你有无渠道、免费版、付费版这种业务形态再加一个维度flavorDimensions(mode, channel) productFlavors { create(free) { dimension mode } create(paid) { dimension mode } create(tencent) { dimension channel } }两个维度会生成“free tencent”、“free huawei”等组合变体构建任务名就是assembleFreeTencentRelease这种。多渠道打包最头疼的是耗时。全渠道 release 打包十几个渠道少则十几分钟多则半小时。我实践下来有两条提速路径第一条是关掉不必要的压缩对齐步骤。如果渠道包之间只是channel值不同不需要重新执行完整的resource shrink和dex用 apk 的“渠道标识往已打好的基线包里写入”就能做。社区里的开源方案很多核心思路是一致的先打一个没有走渠道逻辑的基线包再用脚本把对应的渠道号写入 APK 的 META-INF 或资源里。这样全渠道打包时间可以从“N 个渠道 × 全量构建”变成“1 次全量构建 N 次轻量复制”时间直接砍掉一大截。第二条是并行执行。如果机器核数够多用--parallel加上模块级的并行度配置能明显快一些这个放到性能调优章节详细说。3.4 接进 CI把自动化部署变成流水线本地脚本已经算半自动了但真正的部署自动化是接进 CI 系统。我这边主要用两类Jenkins 和 GitLab CI。Jenkins 比较传统适合老团队GitLab CI 胜在配置即代码一个.gitlab-ci.yml提交到仓库里人人可见、可 review。以 GitLab CI 举例最简配置长这样stages: - build - archive variables: GRADLE_OPTS: -Dorg.gradle.daemonfalse -Dorg.gradle.paralleltrue -Dorg.gradle.cachingtrue before_script: - chmod x ./gradlew build-release: stage: build script: - ./gradlew clean app:assembleRelease --stacktrace artifacts: paths: - app/build/outputs/apk/**/*.apk expire_in: 7 days only: - tags几个要点GRADLE_OPTS里的三个参数daemonfalse避免 CI 容器里驻留进程paralleltrue启用并行构建cachingtrue启用构建缓存第二次跑就能明显感觉到快了。artifacts是 GitLab CI 的产物归档机制把 APK 目录下的产物保留 7 天方便直接从流水线页面下载。only: tags意味着只有打 tag 的时候才触发这是发版场景的标准姿势配合rules还能做得更细比如merge_request时只跑assembleDebug作为冒烟验证。在 Jenkins 里其实是同一套 Gradle 命令只不过 Jenkins 的任务配置需要在 Web 界面上操作流水线脚本用 Groovy DSL 写。核心逻辑没变checkout 代码 → 执行 Gradle 任务 → 归档产物 → 通知到企业微信或者钉钉。小团队如果自动化基础薄弱建议优先上 GitLab CI理由很简单配置文件跟代码放一起后续维护成本低不会出现“这台机器上能跑换台机器就挂”的人肉运维问题。4. 常见问题与排查技巧实录4.1 构建卡在 “Running Gradle task ‘assembleDebug’”这句话大概是 Android 开发群里出现频率最高的一句话。现象就是 Android Studio 底部进度条卡住不动看着像“正在运行 Gradle 任务”但实际可能已经等了几分钟甚至更久。遇到这种情况不要干等按顺序排查先看是不是依赖下载卡住了。Gradle 首次构建要拉依赖国内网络拉到一半超时就会长时间无响应。这种场景最明显的特征是最底下会有一行一行Downloading的日志卡住不动。解决方案是配置国内镜像仓库阿里云的部分仓库对 Android 生态支持不错我常用的是第一节里写的那几个地址。再看是不是构建缓存造成的问题。本地之前构建过相同输入的任务Gradle 认为没有变化就跳过执行日志里会看到UP-TO-DATE。这本身是好的行为但如果你的需求是“强制重新执行”就要加--rerun-tasks。还有可能是守护进程假死。Gradle daemon 跑久了之后偶尔会出岔子表现就是任务提交进去没有响应。用./gradlew --stop杀掉所有守护进程再跑一次往往就好了。这个操作我每周都会碰到一两次属于最便宜的解决方案。社区里传播过一条 Flutter 项目里比较经典的报错You are applying Flutters main Gradle plugin imperatively using the apply(s) method。这个不是我瞎编的它确实是很多 Flutter 开发者升级之后会遇到的新警告原因是 Flutter 的 Gradle 插件使用方式过时了需要用标准的pluginsDSL 方式替代apply方式。解决思路是检查android/settings.gradle和android/build.gradle对比 Flutter 官方模板的写法统一。4.2 依赖冲突与版本锁定的处理多模块构建最常见的异常就是版本冲突。现象是打包的时候报一堆Conflict with dependency或者找不到某个方法、某个类的NoSuchMethodError——后者往往发到线上才被发现更阴险。排查时推荐用 Gradle 自带的依赖洞察命令./gradlew app:dependencies --configuration releaseRuntimeClasspath这条命令会打印 app 模块 release 运行时所有的依赖树里面会标出重复引入的库和版本冲突。看的时候重点关注-符号它表示最终被选定的版本。比如com.squareup.okhttp3:okhttp:4.9.3 - 4.11.0就是 okhttp 从 4.9.3 被提升到 4.11.0。这种提升大多数情况下自动解决了冲突但要是两个库都强制锁了不同版本就要手动处理了。我在项目里的原则是核心库的版本统一放在 Version Catalog 里管子模块不准自己写版本号只能从 Catalog 取。这样从根本上杜绝了同库不同版本的情况。遇到不得不排除传递依赖的场景implementation(libs.foo) { exclude(group com.squareup.okhttp3, module okhttp) }注意排除依赖是双刃剑用得不好会引入运行时崩溃建议能不用尽量不用通过统一版本解决更安全。4.3 Gradle 构建性能调优让打包不再“打半天”“Gradle 打包打半天”是热搜词里逃不掉的一个。构建速度直接决定了开发效率和打包同学的心情这块我个人的调优优先级如下第一开启构建缓存。在gradle.properties里加上org.gradle.cachingtrue org.gradle.paralleltrue org.gradle.workers.max8cachingtrue会把任务输出缓存到本地目录下次构建时如果任务输入没变直接从缓存拿结果不再重新执行。paralleltrue在模块很多时效果显著前提是模块间没有互相依赖的硬串行关系。workers.max根据机器核数适当调默认逻辑一般够用不用刻意拉高。第二配置 Gradle 守护进程的内存。在gradle.properties里org.gradle.jvmargs-Xmx4096m -Dfile.encodingUTF-8 -XX:UseParallelGC如果你还在用默认的-Xmx2048m大型多模块项目很容易在构建死角触发 OOM表现就是构建走到一半直接失败报错里有OutOfMemoryError字样。调到 4G 以上取决于你机器内存能明显降低这个概率。第三考虑本地构建缓存共享。如果团队里多人协作可以把构建缓存指向一个共享目录或者远程缓存服务比如使用 Gradle Enterprise 或者构建缓存代理。这样一个人构建过的结果其他人直接复用团队整体构建时间能缩短一大截。这个属于进阶玩法小团队可以先不折腾本地缓存把本地玩明白再说。4.4 离线包和内网仓库使用的注意事项搜“gradle 离线包”这个关键词的人多半是遇到了依赖下载不下来的困境。在实际工作中我经常遇到两类场景开发机不能访问外网只能在局域网里构建。公司要求构建过程可控不允许运行时去外网拉依赖。处理方式说穿了就是把依赖仓库搬到内网去。Gradle 支持从本地目录加载依赖在settings.gradle中把仓库声明成本地maven路径repositories { maven(url file:///opt/maven-repo) }日常开发中最常用的打包方式是把依赖打进一个本地仓库目录在 CI 机器上用--offline参数强制离线模式这样 Gradle 不会尝试联网检查依赖构建过程稳定可控。不过用--offline有个前置条件本地仓库里必须已经有了所有依赖的缓存否则会直接报“找不到依赖”。我建议的做法是在能联网的机器上先完整构建一次之后把~/.gradle/caches/modules-2下的 dependencies 目录同步到内网机器再配合内网仓库使用。这里踩过的坑是 caches 目录结构跟 Gradle 版本绑定很紧跨 Gradle 大版本同步缓存有时候会失效最稳妥的做法还是用同一 Gradle 版本生成缓存。4.5 快速排查速查表下面把高频问题整理成一个速查表方便你对着症状找药方症状常见原因处理方式构建长时间无响应依赖下载慢或中途失败配置国内镜像仓库检查网络任务显示 UP-TO-DATE 但想重跑Gradle 认为输入未变加--rerun-tasks强制重跑构建中途 OOM 崩溃JVM 内存不足调大org.gradle.jvmargsdaemon 进程假死守护进程过老或资源泄漏./gradlew --stop后重跑依赖冲突报错同库不同版本dependencies命令查依赖树编译报找不到类/方法传递依赖被排除检查 exclude 使用首次构建极慢未开缓存、单线程打开 caching、parallel这些坑都是我在日常构建里真实碰过的其中 OOM 和 daemon 假死是出现频率最高的两个建议优先处理。5. 多环境与部署策略的最后一公里构建和流水线都通了最后还差一步不同的团队角色怎么用这套东西。开发同学平时只需要跑assembleDebug连签名都不需要配用 debug 签名就行。但我们项目里 app 模块默认读取的keystore.properties在本地不一定存在构建脚本里就得做容错signingConfigs { create(release) { if (keystoreProps.isNotEmpty()) { storeFile file(keystoreProps.getProperty(storeFile)) storePassword keystoreProps.getProperty(storePassword) keyAlias keystoreProps.getProperty(keyAlias) keyPassword keystoreProps.getProperty(keyPassword) } } }isNotEmpty()的判断逻辑是这样的keystore.properties存在且有值时进行签名配置不存在时跳过签名打包出来的 APK 会带默认的 unsigned 状态。这样保证新同学 clone 代码后直接能编译 debug 包不会被签名配置挡住。测试同学如果要的是“带最新代码、能安装的测试包”那就触发脚本配合 CI产出 APK 后自动上传到团队的下载页面或者直接推到测试机群里。我见过很多团队到这一步就靠人工 AirDrop其实是没想明白这套流水线可以顺手把“上传”也自动化掉。渠道包这块电商类项目还会有更复杂的玩法比如不同渠道要内置不同的物料资源华为渠道要接入华为移动服务小米渠道要做小米推送。这些渠道差异建议用sourceSets来组织每个渠道的专属资源放到src/tencent/res、src/huawei/res等目录下Gradle 构建时会自动按变体选择对应的资源目录源码里不用写一堆 if-else。我个人在实际操作中还有一个心得在 CI 的打包机器上尽量保持干净的运行环境不要今天装这个 SDK 明天卸那个工具环境漂移是自动化部署最大的隐性敌人。打包机器的 JDK 版本、Android SDK 版本、Gradle 版本统一固定下来之后在日常构建中几乎不会出幺蛾子。数字一样、参数一样、流程一样才能实现你想要的这五个字——构建可复现。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑