Mac上基于Jenkins的Android自动化打包实战:从环境配置到Pipeline构建 1. 需求分析和方案选型为什么非要做自动化打包在 Mac 上做 Android 开发如果你还在用“Android Studio 手动 Build - 找 APK - 改版本号 - 再 Build”这套流程大概率是个少于三个模块的小项目。一旦业务模块超过五个、渠道包超过三个或者团队里同时有三四个开发在改代码手动打包很快就会变成一场灾难。我接手这个项目的时候情况就是典型的“半自动化”状态开发本地打包签名 APK然后扔到群文件里供测试下载。听起来还行实际上光“版本对不上”这一个问题就出过好几次——测试拿到的包不是开发最新的开发改了代码忘了重新出包还有人把 debug 包当成 release 包发出去。这种问题靠自觉是无法根治的必须用工具把流程锁死。当时我选了 Jenkins而不是 GitLab CI 或 GitHub Actions主要原因是项目代码仓库还是 SVN而且团队没有统一迁移到 Git 的时间表。GitLab CI 虽然好但前提是代码得在 GitLab 上GitHub Actions 连私有仓库都要收费跑得才舒服。Jenkins 则完全没有这些限制它就是个独立的服务器进程只要这台 Mac 能访问到代码仓库地址配置好触发方式任务就能跑起来。另一个选 Jenkins 的原因是团队里不只是 Android 一个端。iOS 那边也需要做构建和分发。Jenkins 可以通过节点把 iOS 和 Android 的构建任务统一管理起来哪怕一开始只做 Android后面扩 iOS 也不用再搭一套环境。还有一点容易被忽略Jenkins 是主动拉取代码不依赖代码仓库开放回调。有些自建仓库的网络环境不允许外网回调GitLab CI 的 webhook 就推不进来而 Jenkins 用轮询或者定时触发完全绕开了这个限制。这个特点在某些内网环境里是决定性优势。方案定下来之后整个自动化的目标是开发把代码 push 到仓库触发 Jenkins 构建自动拉代码、自动构建、自动签名、自动出多套产物最后产物存到固定路径并推送通知。中间不涉及人工干预所有参数在构建前可配置。2. 环境准备搭建 Jenkins 前必须做好的三件事很多人一上来就 brew install jenkins然后装上插件就开始建任务结果跑第一个构建就扑街——不是 SDK 找不到就是 JAVA_HOME 没配置或者 gradle 版本不对。归根结底是基础环境没理清。你要是能重来一次先花半小时把环境梳理好后面能省两天时间。我们实际部署的这台构建机是 Mac mini配置不算高M1 芯片16G 内存跑了三个任务一个 Android release 包、一个 Android 内部测试包、一个 iOS 的 archive 包。日均构建量大约 20~30 次运行很稳定。2.1 JDK 版本选择Android 构建的命门这是整个环境里最容易翻车的地方。Android Gradle 插件AGP对 JDK 版本有明确要求比如 AGP 8.x 必须用 JDK 17 编译而早期项目用的 AGP 4.x 在 JDK 11 上跑反而可能报错。我们的项目用的 AGP 7.4.2要求 JDK 11 起步但实测 JDK 17 也能编译所以我在构建机上装了 OpenJDK 17。要说一点经验尽量装 JDK 17 而不是 JDK 11因为后续 Gradle 和 AGP 升级空间大而且 Jenkins 本身的很多插件也需要比较新的 Java 版本。JDK 11 虽然能跑但你在 Jenkins 插件升级时就会碰到警告与其纠结不如直接上 17。安装方法很简单我用的是 Homebrew。别用系统自带的 java 路径设置那玩意儿经常漂移。装完后把 JAVA_HOME 写在 jenkins 的启动脚本里而不是放在 shell 的 .zshrc 里——因为 Jenkins 作为守护进程启动时不一定加载你用户 shell 的配置文件。brew install openjdk17装完后找到 JDK 路径我建议记下来/usr/libexec/java_home -v 17这个命令会输出 JDK 17 的完整路径比如/opt/homebrew/opt/openjdk17/libexec/openjdk.jdk/Contents/Home。后面配置 Jenkins 全局属性时会用到。2.2 Android SDK 和 Gradle版本不对直接白给构建机上必须要有一套完整的 Android SDK。我这里说的“完整”不是指 Android Studio 自带的那种基础 SDK而是包含你项目里用到的所有 platform、build-tools 和 ndk 版本。我们项目用的 compileSdk 是 34build-tools 是 30.0.3还用到了一点 NDK 交叉编译的内容。所以我在构建机上安装了 SDK Platform 34 和 33build-tools 装了 30.0.3、33.0.1 和 34.0.0。为什么不只装一个版本因为你永远不会知道哪个库的依赖会强制要求用某个 build-tools 版本。如果 Gradle 在构建时发现缺版本它会尝试通过 SDK Manager 自动下载但自动下载经常因为网络或者权限问题失败与其赌它不如提前把常用版本都装上。Gradle 本身不用手动下载。Jenkins 构建时Gradle Wrapper 会从 services.gradle.org 下载对应版本的 Gradle 发行包。如果构建机的网络访问外网不稳定最好提前把项目需要的 Gradle 版本下载好放到~/.gradle/wrapper/dists里否则第一次构建会卡在下载阶段让人误以为死掉了。我在实际部署时踩了一个坑项目里distributionUrl写的是 gradle-7.5-bin.zip但 Jenkins 服务器在内网下载这个 zip 文件走了很久。最后我是在有网的环境手动把 zip 塞到了 Gradle Wrapper 缓存目录然后构建速度就恢复正常了。这个方法值得记下来在离线环境下特别有用。2.3 Jenkins 安装方式dmg 还是 Homebrew安装 Jenkins 有两种常见方式一种是官方提供的 macOS installerdmg 包一种是 Homebrew service 方式。我强烈推荐用 Homebrew 方式。原因有三第一dmg 方式安装出来是一个 app在 macOS 的新版本上可能遇到“无法验证开发者”之类的权限问题第二dmg 方式下 Jenkins 的 JENKINS_HOME 路径不太直观找配置文件和 job 目录要费一些功夫第三Homebrew 方式可以通过命令直接查看日志、重启服务排查问题的时候方便得多。安装命令brew install jenkins-lts brew services start jenkins-lts这里用的是jenkins-lts而不是jenkins因为 LTS 版本更加稳定插件兼容性验证做得更充分。自动化构建环境优先追求稳定没必要追新。安装完成后浏览器访问http://localhost:8080初始密码在/Users/你的用户名/.jenkins/secrets/initialAdminPassword里。如果你换了一台机器或者换了一个用户这个路径会变注意结合自己的 JENKINS_HOME 找。2.4 密钥配置Jenkins 和代码仓库之间不带密码通信构建机需要拉取代码如果代码仓库是私有的你得让 Jenkins 能“免密”访问。两种方式用户名 密码凭据Credential简单直接但密码一旦过期就得更新 Jenkins 里的凭据。SSH Key推荐方式。把构建机的公钥加入代码仓库的部署密钥列表后续不用管密码。我在构建机上专门生成了一对只用于构建的密钥没有用开发者的个人密钥ssh-keygen -t rsa -b 4096 -f ~/.ssh/jenkins_deploy_key生成的公钥放到代码仓库的 SSH 部署密钥里不同的仓库管理平台入口不同但一般都在仓库设置里私钥内容则添加到 Jenkins 的凭据管理中。提示私钥文件只添加进 Jenkins不要在构建机随便开放其他用户的读取权限。构建机如果多人共用密钥泄露的风险是真实存在的。3. Jenkins 核心配置任务建好了不等于能跑通环境准备完成后进入 Jenkins 配置环节。很多新手在这里最容易卡住因为 Jenkins 的配置入口实在多——全局工具、系统设置、凭据、节点管理有时候同一个配置可以在两个地方设置让人一头雾水。我先给一个配置顺序全局工具 - 系统属性 - 凭据 - 任务配置。这个顺序走下来最不容易乱。3.1 全局工具配置JDK、Gradle 和 ANDROID_HOME 一次设对进入“Manage Jenkins” - “Tools”在这里配置 JDK 和 Gradle。JDK 有两种配置方式一种是让 Jenkins 自动下载 JDK 到构建机一种是指定已安装的 JDK 路径。我建议使用“Install automatically”之外的“Let Jenkins manage”方式时务必小心——自动下载在国内网络环境下经常失败。直接选择“Use system tool”并填入 JAVA_HOME 路径最稳。Gradle 工具同理。但这里有一个有意思的选择如果在 Tools 里配置了 Gradle构建时 Jenkins 会用你自己指定的全局 Gradle 版本而不是项目里的 Gradle Wrapper。这会导致一个坑本地开发用 Wrapper 的 Gradle 7.5Jenkins 却用全局的 Gradle 8.x两者编译出来的产物可能有细微差异。为了避免这个不一致在构建步骤里最好统一使用./gradlew命令让 Jenkins 里的构建和开发本地的构建完全一致。全局 Gradle 工具可以留着不用但千万别让任务“意外”用了它。ANDROID_HOME 环境变量建议写在 Jenkins 的“Manage Jenkins” - “System” - “Global properties”里。添加一个环境变量ANDROID_HOME /Users/你的用户名/Library/Android/sdk也有人建议直接在构建命令里 export但全局设置写一次后续所有任务都能用不用重复配置。3.2 凭据管理签名文件和仓库密码的安全存放Jenkins 的 Credentials 功能是用来安全存储敏感信息的。打开“Manage Jenkins” - “Credentials”添加以下几类内容代码仓库的 SSH 私钥类型选 “SSH Username with private key”如果有需要访问私有 Maven 仓库的账号密码也加进去Android 签名用的 keystore 信息虽然不能直接存 keystore 文件但可以把 keystore 的密码存成 Secret text实际项目中签名文件需要特别小心处理。不要把 keystore 文件直接放在项目仓库里因为一旦仓库泄露签名文件就等于公开了。我用的是另一种方案把 keystore 文件放在构建机上的固定目录比如/Users/你的用户名/secure/keystore/release.jks并把它的绝对路径写进 Jenkins 任务参数里让构建脚本去读取。这样即使访问构建机的人也无法直接通过配置界面把 keystore 内容拉走。目录权限设为 700只允许构建用户访问。提示永远不要在构建日志里打印 keystore 密码。Gradle 命令如果带密码参数在 Jenkins 的控制台日志里就会暴露所以我建议把密码放在~/.gradle/gradle.properties文件里写成RELEASE_STORE_PASSWORD***构建时 Gradle 会自动读取。3.3 创建自由风格任务还是流水线Jenkins 创建任务有两种主流方式Freestyle project自由风格和 Pipeline。对于简单的 Android 打包场景很多人习惯用 Freestyle觉得上手快。但我的建议是直接上 Pipeline。原因不复杂Freestyle 的构建步骤、参数、环境配置全部点击配置在页面上虽然直观但版本管理困难。一旦任务要改动你得去 Jenkins 网页上找对应位置修改没有变更记录也没有代码评审。而 Pipeline 可以用 Jenkinsfile 把整个构建流程写成代码放到项目仓库里跟着代码一起版本化。改构建脚本要走正常的代码提交流程出错了可以 diff这在多人协作中实在太重要了。Pipeline 的入门门槛稍高但只要熬过基本语法后面收益很大。最简单的 Pipeline 长这样pipeline { agent any environment { ANDROID_HOME /Users/你的用户名/Library/Android/sdk } stages { stage(拉取代码) { steps { checkout scm } } stage(构建 APK) { steps { sh ./gradlew assembleRelease } } stage(归档产物) { steps { archiveArtifacts artifacts: app/build/outputs/apk/release/*.apk } } } }这个脚本就做了三件事拉代码、构建、归档。如果只需要最基本的功能这就够了。但实际项目中我们会在里面加上构建参数的传递、版本号自动更新、多渠道打包等逻辑这就涉及更细的写法。4. 实战构建脚本从基础打包到多产物输出Pipeline 框架搭好之后核心工作就是把构建脚本打磨到好用的程度。我直接分享我们项目最终用的脚本结构和关键片段。4.1 把构建参数暴露出来构建时我们经常需要指定构建类型release / debug、渠道、版本号、是否上传到分发平台。这些不应写死在脚本里而应作为 Jenkins 任务的参数配置。在 Pipeline 脚本里使用parameters指令声明parameters { choice(name: BUILD_TYPE, choices: [release, debug], description: 构建类型) string(name: VERSION_NAME, defaultValue: 1.0.0, description: 版本号) booleanParam(name: UPLOAD_TO_PLATFORM, defaultValue: false, description: 是否上传分发平台) }然后在构建时通过params.BUILD_TYPE引用。你会发现这样做的最大好处是同一个 Jenkins 任务不需要复制出好几个变体。想要 release 包选 release想要 debug 包选 debug不用维护多个任务配置。4.2 版本号自动生成不要再手改 versionName我见过太多团队发布前手动去改build.gradle里的versionCode和versionName。这就违背了自动化的初衷——自动化之后版本号应该能稳定地自动生成。我们项目的规则是versionCode使用日期 当天构建序号例如 2024112601表示 2024年11月26日当天的第 1 次构建。versionName则使用 4 位版本号 构建序号比如 2.3.0.126。这个规则能保证测试人员在反馈 bug 时快速定位到具体构建时间。在 Jenkins Pipeline 里生成版本号并传给 Gradle 的写法stage(生成版本号) { steps { script { def datePart new Date().format(yyyyMMdd) def buildSeq env.BUILD_NUMBER env.VERSION_CODE ${datePart}${buildSeq} env.VERSION_NAME ${params.VERSION_NAME}.${buildSeq} } } }然后在 Gradle 命令里覆盖项目里设置的版本号./gradlew assembleRelease \ -PversionCode${VERSION_CODE} \ -PversionName${VERSION_NAME}项目里的build.gradle需要相应读取这些参数写成这样android { defaultConfig { versionCode project.hasProperty(versionCode) ? versionCode.toInteger() : 1 versionName project.hasProperty(versionName) ? versionName : 1.0.0 } }注意这里versionCode和versionName直接使用项目属性名称时可能和 Gradle 已有属性冲突建议统一用project.ext或者起一个更明确的名字比如projVersionCode。我在初期就因为这名字冲突问题排查了很久写清楚可以少走弯路。4.3 多渠道打包Gradle 配置一次Jenkins 里传不同包名项目有应用市场渠道区分通常做法是在build.gradle里配置flavorDimensions和productFlavors。比如flavorDimensions channel productFlavors { official { dimension channel } google { dimension channel } huawei { dimension channel } }配置后构建命令就变成./gradlew assembleOfficialRelease、./gradlew assembleGoogleRelease等。如果想一次把所有渠道都打出来直接跑./gradlew assembleRelease会触发所有 flavor 的 release 构建出来的 APK 会放在不同目录下。对于发布来说建议分开打因为每个渠道可能需要单独配置签名或加密逻辑。Jenkins 任务里加上一个CHANNEL参数choice(name: CHANNEL, choices: [all, official, google, huawei], description: 选择渠道)对应构建命令if [ ${CHANNEL} all ]; then ./gradlew assembleRelease else ./gradlew assemble${CHANNEL}Release fi这里注意 Grep 大小写assemble${CHANNEL}Release的 CHANNEL 首字母要大写否则 Gradle 找不到任务。对应在参数取值时要把字符串转换大小写这种小事最容易踩。4.4 签名配置让 Jenkins 打得包直接可用签名信息不应该硬编码在build.gradle里。我们项目把签名信息放在~/.gradle/gradle.properties中RELEASE_KEYSTORE_PATH/Users/你的用户名/secure/keystore/release.jks RELEASE_KEY_ALIASmyReleaseKey RELEASE_STORE_PASSWORD*** RELEASE_KEY_PASSWORD***build.gradle里读取signingConfigs { release { storeFile file(RELEASE_KEYSTORE_PATH) storePassword RELEASE_STORE_PASSWORD keyAlias RELEASE_KEY_ALIAS keyPassword RELEASE_KEY_PASSWORD } }当 debug 构建时不需要这些配置也不会报错因为只有 release 构建才引用这个 signingConfig。这个方案的好处是构建机上的开发者也不需要记得密码密码保存在一个权限受限的文件里比散落在邮件和聊天工具中安全得多。4.5 产物归档和多渠道分目录构建完成后APK 会生成在app/build/outputs/apk/下。但如果同时打多渠道包我发现最好把产物统一拷贝到一个固定输出目录并用日期和渠道命名子目录。在 Pipeline 里加一步stage(整理产物) { steps { sh OUTPUT_DIR${WORKSPACE}/output/${BUILD_TYPE}/${VERSION_NAME} mkdir -p ${OUTPUT_DIR} cp app/build/outputs/apk/echo ${CHANNEL} | tr A-Z a-z/release/*.apk ${OUTPUT_DIR}/ } }这一步看起来没有技术含量但实际价值非常高。测试人员拿到路径/output/release/2.3.0.126/清清楚楚知道这是哪个版本、什么时候构建的。如果是直接在build/outputs里找不同 flavor 的路径混在一起很容易拿错。4.6 构建失败邮件通知别让消息在 Jenkins 控制台里躺着自动化构建最大的敌人是构建失败了却没人知道。开发者以为 Jenkins 会“自动”跑测试人员以为新包已经生成结果一整天白等。配置邮件通知是最低限度的要求。Jenkins 有内置邮件通知功能设置 SMTP 服务器后在 Pipeline 的post部分加上post { failure { mail to: android-teamexample.com, subject: 构建失败: ${env.JOB_NAME} - ${env.BUILD_NUMBER}, body: 请查看 Jenkins 控制台日志: ${env.BUILD_URL} } success { mail to: android-teamexample.com, subject: 构建成功: ${env.JOB_NAME} - ${env.BUILD_NUMBER}, body: 产物下载地址: ${env.BUILD_URL}artifact/output/ } }更推荐的做法是在构建成功的同时把 APK 上传到团队使用的分发平台并在消息里带上下载链接。这一步需要调用分发平台的 APIJenkins 一般通过httpRequest插件或curl实现具体 URL 根据平台不同配置也不同。5. 常见问题与排查技巧实录再顺滑的方案实际跑起来也会遇到各种坑。下面这些是我在 Mac 环境 Jenkins Android 这个组合下真正踩过的值得对照排查。5.1 JAVA_HOME 找不到构建提示 “Unable to locate a Java Runtime”这个我遇到不下三次。单独在终端里执行java -version完全正常但 Jenkins 构建却找不到 JDK。原因是 Jenkins 作为服务运行时它的环境变量跟终端不一样。解决办法在 Jenkins 的“系统属性”中明确设置JAVA_HOME或者如果你用的是brew services方式启动可以在/Library/LaunchDaemons/homebrew.mxcl.jenkins-lts.plist文件里增加环境变量配置。还有一种情况项目里用了某个插件强制要求更高的 Java 版本。如果 Jenkins 服务用的是 JDK 11插件要求 JDK 17构建到一半就会崩。这时候要么升级 Jenkins 服务的 JDK要么在全局工具里给该项目单独指定 JDK 17 路径。5.2 Gradle 下载依赖慢或失败Android 构建依赖非常多第一次构建要下载上百 MB 的依赖库。如果构建机网络状况一般建议提前配置国内 Maven 镜像。在项目的build.gradle里增加阿里云镜像配置或使用团队内部的 Maven 仓库repositories { maven { url https://maven.aliyun.com/repository/public } maven { url https://maven.aliyun.com/repository/google } maven { url https://maven.aliyun.com/repository/gradle-plugin } google() mavenCentral() }上面是常见做法具体镜像地址要以实际能访问到的为准。构建机网络环境不同镜像选型也不同。另外可以把 Gradle 的本地缓存目录~/.gradle在构建机上保留好不要频繁清理这样二次构建会快很多。5.3 Mac 上执行 Gradle 时报 “Cannot run program git” 或 “Permission denied”这个一般是因为构建机的用户对某个目录没有执行权限。我在 Mac mini 上部署时遇到过 Jenkins 以jenkins用户运行时无法访问开发者目录下文件的情况。最简单的解决办法确保构建目录和 SDK 目录对 Jenkins 用户有读权限而不要试图把 Jenkins 服务改成 root 运行那会引入新的安全问题。还可以在 Pipeline 里加上一段诊断信息stage(诊断环境) { steps { sh whoami echo $JAVA_HOME echo $ANDROID_HOME git --version } }先跑这个 stage确认环境变量和用户身份没问题再去排查构建逻辑。5.4 构建超时或无响应Jenkins 默认任务不会自动超时如果一个构建卡死了整个执行队列都会被堵住。我遇到过一次 Gradle 进程在后台挂着不退出导致后续好几个任务排队半天。解决办法是在 Pipeline 里显式设置超时options { timeout(time: 30, unit: MINUTES) }30 分钟对于 Android 构建来说是合理预期。如果超过 30 分钟没跑完大概率不是正常构建杀掉重来是更明智的选择。5.5 签名文件找不到或密码错误这类报错通常不会发生在开发本地但到了 Jenkins 上就容易出现。最常见的原因是把 keystore 文件放进了项目仓库的app/目录下但.gitignore把它忽略了导致 Jenkins 拉取代码时根本没拉到这个文件。正确的做法是把 keystore 文件放到构建机的一个固定目录然后在 Jenkins 任务里用参数传入或者写在gradle.properties。总之不要依赖项目仓库保存签名文件。5.6 多模块项目的 task 名冲突如果项目是多模块架构比如:app、:library:network、:library:common构建命令要写全任务路径不能只写assembleRelease可能遇到任务名重复或者找不到任务的情况。建议先跑一次./gradlew tasks --all | grep -i assemble确认当前任务名是什么再写进 Pipeline。磨刀不误砍柴工这个命令很快但能省掉半小时排查。6. 用完这些天的一个整体感受这套东西从搭建到稳定运行前前后后用了不到一周但把以前手动打包的各种痛点基本都解决了。我个人印象最深的是**“版本管理”这个隐性收益**以前每个包叫什么、什么时候打的、对应哪次提交都要靠人肉记录现在 Jenkins 的 BUILD_NUMBER 天然就是一个递增序号配合日期生成 versionCode任何一次构建都能直接回查代码提交点。这对后期线上问题追溯来说价值巨大远比“省了几分钟打包时间”重要。还有一个小技巧如果你也不喜欢频繁打开 Jenkins 网页看构建状态可以把构建结果通知挂到团队聊天工具里直接推送到相关群组。构建成功或失败群组里所有人都能实时看到测试人员不用反复问“包打好了吗”。一开始可以只配置失败通知避免刷屏跑顺畅了再把成功通知也打开。如果你现在还在手动打包强烈建议尽快把流程自动化。哪怕只是先跑通一个最简单的 release 构建任务之后的收益也是指数级的。你在本地花半小时做的事情Jenkins 每天替你重复三十次不用白不用。