☰
一键搞定Android测试包:基于Jenkins的自动化打包实践
2026/10/2 10:20:39 网站建设 项目流程

上周五下午,测试同事又来找我:“帮我打个测试包,最新代码的。”我打开终端,切换分支、拉代码、跑一遍gradlew assembleDebug,几分钟后从 build 目录里翻出那个 apk,改个名、传到内网、把链接丢到群里。这套动作熟练得让我自己都有点难过——因为已经是这个月第二十次了。于是我那天决定做个按钮:在 Jenkins 上挂一个参数化构建任务,把这条重复了无数遍的流程写成脚本,任何人点一下构建按钮,就能拿到一个新鲜的 Android 测试包。

这篇文章就聊聊这个“按钮”背后的完整方案:CI 选型、构建环境准备、打包脚本怎么写、参数化和远程触发怎么配、包打完之后怎么归档通知,以及我在上百次自动化打包里踩过的坑。如果你是团队里那个常年被喊“打测试包”的人,或者正准备把手工打包流程自动化,这篇应该能帮你少走不少弯路。

1. 一个重复了二十次的“打个包”,是怎么变成一键按钮的

1.1 手动打测试包的固定流程,先盘一下

先把手动流程完整列出来,你会发现步骤其实非常多。以 Android 项目为例:

  1. 打开终端,git fetch并切到指定分支;
  2. git pull拉最新代码;
  3. 确认本地 JDK、SDK、Gradle 环境没问题;
  4. 执行./gradlew assembleDebug或指定 flavor 的构建命令;
  5. 等三五分钟甚至更久,期间不能关终端;
  6. 去app/build/outputs/apk/debug/下找到 apk;
  7. 把文件名改成带日期和版本号的格式;
  8. 传到某个内部文件服务器、微信公众号或者蒲公英这类分发平台;
  9. 复制下载链接和安装说明,发到测试群里。

每一步都会出错。切错分支、拉代码拉一半、本地 gradle 缓存坏了、apk 装了没有立即出来、链接发成旧的、甚至有同事拿错旧包测了大半天。等你被这么折腾几次,就知道这个按钮不是“锦上添花”,是“刚需”。

自动化之后,人要做的只有一件事:点一下按钮,或者直接打开一个 URL。剩下全部交给 CI 服务器。

1.2 为什么先选了 Jenkins 而不是 GitLab CI 或 GitHub Actions

“Jenkins 或其它 CI 服务器”这个说法很现实。市面上的 CI 方案大致分三类:

方案自托管难度插件生态触发方式适合场景
Jenkins中等,要维护服务极丰富Web 界面、URL、Webhook、定时部署在自己服务器的团队
GitLab CI低,跟着 GitLab 走一般Pipeline、Schedule、WebhookGitLab 仓库用户
GitHub Actions无,云端托管极丰富workflow_dispatch、push、APIGitHub 仓库用户,不想维护机器

我们团队当时选 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.xJDK 8AGP 3.x ~ 4.x很老的项目还在用
7.xJDK 11AGP 7.x主流稳定的一个组合
8.xJDK 17AGP 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 ;; esac

clean任务我是刻意加进去的。测试包不像生产包那么追求构建速度,稳定可靠永远是第一位的。后面第六章我会专门讲不 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,然后添加三种参数:

  1. String Parameter,变量名BUILD_TYPE,默认值debug;
  2. String Parameter,变量名VERSION_NAME,留空,为空时脚本用时间戳;
  3. 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% 的问题:

  1. 看日志尾部。Jenkins 失败时,不要从头看,直接看最后 100 行,先确认是哪一步出的错——是拉代码、Gradle 编译、还是上传通知。
  2. 在节点上手动复现。登录构建机,切到工作目录,用同一个参数手动执行脚本。如果手动能通过,说明 CI 环境配置有问题;如果手动也失败,说明脚本或项目本身有问题。
  3. 检查环境变量和权限。echo $ANDROID_HOME、ls -la确认构建用户对目录有写权限。Jenkins service 启动方式和普通用户手动执行,环境完全不同。
  4. 检查磁盘与网络。df -h看磁盘,ping或curl -I看外部依赖仓库是否可达。
  5. 看 Gradle 缓存。如果任务是增量构建,清掉相关模块的缓存再试一次,排除“假构建”的可能。
  6. 看插件和版本。JDK/Gradle/AGP 三者版本对应关系,永远是排在第一位的怀疑对象。

这套链路我跑了少说几百次,每次都能快速定位到具体环节,比瞎猜高效得多。

写到这里,我回头想想,从第一次被喊“打个包”到现在的“按钮化”,区别不止省了几分钟操作,而是整个团队拿包的姿势变了:测试不用再盯人,开发不用再反复解释“在哪个分支、哪个版本”,构建脚本收纳了所有手工步骤,任何人都能触发。如果你也要在公司里搭这么一套,我的建议是:环境优先、脚本次之、通知最后,把稳固的构建机环境打磨好,剩下的事情就是水到渠成。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询