!summary\] **AI总结** 本文介绍了如何利用 **Jenkins** 构建一条完整的 Android 应用打包流水线,实现从本地触发到自动编译、上传蒲公英并通知团队的全流程自动化。核心步骤包括:安装 Jenkins 与所需插件,配置仓库凭据和环境变量,编写流水线(拉取代码、编译项目、上传安装包、通过钉钉机器人推送通知),并支持手动触发脚本和 Webhooks 自动发包。此外还展示了如何记录执行信息、检测包体积和集成单元测试等高阶功能。通过这些实践,可以减少重复工作、提升打包质量并降低人为失误。
前言
在传统的开发流程中,开发人员常常在本地完成代码修复后自行打包并上传安装包,但这个过程中可能忘记先 push 代码。这种做法不仅容易遗漏代码更新,还可能导致已修复的 bug 在测试或产品环节"复活",迫使团队重复确认版本,增加了不必要的沟通和时间浪费。
为了避免这些问题,我决定通过 Jenkins 搭建一条标准化的 CI/CD 流水线,将打包、上传和通知等环节全部自动化,消除人为操作的风险。通过自动化,我们能够确保每次构建都基于最新的代码提交,确保版本一致性,并及时通知团队成员,避免了版本错乱和重复劳动。此外,CI/CD 流程的引入还能提升开发效率、加快产品交付周期,并使得团队能够更早地发现和解决问题,从而显著提高软件质量和团队协作效率。
通过本教程,你将学习如何搭建这一流水线,进一步提升团队的工作效率,减少人为错误,让开发、测试和产品团队的合作更加流畅高效。
整体流程与效果展示
0 整体流程
开发者通过 bat 脚本或 Webhooks 触发 Jenkins 任务 → Jenkins 拉取代码 → 编译生成 apk → Python 脚本上传蒲公英 → 钉钉机器人通知团队。
1 手动触发bat脚本发送打包指令
参数说明:
- 构建用户:发包人姓名。在后端维护可信人员列表,如果姓名不在列表内则不允许打包。在钉钉机器人发送的信息中也会在群内艾特此用户。
- 更新说明:表达更新内容。告知团队本次包的主要修改内容,该文本同时作为蒲公英的更新说明。
- 打包分支:支持git项目中的任意分支打包。此参数避免了修改分支后要修改Jenkinsg任务参数的麻烦。
- 打包环境:debug/release,区分打包类型
2 Jenkins实际执行效果

3 发包成功的钉钉机器人信息
4 上传完成的蒲公英信息

5 运行效果总结
几个月的运行统计表明,115次执行全部成功,执行时长平均4分6秒,编译时长4分50秒,上传时长19秒
需要实现的目标
支持不同打包方式
自动打包
- 目标:每次push代码后监测版本号修改情况,当版本号修改时自动打包。
- 过程:在源码管理中使用
Webhooks
,每次push后自动触发Jenkins
自动打包任务;在自动打包任务中判断当前版本号和上次版本号是否相同,如果不同则进行打包。 - 细节:发包完成后需要通知团队其他成员,所以在项目中我们需要保存自动发包时的发包说明和其它信息
手动打包
本地bat
脚本执行,只需要输入构建用户、更新说明、需要艾特的人员等参数后即可下发构建任务。把大部分参数放到脚本中,通过curl命令传到任务中,任务再根据对应的值进行打包。
安全措施
由于手动打包的方式是在bat脚本中发送curl命令,意味着任何有该链接的用户都可以直接通过访问接口的方式发起打包任务,而在bat脚本中放置该链接相当于对用户完全开放。为了限制让可信用户才能打包,刚开始是在bat脚本中使用base64维护一个列表,不在列表内的用户提示无权限。这种方式实际还是没有安全性,于是委托后端新开了一个接口,后端进行校验构建用户是否在可信列表 内,不在的话不允许打包。这种方式保证外部人员即便拿到bat脚本也无法直接触发任务,已经足以满足此项目的安全性。实际效果运行半年多没有发生过安全性问题。
支持对开发以外团队开放
项目参与的测试和产品团队,通过手动打包脚本实现验证包和发包上架流程。but 因为我们公司规模小,用不上这点。
解构目标任务(★代表难度)
1 安装Jenkins ★
- 下载并安装Jenkins,参考教程
- 配置Jenkins
- 额外安装几个插件,分别是Webhook 触发器、解析json数据、HTTP 请求、Generic Webhook Trigger、git
- 启动Jenkins时加入参数
-Dhudson.model.ParametersAction.keepUndefinedParameters=true -Dfile.encoding=UTF8
后重启,以支持自定义参数并避免字符集问题
- 新建一个
Pipeline
流水线任务
2 创建打包流水线
教程采用 Declarative Pipeline 编写流水线脚本。以下每个 stage 负责一个功能模块,可根据需要增加或删除 stage。在构建任务中时用得到的基础说明
pipeline
pipeline { // 脚本入口
agent any // 在任意可用的 Agent/节点上运行,没有指定标签
triggers { GenericTrigger(...) } // Generic Webhook Trigger 插件中的参数
environment { ... } // 声明环境变量,对后续 `steps` 和子进程(如 `sh`)可见
stages { // 任务集合,可以将不同的步骤划分成不同的任务,方便把控每个任务的工作
stage('任务名称') { // 单个任务
when { // 条件判断语句,正常可省略
expression { true/false } // 只有当表达式为 true 时才执行该 stage
}
steps { // 执行步骤
script { // 声明式里使用脚本化 Groovy
// 工作代码
}
}
}
}
post {
success { // 当stages全部成功时执行
echo 'Pipeline executed successfully!'
}
failure { // 当stages未能全部成功时执行
echo 'Pipeline failed!'
}
// 还有 always\changed\unstable\aborted 状态
}
}
2.1 拉取git项目(Checkout) ★
此 stage 是将远程仓库的包拉取到Jenkins项目中,这正是CI流程中的关键思想:持续集成 。将各个工程师编写的代码持续集成进来,由一个统一的控制中心来处理代码交互给用户之前的任何问题。 这一任务完成的前提是要求所有工程师能及时将已修改代码push到远程仓库。
任务代码
pipeline
pipeline {
environment {
// Git 仓库地址
GIT_REPO = 'xxx.git'
// Git 账号
GIT_USER = ''
// Git 密码
GIT_PASSWORD = ''
}
stages {
stage('Checkout') {
steps {
script {
try {
// 拉取 Git 仓库代码
git url: "${GIT_REPO}",
credentialsId: "${GIT_USER}:${GIT_PASSWORD}"
// ,branch: "${env.BRANCH}"
echo "成功从 ${GIT_REPO} 的 ${env.BRANCH} 分支拉取代码"
} catch (Exception e) {
echo "拉取代码失败: ${e.message}"
error("拉取代码失败,请检查仓库地址、凭证和分支名")
}
}
}
}
}
}
以上的方式只是方便快速使用,实际使用时用全局配置的方式添加凭证,避免泄露。路径: Dashboard
- 系统管理
- 凭据
- 系统
- 全局凭据 (unrestricted)
- New credentials
2.2 编译(Build) ★★★★★
此 stage 是在拉取git代码后,在Ubuntu环境中编译项目输出apk包。进行此任务前必须先确保项目在Linux版的Android Studio中能打包成功。 以下是打包过程中遇到的一部分问题
- Ubuntu服务器版问题:刚开始运维给的是无桌面的Ubuntu,由于Android打包需要配置环境(gradle/ndk/依赖包等),但是很多资源包压根没有在命令行编译的环境进行优化,导致千奇百怪的各种报错。尝试了很多天后得出结论:几乎不可能在命令行环境中下载完整的Android编译所需的依赖文件。通过切换成Ubuntu桌面版解决
- 项目模块大小写问题:在Windows中
include(":module")
是不区分大小的,也就是你引入的模块目录大小写混着写是能编译过的。但是在Linux中不行,对于大小写是完全敏感的,必须保证引入模块和模块目录名称大小写一致 - 编译问题:各种从所未见的编译问题,比如aidl文件不匹配,经过一顿摸索发现是 当前的 build-tools 和 aidl 工具不支持 ARM 64 位架构
- 环境问题:哪怕Android Studio能成功打包了,但是在任务执行时还是可能出错。例如sdk路径问题,因为在Android Studio打包跟Jenkins任务中打包的环境不一致,需要强制在
local.properties
文件中写入sdk.dir=/root/Android/Sdk
任务代码
pipeline
stage('Build') {
steps {
script {
try {
def settingsGradleFile = "${WORKSPACE}/local.properties"
// 如果环境配置不存在必须先创建这个文件并写入
if (!fileExists(settingsGradleFile)) {
writeFile file: settingsGradleFile, text: 'sdk.dir="/root/Android/Sdk"'
}
sh "sudo chmod +x gradlew"
// 输出 Debug 或 Release
def capitalizedMatrix = env.MATRIX[0].toUpperCase() + env.MATRIX.substring(1)
// 最后的参数代表构建的类型包
sh "sudo ./gradlew clean assembleForeignbeta${capitalizedMatrix}"
echo "Gradle 构建任务执行成功 ${capitalizedMatrix}"
} catch (Exception e) {
FAILURE_REASON = "Gradle 构建失败: ${e.message}"
error(FAILURE_REASON)
}
}
}
}
2.3 蒲公英发包(Upload APK to Pgyer) ★★★
此 stage 用于将编译好的apk文件上传到发包平台,在测试阶段可以先屏蔽上面的编译任务。我刚开始使用的时候还是1.0版本,下载jenkins里的蒲公英插件后配置api信息,直接通过curl带文件上传就行。 后面蒲公英升级了2.0版本,上传速率更快了,但是不能在pipeline中直接使用curl上传,所以写了一个Python脚本upload_to_pgyer.py
用于上传文件。
任务代码
pipeline
stage('Upload APK to Pgyer') {
steps {
script {
try {
env.START_UPLOAD_TIME = new Date().format('yyyy-MM-dd HH:mm:ss')
env.START_UPLOAD_TIMESTAMP = System.currentTimeMillis()
def apkDir = "app/build/outputs/apk/foreignbeta/${env.MATRIX}"
def apkFiles = sh(script: """
firstApk=\$(find ${apkDir} -name "*.apk" | head -n 1)
echo \$firstApk | xargs -n 1 basename
""", returnStdout: true).trim()
if (apkFiles.isEmpty()) {
error("未找到 APK 文件。")
}
def apkPath = "${apkDir}/${apkFiles}"
if (!fileExists(apkPath)) {
error("文件 ${apkPath} 不存在。")
}
env.INSTALL_TYPE = (env.INSTALL_TYPE && env.INSTALL_TYPE != 'null') ? env.INSTALL_TYPE : '1'
echo "📤 准备上传 APK 到蒲公英..."
def output = sh (
script: """
python3 -u upload_to_pgyer.py \
--file ${apkPath} \
--install_type ${env.INSTALL_TYPE} \
--password '${env.PASSWORD}' \
--update_description '${env.UPDATE}'
""",
returnStdout: true
).trim()
echo "py: ${output}"
def result = readJSON file: 'upload_result.json'
if (result.code != 0) {
error "上传失败: ${result.message}"
}
def data = result['data']
env.appVersion = data?.buildVersion
env.appShortcutUrl = data?.buildShortcutUrl
env.appUpdated = data?.buildUpdated
env.appQRCodeURL = data?.buildQRCodeURL
} catch (Exception e) {
FAILURE_REASON = "上传APK失败: ${e.message}"
throw e
}
}
}
}
上传脚本upload_to_pgyer.py
主要流程:读取参数 → 调用 getCOSToken
获取上传地址 → 使用 COS Token 上传 APK → 调用 uploadComplete
完成上传 → 轮询发布结果并生成 upload_result.json
调用时仅需修改文件内的api,并按照要求传入文件路径即可
python
import os
import time
import json
import requests
import argparse
# 🔐 固定 Key 配置(可配置在此处)
PGY_API_KEY = ""
parser = argparse.ArgumentParser()
parser.add_argument('--file', required=True, help='APK 文件路径')
parser.add_argument('--install_type', default='1', help='安装类型')
parser.add_argument('--password', default='', help='安装密码')
parser.add_argument('--update_description', default='更新~', help='更新说明')
args = parser.parse_args()
APK_PATH = args.file
installType = args.install_type
password = args.password
updateDescription = args.update_description
# 忽略证书警告
requests.packages.urllib3.disable_warnings()
def get_cos_token(install_type, password, update_description):
url = "https://api.pgyer.com/apiv2/app/getCOSToken"
data = {
"_api_key": PGY_API_KEY,
"buildType": "apk",
"buildUpdateDescription": update_description,
"buildInstallType": install_type,
"buildPassword": password,
}
res = requests.post(url, data=data)
res.raise_for_status()
result = res.json()
return result["data"]
def upload_to_cos(cos_data):
files = {"file": open(APK_PATH, "rb"),}
payload = cos_data["params"]
payload["key"] = cos_data["key"]
payload["x-cos-meta-file-name"] = os.path.basename(APK_PATH)
response = requests.post(cos_data["endpoint"], data=payload, files=files, verify=False)
response.raise_for_status()
return cos_data["key"]
def complete_upload_form(build_key, install_type, password, update_description):
url = "https://api.pgyer.com/apiv2/app/uploadComplete"
payload = {
"_api_key": PGY_API_KEY,
"buildKey": build_key,
"buildUpdateDescription": update_description,
"buildInstallType": install_type,
"buildPassword": password,
}
print(f"updateDescription: {updateDescription} === {update_description}")
# ✅ 打印实际上传参数
print("📝 正在提交以下安装信息:")
for k, v in payload.items():
print(f" {k}: {v}")
response = requests.post(url, data=payload, verify=False)
response.raise_for_status()
return response.json()
def wait_for_publish(build_key, max_wait_seconds=300, interval=5):
"""
等待应用发布成功,最大等待时间为 max_wait_seconds 秒,每 interval 秒轮询一次
"""
url = "https://api.pgyer.com/apiv2/app/buildInfo"
params = {
"_api_key": PGY_API_KEY,
"buildKey": build_key
}
max_retries = max_wait_seconds // interval
for i in range(max_retries):
response = requests.get(url, params=params, verify=False)
data = response.json()
if data.get("code") == 0:
print("✅ 发布成功!")
return data
elif data.get("code") == 1216:
print("❌ 发布失败:", data.get("message"))
return data
elif data.get("code") == 1247:
print(f"⏳ 第 {i + 1}/{max_retries} 次轮询:App 正在发布中,请稍候...")
time.sleep(interval)
else:
print(f"⚠️ 其他返回:{data}")
time.sleep(interval)
# 超时
return {
"code": -1,
"message": "发布超时(5分钟未完成)"
}
def upload_app_to_pgyer(install_type, password, update_description, output_json="upload_result.json"):
print("🚀 获取上传地址...")
cos_token = get_cos_token(install_type, password, update_description)
print(f"{cos_token}")
print("📦 上传 APK 文件... ")
build_key = upload_to_cos(cos_token)
print(f"{build_key}")
print("🔄 等待发布结果...")
result = wait_for_publish(build_key)
print(f"✅ 完成,结果保存至:{output_json}")
with open(output_json, "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False, indent=4)
if __name__ == "__main__":
print("📦 环境变量读取结果:")
print(f"APK_PATH: {APK_PATH}")
print(f"installType: {installType}")
print(f"password: {password}")
print(f"updateDescription: {updateDescription}")
upload_app_to_pgyer(installType, password, updateDescription)
上传输出示例upload_result.json
,内容含义看此文档
json
{
"code": 0,
"message": "",
"data": {
"buildKey": "oooo",
"buildType": "2",
"buildIsFirst": "0",
"buildIsLastest": "1",
"buildFileKey": "aabb.apk",
"buildFileName": "xxx_v1.2.5_9-11-foreignbeta-release.apk",
"buildFileSize": "123",
"buildName": "xxx",
"buildVersion": "1.2.5",
"buildVersionNo": "134",
"buildBuildVersion": "393",
"buildIdentifier": "com.ooo",
"buildIcon": "ooo",
"buildDescription": "",
"buildUpdateDescription": "更新说明",
"buildScreenshots": "",
"buildShortcutUrl": "ooo",
"buildCreated": "2025-ooo 17:47:06",
"buildUpdated": "2025-ooo 17:47:06",
"buildQRCodeURL": "https://pgyer.com/app/qrcodeHistory/oooo"
}
}
2.4 钉钉机器人(Post DingTalk) ★★
此 stage 根据打包参数决定是否发送通知,并拼接链接信息。在发送通知时需要将钉钉群内的人员昵称映射为手机号,以便机器人正确 @ 用户。
任务代码
pipeline
stage('Post DingDing') {
when {
// 考虑到不是每次发包都需要钉钉提醒,可以加个参数跳过发钉钉的任务
expression { env.TEST?.toLowerCase() == 'true' }
}
steps {
script {
try {
def nameToPhoneMap = loadNameToPhoneMap()
def atList = []
if (env.NAME) {
atList.add(nameToPhoneMap.get(env.NAME) ?: env.NAME)
}
if (env.AT) {
def names = env.AT.replaceAll("[\\[\\] ]", "").split(",")
for (name in names) {
atList.add(nameToPhoneMap.get(name) ?: name)
}
}
// 这个id需要看上面的文档配置好
env.robot = ''
env.outtext = "${env.UPDATE}; 分支:${env.BRANCH}; 类型:${env.MATRIX}; 版本:${env.appVersion};https://www.pgyer.com/${env.appShortcutUrl}"
dingtalk(
robot: env.robot,
type: 'MARKDOWN',
title: env.UPDATE,
text: [
"### ${env.UPDATE}",
'---',
"- 构建分支: ${env.BRANCH}",
"- 构建类型: ${env.MATRIX}",
"- 构建版本: ${env.appVersion}",
'---',
"[https://www.pgyer.com/${env.appShortcutUrl}](https://www.pgyer.com/${env.appShortcutUrl})"
],
at: atList
)
} catch (Exception e) {
echo "钉钉推送失败: ${e.message}"
}
}
}
}
// 从外部文件读取姓名与电话映射
def loadNameToPhoneMap() {
def mapFile = "${WORKSPACE}/name_to_phone.json"
if (!fileExists(mapFile)) {
error("缺少映射文件: ${mapFile}")
}
return readJSON(file: mapFile)
}
艾特列表name_to_phone.json
,注意:手机号是钉钉的注册手机号,这样机器人才能识别到所艾特用户
json
{
"姓名":"手机号",
"测试1":"2233"
}
2.5 接收curl数据(Print Parameters) ★★★★
此 stage 将curl的传过来的参数在pipeline脚本中打印出来,这样才能进行数据互通
- 进入配置页面:
项目
-configure
-Triggers
- 添加Token:用于直接通过网址直接触发任务,例如:yuu
- 添加变量:用于接收curl传过来的参数 示例:
Generic Webhook Trigger
-Name of variable:变量名(NAME); Expression:代码中的读取方式($.name); JSONPath; Default value:默认参数可不填
- 选择
Pipeline script
,编写脚本:
任务代码
pipeline
pipeline {
agent any
triggers {
GenericTrigger(
token: 'yuu',
genericVariables: [
[key: 'NAME', value: '$.name']
],
printContributedVariables: true,
printPostContent: true
)
}
stages {
stage('Print Parameters') {
steps {
script {
echo "name: ${env.name}"
}
}
}
}
}
以上步骤完成后,在Windows 的cmd中执行这个命令:curl 127.0.0.1/generic-webhook-trigger/invoke?token=yuu -d name:姓名
3 bat脚本 ★★
通过上面的代码实现了在本地拉取项目代码并打包上传的操作,但是存在一个问题,触发脚本需要打开Jenkins后台,如果将Jenkins后台开放给全部人,将会导致严重的权限问题 。虽然Jenkins自带了用户权限管理,但是依然无法限制有权限用户对Jenkins任务的更改,为了尽可能的避免这个问题,决定使用Generic Webhook Trigger
触发脚本,而不是通过Jenkins后台。当然,这个问题也可以通过使用Pipeline script from SCM
来规避,这是在脚本能稳定运行之后需要做的。 这种前后端分离的做法也使得执行任务更为容易,只需要保证更健壮的执行稳定性
bat脚本代码设计思路:因为这是内部开放的脚本,需要区分构建用户,以及带上一定的保密措施。在构建的时候带上需要打包的类型(debug/release),打包分支等,以及告诉测试更新内容 ,和需要艾特的人员。这些全部参数都在bat脚本中填写,这样就不需要再额外修改pipeline脚本了。为了更佳的适配性,这个脚本本身需要执行的东西也是越少越好,对外仅执行一个curl
命令,发送post信息。
任务代码
bat
@echo off
chcp 65001 >nul
setlocal EnableDelayedExpansion
:: 设计参数: name:姓名; update:更新说明; at:需要艾特的人员列表; branch:分支...
set "NAME="
if "%NAME%"=="" (
:input_name
set /p "NAME=请输入构建人姓名: "
if "!NAME!"=="" (
echo 姓名不能为空,请重新输入。
goto input_name
)
)
:: 打包环境 debug / release
set "MATRIX=debug"
:: 分支(master main ,不允许为空)
set "BRANCH=main"
:: true 在钉钉群发布, false 不发布
set "TEST=true"
:group_found
:: 初始化其他变量
set "PASSWORD="
set "INSTALL_TYPE=1"
set "update_info="
set "at_people="
set "aab_type="
:: 等待时间
set "wait_time=30"
:: 查询 Git 提交数量
set "query_git_commit=10"
set "shortcut=trtc"
:update_input
set /p "update_info=请输入更新说明: "
if "!update_info!"=="" (
echo 更新说明不能为空,请重新输入
goto update_input
)
set "at_people="
:: echo.
echo 请输入艾特人员(可为空):
echo 姓名,姓名
set /p "at_people="
:: 显示信息并确认
cls
echo.
echo ---------- 请确认以下信息 ----------
echo 构建用户: %NAME%
if not "!update_info!"=="" echo 更新说明: !update_info!
if not "!at_people!"=="" echo 艾特人员: !at_people!
echo 打包分支: %BRANCH%
echo 打包环境: %MATRIX%
if not "!aab_type!"=="" echo 打包类型 aab: !aab_type!
if not "!PASSWORD!"=="" echo 蒲公英密码: !PASSWORD!
echo -------------------------------------
echo.
echo 确认信息无误后,按任意键发送请求
pause > nul
echo 请求发送中...
echo.
:: 创建临时JSON文件
set temp_json="{\"branch\":\"%BRANCH%\", \"name\":\"%NAME%\",\"update\":\"%update_info%\",\"at\":\"%at_people%\",\"test\":\"%TEST%\",\"matrix\":\"%MATRIX%\",\"password\":\"%PASSWORD%\",\"aab\":\"%aab_type%\", \"query\":%query_git_commit%,\"shortcut\":\"%shortcut%\"}"
:: Jenkins 相关配置
set "JENKINS_URL=127.0.0.1/generic-webhook-trigger/invoke?token=yuu"
:: echo 发送数据 %temp_json%
:: 执行curl请求并获取状态码
set temp_response=%TEMP%\temp_response.txt
curl -X POST -H "Content-Type: application/json" -o "%temp_response%" -w "%%{http_code}" "%JENKINS_URL%" -d %temp_json% > %TEMP%\http_code.txt
set /p http_code=%TEMP%\http_code.txt
if not "%http_code%"=="200" (
type "%temp_response%"
echo.
echo 请求失败,状态码: %http_code%,请截图联系运维
set "wait_time=60"
:: del %TEMP%\http_code.txt
goto :exit_script
) else (
:: 解析返回的JSON数据
set "jobs_null=false"
for /f "usebackq delims=" %%a in (`powershell -Command "(Get-Content '%temp_response%' -Raw) -match '\"jobs\":null'"`) do (
if "%%a"=="True" (
set "jobs_null=true"
)
)
if "%jobs_null%"=="true" (
type "%temp_response%"
echo.
echo 任务为空,请截图联系运维
:: del %TEMP%\http_code.txt
goto :exit_script
)
echo.
echo 发送任务构建命令成功,稍后在群内查看
goto :exit_script
)
:exit_script
echo.
echo 按任意键退出...
timeout /t %wait_time%
exit /b
4 自动发包任务 ★★
此 stage 是用来实现自动打包的,因为步骤跟手动打包差不多,所以就不另起一个任务来执行。
关键的点在于,git仓库调用webhook
的时候加上一个参数代表自动打包;以及需要在源码仓库中保存打包过程所需的数据,如:更新说明、打包类型、艾特人员等等。还有一个难点在于区分分支,因为push任意代码后都会调用这里,但是push到哪个分支无法通过webhook
传进来,所以这里需要去遍历全部分支,找出最近提交代码的分支,以此分支来进行打包流程。
由于这里是url调用不带参,那对于非自动发包任务的curl请求 中应该添加参数来表示,例如:auto:false

具体思路:
- 在[[#2.1 拉取git项目 ★|拉取git项目]]任务执行之前,创建一个任务用来校验是否为自动发包,使用
when
语句即可 - 由于Webhooks是不带参数的,所以无法带入分支参数,这时候就需要我们定义一个默认分支,但是如果以默认分支为打包分支,那么后续每次需要切换分支时都需要在任务中重新修改,这很明显是不够优雅的
- 拉取默认分支,遍历所有分支,找出最新提交的分支,拉取此分支内容。
git log --graph --all --oneline
- 读取仓库中的
jenkins.json
文件,获取到后面需要的参数,例如:更新说明、打包类型、艾特人员等 - 继续[[#2.2 编译 ★★★★★|编译]]后续工作
Webhooks可以用git Hooks替代
5 *高阶任务(可选)
打印git log信息(Query Git Commits) ★
此 stage 用户查看此次拉取的git仓库中最近的几次git log,如果代码出问题的话方便快速定位,也可以将最近一次log在钉钉机器人中发送出去。
yuu 2025-03-11T17:39:53+08:00 修复编译错误
任务代码
pipeline
stage('Query Git Commits') {
steps {
script {
def command = "git log -n ${env.query} --pretty=\"%cn %cI %s\""
def recentCommits = sh(script: command, returnStdout: true).trim()
env.recentCommits = recentCommits
def firstGitLine = recentCommits.tokenize('\n')[0]
env.firstGitLine = firstGitLine
echo "对外输出的: ${firstGitLine}"
}
}
}
记录执行信息 ★★
此 stage 用来保存Jenkins任务的执行结果,方便分析原因和统计
思路是在always
方法中,记录其它任务的执行情况,比如:执行总时长、编译时长、上传时长、开始时间戳、git log、钉钉推送信息等等。总之就是在其它任务的执行中保存你想要的数据,然后保存下来。为了能对时间进行一个平均计算,我是引入了Excel表格式csv
,每次执行完成往后添加一条记录
任务代码
pipeline
pipeline{
post {
always {
script {
try {
writeCsvLog(true, "")
} catch (e) {
echo "CSV日志记录失败: ${e.message}"
}
}
}
}
}
// 统一 CSV 写入方法
def writeCsvLog(boolean success, String failureReason = "") {
def endTimestamp = System.currentTimeMillis()
def startTimestamp = Long.parseLong(env.START_BUILD_TIMESTAMP ?: "0")
def uploadTimestamp = Long.parseLong(env.START_UPLOAD_TIMESTAMP ?: startTimestamp.toString())
def totalDuration = formatDuration(endTimestamp - startTimestamp)
def buildDuration = formatDuration(uploadTimestamp - startTimestamp)
def uploadDuration = formatDuration(endTimestamp - uploadTimestamp)
def transmissionData = [
"branch" : env.BRANCH ?: "",
"name" : env.NAME ?: "",
"update" : env.UPDATE ?: "",
"at" : env.AT ?: "",
"test" : env.TEST ?: "",
"matrix" : env.MATRIX ?: "",
"password": env.PASSWORD ? "***" : "",
"query" : env.query ?: 5,
"shortcut": env.SHORTCUT ?: ""
]
def transmissionJson = groovy.json.JsonOutput.toJson(transmissionData)
def pushInfo = ""
if (success && env.TEST?.toLowerCase() == 'true' && env.outtext) {
pushInfo = env.outtext.toString().replaceAll('\\[|\\]', '').replaceAll(', ', ' ')
}
def csvRecord = [
totalDuration,
buildDuration,
uploadDuration,
env.START_BUILD_TIME ?: "",
success.toString(),
(env.firstGitLine ?: "").replaceAll(',', ';'),
pushInfo.replaceAll(',', ';'),
failureReason.replaceAll(',', ';'),
transmissionJson.replaceAll(',', ';')
].collect { "\"${it}\"" }.join(',')
def csvRecordoutput = sh (
script: """
python3 -u save.py \
--csv '${csvRecord}'
""",
returnStdout: true
).trim()
echo "csvRecord: ${csvRecord}"
echo "py output: ${csvRecordoutput}"
echo ' out ${env.CSV_FILE}'
}
// 时间格式化
def formatDuration(long milliseconds) {
long seconds = milliseconds / 1000
long minutes = seconds / 60
seconds = seconds % 60
return String.format("%02d:%02d", minutes, seconds)
}
在写入csv信息的save.py
脚本中,还可以进行邮件发送,这样方便在微信中收到执行信息

包体积管理(APK Size Check) ★★★
此 stage 用来记录apk的包问题,在[[#2.2 编译 ★★★★★|编译]]完成后执行,职责是包体积判断。每次执行完都会将包大小记录到apkTool.json
文件中,key:分支_类型_size,例如:main_debug_size
包体积超过1MB时直接使用APK Checker
进行包内容检查
包体积超过5MB时停止任务,不允许继续发包,并根据情况决定是否发邮件通知发包人和leader
任务代码
apkTool.json
保存上次的文件大小信息
json
{
"分支_debug_size":2333,
"分支_release_size":2233
}
pipeline
stage('APK Size Check') {
steps {
script {
// 1. 确定 apk 路径目录与 apk 文件
def apkDir = "app/build/outputs/apk/foreignbeta/${env.MATRIX}"
def apkFiles = sh(script: """
firstApk=\$(find ${apkDir} -name "*.apk" | head -n 1)
echo \$firstApk | xargs -n 1 basename
""", returnStdout: true).trim()
if (apkFiles.isEmpty()) {
error("未找到 APK 文件。")
}
def apkPath = "${apkDir}/${apkFiles}"
if (!fileExists(apkPath)) {
error("文件 ${apkPath} 不存在。")
}
// apkFile.length 是字节数
long currentSizeBytes = apkFile.length
echo "Found APK: ${apkPath}, size = ${currentSizeBytes} bytes"
// 2. 读取 apkTool.json 中上次记录的包大小
def jsonPath = 'apkTool.json'
def jsonExists = fileExists(jsonPath)
def jsonMap = [:]
if (jsonExists) {
jsonMap = readJSON file: jsonPath
} else {
echo "apkTool.json not found; will create a new one"
}
// 构造 key "main_debug_size"
def branchName = env.BRANCH ?: env.GIT_BRANCH ?: 'unknown_branch'
// 保证 key 与 env.MATRIX 一致
def key = "${branchName}_${env.MATRIX}_size"
echo "Using key: ${key}"
long previousSizeBytes = 0
if (jsonMap.containsKey(key)) {
previousSizeBytes = (jsonMap[key] as Long)
echo "Previous size for ${key}: ${previousSizeBytes} bytes"
} else {
echo "No previous size for ${key}; assuming 0"
}
// 3. 比较当前 vs 以前
long diffBytes = currentSizeBytes - previousSizeBytes
long oneMB = 1 * 1024 * 1024
long fiveMB = 5 * 1024 * 1024
if (diffBytes > fiveMB) {
error "APK size increased by more than 5 MB: +${(diffBytes / (1024*1024)).round(2)} MB"
} else if (diffBytes > oneMB) {
echo "APK size increased by more than 1 MB (+${(diffBytes / (1024*1024)).round(2)} MB), running ApkChecker"
// 4. 运行 ApkChecker
sh "java -jar ApkChecker.jar --apk ${apkPath} --config apkChecker.json"
} else {
echo "APK size increase within acceptable range (+${(diffBytes / (1024*1024)).round(2)} MB)"
}
// 5. 写回最新包大小到 apkTool.json
jsonMap[key] = currentSizeBytes
// 可以保留其他 key 不变
writeJSON file: jsonPath, json: jsonMap, pretty: 2
echo "Updated ${jsonPath} with ${key}: ${currentSizeBytes} bytes"
}
}
}
自动化测试 ★★★
根据项目实际需要,接入不同的测试任务,常见的有单元测试、Lint代码检查,结合上面的包体积管理输出一份自动化测试报告。
参考:
delme-bat.github.io/2018/06/26/...
tech.meituan.com/2015/12/24/...
6 总结
pipeline是非常容易理解的胶水语言,结合Python、JSON外部文件可以完成各种事情,这个过程中只需要聚焦在每个任务中,经过简单尝试就能在项目中完成想要的功能
在应用上架过程,Jenkins完全可以做到由产品团队直接调用任务任务完成,将打包/分发/上传流程统一,这需要严格的测试回归、分支管理、权限分配等流程
实践
Linux环境 项目:github.com/yuuouu/Deep...
1 创建流水线
配置GitHub项目,填入项目URL

2 配置流水线
- Pipeline script from SCM
- Repository URL:github.com/yuuouu/Deep...
- 指定分支(为空时代表any):*/master
- 脚本路径:jenkins/pipeline

3 构建任务
稍后便能看到执行成功

总结
🎉彻底掌握Jenkins自动发包全流程,具体想做什么由你定义,fork项目后修改jenkins/pipeline
中的stage
后语
我小时候第一次接触电脑,是在黑网吧里,玩一些单机游戏。印象很深的是玩《恐龙快打》,我记得有个选项叫做配置命令,应该是类似《侠盗猎车手》中输入
panzer
掉坦克一样的作弊码,我当时并不会用这个功能,只是在脑海里想着要用这个功能来实现无限生命无限武器这样的操作。这个想法在我脑海中持续了很长时间,连实现之后怎么玩都想了很多遍,但是由于我并没有稳定接触电脑的机会,加上不能够完全理解游戏内容,这样的想法一直没有实现。当我在现在的工作中需要实现类似操作的时候:通过编写脚本实现全自动发包流程,我发现我已经有能力做到这点。