上周五下午测试同事又来找我“帮我打个测试包最新代码的。”我打开终端切换分支、拉代码、跑一遍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、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、AGPAndroid 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装完要检查 PATHCI 机器上装 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 发行包。一个发行包少说 100MBCI 上如果网络不好光这一步就能卡住十分钟。解决办法是让 Gradle 缓存持久化。Jenkins 工作节点上Gradle 默认把缓存放在用户主目录的~/.gradle/caches里。只要你不主动清理这个目录第二次构建就会快很多。还有一个提速细节在项目的gradle.properties里把下面几行加上尤其是多模块项目收益很明显org.gradle.jvmargs-Xmx2048m -XX:MaxMetaspaceSize512m -Dfile.encodingUTF-8 org.gradle.paralleltrue org.gradle.cachingtrue其中org.gradle.cachingtrue开启构建缓存后如果代码没有改动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_NAMEassemble${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_NAMEassemble${FLAVOR^}${BUILD_TYPE^}${FLAVOR^}是 Bash 里的首字母大写语法把paid变成Paiddebug变成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默认值debugString 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_NAMEdevelopBUILD_TYPEdebugAPI_TOKEN 在 Jenkins 个人设置里生成不要用明文密码。如果你的团队用的是 GitLab CI对应的玩法是在.gitlab-ci.yml里定义一个手动触发的 jobbuild-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 把下载链接与二维码发到钉钉或企业微信群打完包不发群等于白打。我在脚本后面接了钉钉机器人用的是自定义机器人 WebhookDINGTALK_WEBHOOKhttps://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:MaxMetaspaceSize512m如果项目特别大可以给到-Xmx4g。另外--no-daemon这个参数在 CI 上到底加不加我的习惯是加上——CI 任务是一次性的不需要常驻后台 Daemon反而减少内存占用和被莫名吃掉的情况。并行构建org.gradle.paralleltrue对多模块项目有收益但如果你发现某个模块因为并行构建引入了编译竞争问题可以暂时关掉。CI 上稳定大于性能。6.5 签名策略测试包和正式包必须严格分开签名这个问题测试阶段最容易出现一种尴尬情况测试包用了debug签名正式包用releasekeystore然后测试同事说“为什么我手机上装不了新版测试包”那是因为同一个 app 包名如果签名不同是无法覆盖安装的。测试包用 debug keystore在~/.android/debug.keystore与正式包签名肯定不同所以手机上已经装了正式包时再装测试包就会提示“应用未安装”。我建议在 Gradle 配置里明确区分 debug 和 release 签名测试包固定用 debug keystore正式包用正式 keystore并且构建脚本里直接禁止在BUILD_TYPErelease时传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 三者版本对应关系永远是排在第一位的怀疑对象。这套链路我跑了少说几百次每次都能快速定位到具体环节比瞎猜高效得多。写到这里我回头想想从第一次被喊“打个包”到现在的“按钮化”区别不止省了几分钟操作而是整个团队拿包的姿势变了测试不用再盯人开发不用再反复解释“在哪个分支、哪个版本”构建脚本收纳了所有手工步骤任何人都能触发。如果你也要在公司里搭这么一套我的建议是环境优先、脚本次之、通知最后把稳固的构建机环境打磨好剩下的事情就是水到渠成。
阅读完成 · 觉得有帮助?