上周五下午,测试同事又来找我:“帮我打个测试包,最新代码的。”我打开终端,切换分支、拉代码、跑一遍gradlew assembleDebug,几分钟后从 build 目录里翻出那个 apk,改个名、传到内网、把链接丢到群里。这套动作熟练得让我自己都有点难过——因为已经是这个月第二十次了。于是我那天决定做个按钮:在 Jenkins 上挂一个参数化构建任务,把这条重复了无数遍的流程写成脚本,任何人点一下构建按钮,就能拿到一个新鲜的 Android 测试包。
这篇文章就聊聊这个“按钮”背后的完整方案:CI 选型、构建环境准备、打包脚本怎么写、参数化和远程触发怎么配、包打完之后怎么归档通知,以及我在上百次自动化打包里踩过的坑。如果你是团队里那个常年被喊“打测试包”的人,或者正准备把手工打包流程自动化,这篇应该能帮你少走不少弯路。
1. 一个重复了二十次的“打个包”,是怎么变成一键按钮的
1.1 手动打测试包的固定流程,先盘一下
先把手动流程完整列出来,你会发现步骤其实非常多。以 Android 项目为例:
- 打开终端,
git fetch并切到指定分支; git pull拉最新代码;- 确认本地 JDK、SDK、Gradle 环境没问题;
- 执行
./gradlew assembleDebug或指定 flavor 的构建命令; - 等三五分钟甚至更久,期间不能关终端;
- 去
app/build/outputs/apk/debug/下找到 apk; - 把文件名改成带日期和版本号的格式;
- 传到某个内部文件服务器、微信公众号或者蒲公英这类分发平台;
- 复制下载链接和安装说明,发到测试群里。
每一步都会出错。切错分支、拉代码拉一半、本地 gradle 缓存坏了、apk 装了没有立即出来、链接发成旧的、甚至有同事拿错旧包测了大半天。等你被这么折腾几次,就知道这个按钮不是“锦上添花”,是“刚需”。
自动化之后,人要做的只有一件事:点一下按钮,或者直接打开一个 URL。剩下全部交给 CI 服务器。
1.2 为什么先选了 Jenkins 而不是 GitLab CI 或 GitHub Actions
“Jenkins 或其它 CI 服务器”这个说法很现实。市面上的 CI 方案大致分三类:
| 方案 | 自托管难度 | 插件生态 | 触发方式 | 适合场景 |
|---|---|---|---|---|
| Jenkins | 中等,要维护服务 | 极丰富 | Web 界面、URL、Webhook、定时 | 部署在自己服务器的团队 |
| GitLab CI | 低,跟着 GitLab 走 | 一般 | Pipeline、Schedule、Webhook | GitLab 仓库用户 |
| GitHub Actions | 无,云端托管 | 极丰富 | workflow_dispatch、push、API | GitHub 仓库用户,不想维护机器 |
我们团队当时选 Jenkins,主要是因为仓库还在自建 GitLab 上,而且内部有些老项目要走特定的打包参数,Jenkins 的“参数化构建 + 自由风格任务”最直接,一个 Job 配好,后面就是点点点。GitLab CI 也不错,如果你已经把仓库迁到 GitLab,直接用它的.gitlab-ci.yml写一个 pipeline,效果类似。GitHub Actions 则是云端托管,连服务器都省了,唯一限制是这个机制更适合开源或 GitHub 仓库。
所以我下面的例子以 Jenkins 为主,但脚本本身是通用的,GitLab CI、GitHub Actions 一样能用。
1.3 “按钮”的本质:一个可触发的 CI 任务而已
很多人一听“CI 服务器上放个按钮”,以为是什么高级东西。其实拆开看,就是三个部分:一个构建任务、一段打包脚本、一个触发入口。
- 构建任务在 Jenkins 里叫Job,它定义了在哪个机器上执行、用什么参数、执行什么命令;
- 打包脚本就是我们自己写的 shell,它负责把 Job 传进来的参数翻译成一条完整的 Gradle 构建指令,并处理后续的产物;
- 触发入口可以是 Jenkins 网页上的Build with Parameters按钮、一条带参数的 URL,也可以是 GitLab CI 里的手动 pipeline。
技术难度不高,但要把这三个部分串联得顺滑、稳定,就需要一些经验了。后面我按“环境 → 脚本 → 参数 → 分发 → 排错”这个顺序,把每个环节的关键点讲透。
2. 构建环境是地基,版本不匹配会让你怀疑人生
2.1 JDK 版本与 Gradle 版本的对应关系
先讲环境,因为绝大多数自动化打包失败,根子都在环境上。Android 构建链路里,JDK、Gradle、AGP(Android Gradle Plugin)三者是死死绑定的。版本对不上,你会看到一堆让人头皮发麻的报错,比如:
Unsupported class file major version 61这句话翻译过来就是:你用的 JDK 版本太新或太旧,Gradle 不认识。
我整理了一份实际工作中常用的对应关系,可以直接当作参考表:
| Gradle 版本 | 最低 JDK | 推荐 AGP 版本 | 备注 |
|---|---|---|---|
| 6.x | JDK 8 | AGP 3.x ~ 4.x | 很老的项目还在用 |
| 7.x | JDK 11 | AGP 7.x | 主流稳定的一个组合 |
| 8.x | JDK 17 | AGP 8.x | 新的项目建议直接上 |
一个稳妥的建议:项目里的gradle/wrapper/gradle-wrapper.properties写着什么 Gradle 版本,就按上表选 JDK。如果有多个老项目混在一个 CI 机器上,建议直接用JDK 11,它能兼容 Gradle 6.x 到 7.x 的大部分场景。JDK 17 虽然新,但碰到老 AGP 会直接拒绝工作。
2.2 Android SDK 与 Build-Tools:装完要检查 PATH
CI 机器上装 Android SDK 是第二步。注意这里的坑比 JDK 更隐蔽:很多人以为装了 Android Studio 就等于有 SDK,其实 CI 服务器是纯命令行环境,根本不需要 Android Studio。
装 SDK 的正规姿势是通过命令行工具:
# 下载 command line tools 后解压到 /opt/android-sdk cd /opt/android-sdk/cmdline-tools/latest/bin # 接受 license yes | ./sdkmanager --licenses # 安装构建需要的包 ./sdkmanager "platform-tools" "platforms;android-33" "build-tools;33.0.1"装完之后,两个环境变量必须设置,而且要在 Jenkins 启动它的那个用户里能读到:
export ANDROID_HOME=/opt/android-sdk export ANDROID_SDK_ROOT=/opt/android-sdk export PATH=$PATH:$ANDROID_HOME/platform-tools:$ANDROID_HOME/build-tools有些项目在local.properties里写死 SDK 路径,这种做法我不太推荐在 CI 里用,因为 local.properties 是本地开发的文件,容易漏提交或者写错机器路径。更可靠的是让系统全局环境变量生效,后面我会讲 Jenkins 里一个小坑。
2.3 Wrapper 与 Gradle 缓存:构建提速的关键
用./gradlew而不是系统安装的gradle,是个基本常识。Wrapper 会把 Gradle 版本锁死在项目里,团队所有人都用同一个版本,CI 也一样。
但第一次用 Wrapper 时会有一个明显痛点:下载 Gradle 发行包。一个发行包少说 100MB,CI 上如果网络不好,光这一步就能卡住十分钟。解决办法是让 Gradle 缓存持久化。Jenkins 工作节点上,Gradle 默认把缓存放在用户主目录的~/.gradle/caches里。只要你不主动清理这个目录,第二次构建就会快很多。
还有一个提速细节:在项目的gradle.properties里把下面几行加上,尤其是多模块项目,收益很明显:
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 org.gradle.parallel=true org.gradle.caching=true其中org.gradle.caching=true开启构建缓存后,如果代码没有改动,Gradle 可以直接从缓存里拿现成的编译结果,这个对 CI 尤其关键——你的测试包构建时间可能直接从 5 分钟降到 1 分半。
3. 打测试包的 Shell 脚本,我拆成四段讲给你听
环境准备好了,下面进入正题:那个“按钮”真正执行的脚本。我把它拆成四段,方便你理解每一段在干嘛,也可以直接抄走改改就能用。
这是完整脚本的基本骨架:
#!/bin/bash set -e # ===== 1. 参数接收区 ===== BRANCH_NAME="${BRANCH_NAME:-develop}" BUILD_TYPE="${BUILD_TYPE:-debug}" FLAVOR="${FLAVOR:-}" VERSION_NAME="${VERSION_NAME:-$(date +%Y%m%d%H%M)}" WORKSPACE_DIR="${WORKSPACE:-/var/lib/jenkins/workspace/your-project}" echo "========== 开始构建测试包 ==========" echo "分支: $BRANCH_NAME" echo "构建类型: $BUILD_TYPE" echo "版本号: $VERSION_NAME" # ===== 2. 切换分支并拉取最新代码 ===== cd "$WORKSPACE_DIR" git fetch --all git checkout "$BRANCH_NAME" git pull origin "$BRANCH_NAME" # ===== 3. 执行 Gradle 构建 ===== TASK_NAME="assemble${FLAVOR^}${BUILD_TYPE^}" echo "执行的 Gradle 任务: $TASK_NAME" ./gradlew clean "$TASK_NAME" \ -PversionName="$VERSION_NAME" \ --no-daemon # ===== 4. 产物定位、归档、清理 ===== APK_DIR="$WORKSPACE_DIR/app/build/outputs/apk" ARCHIVE_DIR="$WORKSPACE_DIR/test-apk-archive" mkdir -p "$ARCHIVE_DIR" NEWEST_APK=$(find "$APK_DIR" -name "*.apk" -mmin -5 | head -n 1) if [ -z "$NEWEST_APK" ]; then echo "错误:没有找到构建产物" exit 1 fi TARGET_NAME="${FLAVOR}-${BUILD_TYPE}-${VERSION_NAME}.apk" cp "$NEWEST_APK" "$ARCHIVE_DIR/$TARGET_NAME" find "$ARCHIVE_DIR" -name "*.apk" -mtime +7 -delete echo "========== 构建完成 ==========" echo "APK 路径: $ARCHIVE_DIR/$TARGET_NAME"下面我逐段解释为什么这么写。
3.1 第一段:参数接收、分支切换与版本号注入
脚本开头用${BRANCH_NAME:-develop}这种写法,意思是:如果环境变量BRANCH_NAME存在就用它,否则用默认值develop。这是 Jenkins 参数化构建和本地手动执行共用一套脚本的关键——Jenkins 会往里传环境变量,你自己在终端里跑时又能有默认值兜底。
版本号推荐用时间戳,形如202405071830,简单直观,测试同事一看就知道是哪个时间打的包。如果项目有语义化版本号需求,也可以从 Git tag 取:
VERSION_NAME="${VERSION_NAME:-$(git describe --tags --always)}"这样打出来的包就是v1.2.3-2-abcdef这种样式,适合按版本迭代管理的团队。
切分支那段有个细节:git fetch --all之后再git checkout,能避免远端删了分支、本地还留着旧引用的尴尬情况。git pull这里其实作用和其它命令有一点重叠,但你多写一行无妨——它能保证拉取的是远端最新状态,而不是本地缓存的旧引用。
3.2 第二段:assemble 命令的拼装逻辑与写法
Gradle 的 task 名称是驼峰式的,比如assembleDebug、assembleRelease,如果有 flavor 就是assemblePaidDebug这种。脚本里我用:
TASK_NAME="assemble${FLAVOR^}${BUILD_TYPE^}"${FLAVOR^}是 Bash 里的首字母大写语法,把paid变成Paid,debug变成Debug。这一步很容易踩坑:assemblepaiddebug这种全小写会直接报 “Task not found”。所以如果要支持自定义 flavor,这个大小写转换是必须的,不如直接用一个case写得明明明白白:
case "$BUILD_TYPE" in debug|release) ;; *) echo "不支持的 BUILD_TYPE"; exit 1 ;; esacclean任务我是刻意加进去的。测试包不像生产包那么追求构建速度,稳定可靠永远是第一位的。后面第六章我会专门讲不 clean 会带来什么诡异问题。
3.3 第三段:APK 定位、改名与按日期归档
构建完成之后,最怕的一件事就是“apk 到底在哪”。多模块项目、带 flavor 的项目,产物路径五花八门。我见过有人写死app/build/outputs/apk/debug/app-debug.apk,结果某天改了 flavor,脚本就白等了。
我的做法是先清场再定位。构建前 APK 输出目录通常是空的,构建后我用:
find "$APK_DIR" -name "*.apk" -mmin -5 | head -n 1-mmin -5表示找出最近 5 分钟被修改的 apk,逻辑是:既然 Gradle 构建刚结束,最新的 apk 一定是刚刚生成的,不会和旧产物混淆。配合前面强制clean,基本不会定位错误。
找到之后,按flavor-buildType-version.apk的格式重命名并归档到统一目录,这是一个很有价值的习惯。测试同事在群里看到消息时,往下拉就能看到一串清晰的包名,不用打开文件管理器翻半天。
3.4 第四段:产物清单与基础清理
很多脚本到最后一步都会忽略清理。测试包打多了,磁盘空间会悄悄被吃光。我在脚本里用了非常简单粗暴的一条:
find "$ARCHIVE_DIR" -name "*.apk" -mtime +7 -delete保留 7 天内的包,过期的直接删。测试包本来就是临时产物,留 7 天足够了。
如果你想更复杂一点,可以生成一个manifest.txt,把每次打包的时间、分支、版本号、APK 路径写进去,后续做通知或追溯时非常方便:
echo "$(date '+%Y-%m-%d %H:%M:%S') | $BRANCH_NAME | $VERSION_NAME | $ARCHIVE_DIR/$TARGET_NAME" >> "$WORKSPACE_DIR/build-manifest.txt"这一步看似简单,但当你需要回答“这个 bug 是哪个包测出来的”时,它就是救命稻草。
4. 让按钮更“好用”:参数化构建与多分支触发
4.1 Jenkins 参数化构建界面应该怎么配
脚本写好了,下一步是把参数暴露到 Jenkins 界面上。新建一个自由风格任务,在General区域勾选This project is parameterized,然后添加三种参数:
- String Parameter,变量名
BUILD_TYPE,默认值debug; - String Parameter,变量名
VERSION_NAME,留空,为空时脚本用时间戳; - Choice Parameter,变量名
FLAVOR,选项填normal和paid(按你的项目实际来)。
如果你用的是 Git 管理代码,建议再加一个Git Parameter,这样构建时可以直接从下拉框选分支,不用手动输入字符串。
配完之后,打开任务的Build with Parameters页面,那个按钮才是真正的“一键打测试包按钮”。
4.2 Git Parameter 选分支:想打哪个分支就打哪个
Git Parameter 插件算是 Jenkins 里装完几乎不会卸载的插件。用法是在参数化构建里加一个Git Parameter,类型选Branch,变量名取BRANCH_NAME,默认值设origin/develop。
它的好处是,构建页面会动态读取远端分支列表,下拉选择即可。这样脚本里的BRANCH_NAME变量就来自用户选择,配合前面写好的分支切换逻辑,测试同事想测哪个分支就测哪个分支,不用动任何配置。
这里有一个小建议:分支名带origin/前缀的话,脚本里git checkout "$BRANCH_NAME"是可以直接用的,但如果用户选的是 tag,别用git pull,会拉不动。所以脚本的拉取逻辑最好改成:
git checkout "$BRANCH_NAME" git pull origin "$(basename "$BRANCH_NAME")" || true或者干脆判断一下$BRANCH_NAME是否以refs/tags/开头,是 tag 就只 checkout 不 pull。
4.3 用一条 URL 远程唤醒构建,以及其它 CI 的对应玩法
网页上点按钮很方便,但有时候我们希望“按钮”藏在内部系统里,点一个链接就触发构建。Jenkins 提供了远程触发 API,只要开启任务里的Trigger builds remotely并配一个 token,就能用一条 curl 命令唤醒:
curl -X POST \ -u "用户名:API_TOKEN" \ "http://jenkins.example.com/job/你的任务名称/buildWithParameters?BRANCH_NAME=develop&BUILD_TYPE=debug"API_TOKEN 在 Jenkins 个人设置里生成,不要用明文密码。
如果你的团队用的是 GitLab CI,对应的玩法是在.gitlab-ci.yml里定义一个手动触发的 job:
build-test-apk: stage: build when: manual script: - ./scripts/build-test-apk.sh这样在 GitLab 的 pipeline 页面也会有一个手动执行的按钮,效果和 Jenkins 一模一样。GitHub Actions 则是在 workflow 文件里写workflow_dispatch,然后在仓库的 Actions 页面点Run workflow。
殊途同归,核心思路都一样:把脚本挂到一个能手动触发的入口上,参数通过环境变量传下去。
5. 打包完成之后:归档上传、二维码、群通知一条龙
5.1 上传到蒲公英/FIR/自建服务器的脚本姿势
测试包打出来,只有躺在构建机硬盘上是不够的,得让同事能下载。最常见三个去处:
蒲公英(PGYER)用他们家的 API 接口,一行 curl 就传完:
curl -F "file=@$ARCHIVE_DIR/$TARGET_NAME" \ -F "_api_key=你的APIKey" \ https://www.pgyer.com/apiv2/app/upload蒲公英返回的 JSON 里有buildShortUrl和buildQRCodeURL,一个短链接一个二维码,直接能用于通知。
FIR.im类似,但接口格式不一样,需要先获取 API Token 再传文件,我实测用起来差不多,看团队习惯选择。
自建 nginx 目录如果公司有内网文件服务,最省事的其实是把 APK 复制到 nginx 的目录下,然后提供一个固定格式的 URL。例如:
cp "$ARCHIVE_DIR/$TARGET_NAME" /var/www/html/apk/ echo "下载地址: http://internal.example.com/apk/$TARGET_NAME"这个方案的好处是零第三方依赖,只要内网能访问就够。缺点是没有二维码、没有版本管理页面,体验比蒲公英差一些,看团队需求取舍。
5.2 把下载链接与二维码发到钉钉或企业微信群
打完包不发群,等于白打。我在脚本后面接了钉钉机器人,用的是自定义机器人 Webhook:
DINGTALK_WEBHOOK="https://oapi.dingtalk.com/robot/send?access_token=你的Token" curl -s -H "Content-Type: application/json" \ -d "{ \"msgtype\": \"markdown\", \"markdown\": { \"title\": \"新测试包来了\", \"text\": \"## Android 测试包 \n- 分支: $BRANCH_NAME \n- 版本: $VERSION_NAME \n- 下载: [点击下载](http://internal.example.com/apk/$TARGET_NAME) \n- 安装密码: 无\" } }" "$DINGTALK_WEBHOOK"企业微信机器人也差不多,把msgtype改成markdown,把 webhook 地址换掉即可。
二维码倒不用额外生成。如果你用的是蒲公英,它的接口会直接返回二维码 URL;如果走自建服务器,可以用本地 qrencode 生成一个 PNG,再把图片放到 nginx 下,链接一并发到群里:
qrencode -o /var/www/html/qr/$TARGET_NAME.png "http://internal.example.com/apk/$TARGET_NAME"扫码安装对手机端测试来说还是比敲 URL 方便太多了,测试同事拿着手机对着电脑屏幕扫一下就能装。
5.3 按需清理:别让构建机变成硬盘杀手
构建机的磁盘是消耗品。日志、Gradle 缓存、APK 归档,每一样都在默默增长。以我们项目为参考:一个 apk 大约 80MB,一天平均打 8 个包,一天就是 640MB,一周接近 4.5GB。不清理的话,一个月不到就 20GB 没了。
Jenkins 层面,任务配置里有个Discard old builds,设置保留最近构建次数和天数,这个必须开。脚本层面,前面已经用find -mtime +7 -delete清了 APK 归档。再补一个针对 Gradle 缓存的定期清理任务,比如每月跑一次:
find ~/.gradle/caches -type f -mtime +30 -delete注意这个命令要谨慎,它会删掉 30 天未使用的缓存文件。如果 CI 构建频率高、缓存一直在用,删掉无害;如果某个月某台机器没怎么构建,下个月第一次构建会重新下载依赖,稍微慢一点,但总比磁盘爆掉好。
6. 上百次自动化打包里踩过的坑,和完整排查链路
这一章是全文我最想写的部分。脚本本身不难,难的是跑起来之后各种莫名其妙的失败。
6.1 构建超时:不是脚本问题,是默认时长太短
第一次跑自动化打包,我等了 10 分钟,job 直接标红 Failed。日志尾部写着“Build timed out”。排查之后发现问题根本不在脚本,而是 Jenkins 默认的构建超时设置。如果配置了全局超时或者用了Build Timeout插件,默认时间可能只有 15 分钟。而 Android 项目的首次构建,尤其是没有缓存时,10 到 15 分钟是很正常的。
GitLab CI 也有类似问题,默认 job timeout 通常是 1 小时。GitHub Actions 则是 6 小时上限,一般够用。解决办法很简单:把超时调到 30 分钟,同时确认 Gradle 缓存已经持久化。如果你发现加了缓存之后构建时间还在 15 分钟以上,那多半是依赖下载没有命中本地缓存,需要检查 CI 机器的网络和仓库源。
6.2 环境变量在 Jenkins 服务里的坑:SDK location not found
报错:
SDK location not found. Define location with an ANDROID_HOME environment variable or by setting the sdk.dir path in local.properties.我看到这条日志的次数,比我加班次数还多。原因非常有代表性:Jenkins 如果以系统服务方式启动(比如systemctl start jenkins),它运行在后台,默认不会读取某个用户的~/.bashrc。你手动在终端里export ANDROID_HOME,以为万事大吉,重启 Jenkins 服务后变量又没了。
正确做法是把环境变量写进 Jenkins 自身的系统配置:Manage Jenkins → System → Global properties,勾选Environment variables,添加ANDROID_HOME、ANDROID_SDK_ROOT、JAVA_HOME。这样所有 job 都能读到,不会因为用户切换而丢失。
排查链路也顺手说一下:先在构建节点的终端里手动跑一次echo $ANDROID_HOME,确认有没有值;再在 Jenkins 的 job 里加一个构建步骤printenv | grep ANDROID,看看 job 进程里到底是什么环境。两相对比,差距一目了然。
6.3 缓存导致的“假构建”:clean 还是不 clean,这是个问题
另一个高发问题:代码明明改了,测试包却是旧的。我第一次碰到时百思不得其解,最后发现是 Gradle 增量编译和构建缓存合作出来的“假构建”。
Gradle 会判断哪些 task 是 up-to-date,如果它认为某个 task 的输出没有变化,就会直接跳过。但有时候这个判断会被本地未跟踪的文件、文件时间戳、Daemon 状态干扰,导致一部分模块没重新编译。所以打测试包这件事,我最终选择了一条稳妥路线:每次构建都执行 clean。
当然,clean 的代价是构建时间变长。如果你想折中,可以只在确认“增量构建可能出问题”时 clean,比如:
if [ "$CLEAN_BUILD" = "true" ]; then ./gradlew clean "$TASK_NAME" else ./gradlew "$TASK_NAME" fi再在 Jenkins 里加一个布尔参数CLEAN_BUILD,默认值为 true。测试包走稳定优先路线,正式发布包再考虑不 clean 的快速构建。
6.4 Gradle Daemon 内存溢出与并行构建参数
构建过程中另一个常见故障是:
GC overhead limit exceeded或者干脆就OutOfMemoryError。原因是默认的 Gradle JVM 堆太小,尤其在 CI 机器上同时跑多个构建任务时。我建议在gradle.properties里明确写好内存参数:
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m如果项目特别大,可以给到-Xmx4g。另外--no-daemon这个参数在 CI 上到底加不加,我的习惯是加上——CI 任务是一次性的,不需要常驻后台 Daemon,反而减少内存占用和被莫名吃掉的情况。
并行构建org.gradle.parallel=true对多模块项目有收益,但如果你发现某个模块因为并行构建引入了编译竞争问题,可以暂时关掉。CI 上稳定大于性能。
6.5 签名策略:测试包和正式包必须严格分开
签名这个问题,测试阶段最容易出现一种尴尬情况:测试包用了debug签名,正式包用releasekeystore,然后测试同事说“为什么我手机上装不了新版测试包?”
那是因为同一个 app 包名,如果签名不同,是无法覆盖安装的。测试包用 debug keystore(在~/.android/debug.keystore),与正式包签名肯定不同,所以手机上已经装了正式包时,再装测试包就会提示“应用未安装”。
我建议在 Gradle 配置里明确区分 debug 和 release 签名,测试包固定用 debug keystore,正式包用正式 keystore,并且构建脚本里直接禁止在BUILD_TYPE=release时传VERSION_NAME为时间戳——避免误发一个带测试标记的 release 包。
签名相关还有一个坑:debug keystore 在某些较新的 AGP 版本里,会自动生成并放在用户目录,但如果 CI 机器的~/.android/目录权限不对,或者构建用户和 owner 不一致,会报keystore tampered or password incorrect。遇到这个,先确认debug.keystore的所有者和JAVA_HOME对应的 keytool 版本,别急着删文件。
6.6 一套通用的 CI 构建排错顺序
最后,把排错思路总结成一套顺序,照着做基本能覆盖 90% 的问题:
- 看日志尾部。Jenkins 失败时,不要从头看,直接看最后 100 行,先确认是哪一步出的错——是拉代码、Gradle 编译、还是上传通知。
- 在节点上手动复现。登录构建机,切到工作目录,用同一个参数手动执行脚本。如果手动能通过,说明 CI 环境配置有问题;如果手动也失败,说明脚本或项目本身有问题。
- 检查环境变量和权限。
echo $ANDROID_HOME、ls -la确认构建用户对目录有写权限。Jenkins service 启动方式和普通用户手动执行,环境完全不同。 - 检查磁盘与网络。
df -h看磁盘,ping或curl -I看外部依赖仓库是否可达。 - 看 Gradle 缓存。如果任务是增量构建,清掉相关模块的缓存再试一次,排除“假构建”的可能。
- 看插件和版本。JDK/Gradle/AGP 三者版本对应关系,永远是排在第一位的怀疑对象。
这套链路我跑了少说几百次,每次都能快速定位到具体环节,比瞎猜高效得多。
写到这里,我回头想想,从第一次被喊“打个包”到现在的“按钮化”,区别不止省了几分钟操作,而是整个团队拿包的姿势变了:测试不用再盯人,开发不用再反复解释“在哪个分支、哪个版本”,构建脚本收纳了所有手工步骤,任何人都能触发。如果你也要在公司里搭这么一套,我的建议是:环境优先、脚本次之、通知最后,把稳固的构建机环境打磨好,剩下的事情就是水到渠成。