从零搭建 Android APK 自动化构建平台:Docker + Jenkins 完整实践
前言
Android 项目随着规模扩大,往往面临以下痛点:
- 多 App、多环境(测试 / 预发 / 生产)、多签名,人工打包流程繁琐且容易出错
- 打包环境只在某一台机器上,机器故障或人员离职就得从头配置
- 测试包、调试包、正式包区分不清,容易发错给用户
本文从零开始,介绍如何用 Docker + Jenkins 搭建一套 Android APK 自动化构建平台。所有配置文件纳入 Git 管理,在任意 Linux 机器执行一条命令即可重建完整环境,拿到模板后只需修改少量参数即可适配自己的项目。
适用读者:有一定 Android 开发经验,了解基本的 Linux 命令,没有 Docker / Jenkins 使用经验也没关系,本文会逐步说明。
一、整体思路
1.1 为什么用 Docker
传统方案是在服务器上手动安装 JDK、Android SDK,然后在 Jenkins 里配置环境变量。这种方式有三个问题:
- 不可复现:装过什么包、改过哪些配置全靠记忆,出了问题很难排查
- 难以迁移:换机器就要重装一遍,费时费力
- 版本冲突:不同项目可能需要不同 compileSdk 版本,一台机器上容易互相干扰
Docker 的核心优势是把运行环境打包成镜像。我们把 JDK、Android SDK、Jenkins 全部打进一个镜像,任何机器只需要安装 Docker,执行一条命令就能得到完全一致的打包环境。
1.2 整体架构
Git 仓库(JenkinsBuild)
├── Dockerfile ← 定义镜像:Jenkins + Android SDK
├── docker-compose.yml ← 定义容器运行方式:端口、数据卷、环境变量
├── casc/
│ └── jenkins.yaml ← Jenkins 所有配置:账号、凭据、插件设置
├── Jenkinsfile.myapp ← 构建 Pipeline 脚本
├── init.groovy.d/
│ └── 10-myapp-job.groovy ← Jenkins 启动时自动创建/更新任务
├── patches/ ← 构建期通用补丁文件
├── patches-dev/ ← 调试专用补丁文件
└── scripts/
└── up.sh ← 一键启动脚本
运行时布局:
宿主机
├── Docker 容器
│ ├── Jenkins 本体(JDK 21 运行)
│ ├── /opt/java/jdk-17 ← Gradle 编译专用
│ ├── /opt/android-sdk ← Android SDK
│ └── /opt/android-patches/ ← 构建期注入的补丁文件
│
├── 数据卷 jenkins_home ← 持久化,重建容器不丢失
│ ├── .gradle/ ← Gradle 依赖缓存(多任务共享)
│ ├── workspace/ ← 各任务工作区(保留增量编译缓存)
│ └── jobs/ ← 构建历史和归档产物
│
└── ~/.ssh/id_rsa ← SSH 私钥(挂载进容器,不进镜像)
二、安装 Docker
2.1 Linux(Ubuntu / Debian)
# 1. 卸载旧版本(如果有)
sudo apt-get remove docker docker-engine docker.io containerd runc
# 2. 安装依赖
sudo apt-get update
sudo apt-get install -y \
ca-certificates curl gnupg lsb-release
# 3. 添加 Docker 官方 GPG 密钥
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \
sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
# 4. 添加 Docker 软件源
echo \
"deb [arch=$(dpkg --print-architecture) \
signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu \
$(lsb_release -cs) stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
# 5. 安装 Docker Engine
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
# 6. 验证安装
docker --version # 期望输出:Docker version 24.x.x
docker compose version # 期望输出:Docker Compose version v2.x.x
2.2 让当前用户有权限运行 Docker(无需 sudo)
sudo usermod -aG docker $USER
# 重新登录终端后生效
newgrp docker
# 验证
docker run hello-world
2.3 macOS
下载并安装 Docker Desktop for Mac,安装完成后启动 Docker Desktop,等待状态栏图标变为"运行中"即可。
2.4 Windows
下载并安装 Docker Desktop for Windows,需要启用 WSL 2 后端。安装完成后重启,启动 Docker Desktop。
本文后续命令以 Linux 为主,macOS/Windows 用户使用 Docker Desktop 内置终端或 WSL 执行。
三、准备 SSH 私钥
Jenkins 需要 SSH 私钥来拉取 GitHub/GitLab 上的代码仓库。
3.1 生成新密钥对(如果还没有)
ssh-keygen -t ed25519 -C "jenkins-ci" -f ~/.ssh/jenkins_ci
# 一路回车,不设 passphrase(Jenkins 自动化场景不方便交互输密码)
生成两个文件:
~/.ssh/jenkins_ci:私钥(只留在服务器上)~/.ssh/jenkins_ci.pub:公钥(添加到 GitHub/GitLab)
3.2 把公钥添加到代码托管平台
GitHub:Settings → SSH and GPG keys → New SSH key,粘贴 jenkins_ci.pub 的内容。
GitLab:User Settings → SSH Keys,粘贴公钥内容。
3.3 验证 SSH 连接
ssh -i ~/.ssh/jenkins_ci -T git@github.com
# 期望输出:Hi xxx! You've successfully authenticated...
四、项目目录结构
创建 JenkinsBuild 项目目录,后续所有文件都放在这里:
mkdir JenkinsBuild && cd JenkinsBuild
mkdir -p casc init.groovy.d scripts patches patches-dev userContent
完整目录结构如下,接下来逐一创建各文件:
JenkinsBuild/
├── Dockerfile
├── docker-compose.yml
├── .env.example ← 环境变量模板,复制成 .env 后填写
├── plugins.txt ← Jenkins 插件列表
├── casc/
│ └── jenkins.yaml ← Jenkins 配置即代码
├── Jenkinsfile.myapp ← Pipeline 脚本(按项目复制修改)
├── init.groovy.d/
│ └── 10-myapp-job.groovy ← 自动创建构建任务
├── patches/ ← 通用补丁(所有构建都注入)
├── patches-dev/ ← 调试补丁(仅特定参数时注入)
├── scripts/
│ └── up.sh ← 一键启动脚本
└── userContent/ ← Jenkins 静态资源(可选)
五、Dockerfile
这是整个方案的核心,把 Jenkins 和 Android SDK 打包成一个镜像。
# =============================================================================
# Dockerfile — Jenkins + Android SDK 一体镜像
#
# 修改说明(适配新项目时只需改这几处):
# ANDROID_PACKAGES:改成项目实际的 compileSdk 和 build-tools 版本
# JENKINS_IMAGE:如需锁定 Jenkins 版本,改成具体版本号
# =============================================================================
# ── Stage 1:下载 Android SDK(单独成层,改 Jenkins 配置不需要重下 SDK)──
FROM ubuntu:22.04 AS sdk
ARG ANDROID_CMDLINE_TOOLS_VERSION=11076708
ARG ANDROID_HOME=/opt/android-sdk
# 安装必要工具
RUN apt-get update && apt-get install -y --no-install-recommends \
wget unzip curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# 安装 Temurin JDK 17(Gradle 编译用,Jenkins 本体用 JDK 21)
RUN mkdir -p /opt/java && \
wget -q -O /tmp/jdk17.tar.gz \
"https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.9%2B9/OpenJDK17U-jdk_x64_linux_hotspot_17.0.9_9.tar.gz" && \
tar -xzf /tmp/jdk17.tar.gz -C /opt/java && \
mv /opt/java/jdk-17.0.9+9 /opt/java/jdk-17 && \
rm /tmp/jdk17.tar.gz
# 下载 Android cmdline-tools
RUN mkdir -p ${ANDROID_HOME}/cmdline-tools && \
wget -q -O /tmp/cmdline-tools.zip \
"https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_CMDLINE_TOOLS_VERSION}_latest.zip" && \
unzip -q /tmp/cmdline-tools.zip -d ${ANDROID_HOME}/cmdline-tools && \
mv ${ANDROID_HOME}/cmdline-tools/cmdline-tools \
${ANDROID_HOME}/cmdline-tools/latest && \
rm /tmp/cmdline-tools.zip
ENV PATH="${ANDROID_HOME}/cmdline-tools/latest/bin:${ANDROID_HOME}/platform-tools:${PATH}"
ENV ANDROID_HOME=${ANDROID_HOME}
# 安装 SDK 组件(按项目需求修改 ANDROID_PACKAGES)
ARG ANDROID_PACKAGES="\
platform-tools \
platforms;android-35 \
build-tools;35.0.0 \
platforms;android-34 \
build-tools;34.0.0"
RUN yes | sdkmanager --licenses > /dev/null 2>&1 && \
sdkmanager ${ANDROID_PACKAGES}
# ── Stage 2:最终镜像 ──
ARG JENKINS_IMAGE=jenkins/jenkins:2.479.3-lts-jdk21
FROM ${JENKINS_IMAGE}
USER root
# 复制 JDK 17 和 Android SDK
COPY --from=sdk /opt/java/jdk-17 /opt/java/jdk-17
COPY --from=sdk /opt/android-sdk /opt/android-sdk
# 设置环境变量(容器内全局生效)
ENV ANDROID_HOME=/opt/android-sdk
ENV JAVA17_HOME=/opt/java/jdk-17
ENV PATH="${ANDROID_HOME}/cmdline-tools/latest/bin:${ANDROID_HOME}/platform-tools:${PATH}"
# 预装 Jenkins 插件(避免启动时联网,加快首次启动速度)
COPY plugins.txt /usr/share/jenkins/ref/plugins.txt
RUN jenkins-plugin-cli --plugin-file /usr/share/jenkins/ref/plugins.txt
# 拷入 JCasC 配置(带 .override 后缀:容器启动时强制覆盖,保证配置最新)
COPY casc/jenkins.yaml /usr/share/jenkins/ref/casc/jenkins.yaml.override
# 拷入 Pipeline 脚本
COPY Jenkinsfile.* /usr/share/jenkins/ref/pipelines/
# 拷入 init 脚本(Jenkins 启动时自动执行,创建/更新构建任务)
COPY init.groovy.d/ /usr/share/jenkins/ref/init.groovy.d/
# 拷入构建期补丁
COPY patches/ /opt/android-patches/
COPY patches-dev/ /opt/android-patches-dev/
# 可选:Jenkins 界面静态资源(自定义 CSS/JS)
COPY userContent/ /usr/share/jenkins/ref/userContent/
USER jenkins
# 告知 Jenkins 去哪里找 JCasC 配置
ENV CASC_JENKINS_CONFIG=/var/jenkins_home/casc/jenkins.yaml
六、docker-compose.yml
# =============================================================================
# docker-compose.yml
#
# 修改说明:
# ports:如果 8080 被占用,改成其他端口,如 "8888:8080"
# JENKINS_ADMIN_ID / JENKINS_ADMIN_PASSWORD:在 .env 里设置,不要写在这里
# =============================================================================
version: "3.9"
services:
jenkins:
build:
context: .
dockerfile: Dockerfile
image: jenkins-android:local
container_name: jenkins-android
# 使用宿主机网络(最简单,无需配置端口映射内网访问)
# 如果是 rootless Docker 或需要精确控制,改用下面注释的 ports 方式
network_mode: host
# 如果不用 host 网络,取消注释下面两行,同时注释掉 network_mode: host
# ports:
# - "${JENKINS_HTTP_PORT:-8080}:8080"
# - "${JENKINS_AGENT_PORT:-50000}:50000"
volumes:
# 数据持久化(构建历史、Gradle 缓存、工作区)
- jenkins_home:/var/jenkins_home
# SSH 私钥(运行时注入,不进镜像)
- ${HOST_SSH_DIR:-~/.ssh}:/var/jenkins_home/.ssh
environment:
# Jenkins 管理员账号(从 .env 文件读取)
- JENKINS_ADMIN_ID=${JENKINS_ADMIN_ID:-admin}
- JENKINS_ADMIN_PASSWORD=${JENKINS_ADMIN_PASSWORD}
# JCasC 配置文件路径
- CASC_JENKINS_CONFIG=/var/jenkins_home/casc/jenkins.yaml
# Jenkins 端口(host 网络模式下通过启动参数控制)
- JENKINS_OPTS=--httpPort=${JENKINS_HTTP_PORT:-8080}
- JENKINS_SLAVE_AGENT_PORT=${JENKINS_AGENT_PORT:-50000}
# Gradle 调优
- GRADLE_OPTS=-Dorg.gradle.daemon=false -Dfile.encoding=UTF-8
# 健康检查:等 Jenkins 完全启动后再认为容器就绪
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:${JENKINS_HTTP_PORT:-8080}/login"]
interval: 10s
timeout: 5s
retries: 15
start_period: 60s
# Kotlin 编译需要大量文件描述符
ulimits:
nofile:
soft: 65536
hard: 65536
restart: unless-stopped
volumes:
jenkins_home:
name: jenkins_android_home
七、.env.example(环境变量模板)
# =============================================================================
# .env.example — 复制成 .env 后填写,.env 已加入 .gitignore,不会提交
# =============================================================================
# Jenkins 管理员账号(首次启动后可在 UI 里修改)
JENKINS_ADMIN_ID=admin
JENKINS_ADMIN_PASSWORD=请改成强密码
# Jenkins 端口(host 网络模式下生效)
JENKINS_HTTP_PORT=8080
JENKINS_AGENT_PORT=50000
# SSH 私钥所在目录(整个目录会挂载进容器)
# 确保目录内有 id_rsa 或 jenkins_ci 文件,且权限为 600
HOST_SSH_DIR=/root/.ssh
使用方法:
cp .env.example .env
# 用编辑器打开 .env,修改密码和路径
nano .env
八、plugins.txt(Jenkins 插件列表)
# =============================================================================
# plugins.txt — Jenkins 插件清单
#
# 格式:插件ID[:版本]
# 不锁定版本则安装最新版(推荐:避免已知安全漏洞)
# =============================================================================
# 核心:Pipeline 支持
workflow-aggregator
pipeline-stage-view
# Git 集成
git
github
# 参数:分支下拉列表(实时从远端拉取,不用手填分支名)
list-git-branches-parameter
# 参数:Active Choice(动态参数,支持 Groovy 脚本生成选项)
uno-choice
# 权限:基于角色的访问控制
role-strategy
# 凭据管理
credentials-binding
ssh-credentials
# 配置即代码(JCasC)
configuration-as-code
# 构建日志优化
ansicolor
timestamper
# 构建历史管理
build-discarder
# 可选:构建完成后飞书/钉钉/邮件通知
# feishu-notification
# dingding-notifications
# email-ext
九、casc/jenkins.yaml(Jenkins 配置即代码)
# =============================================================================
# casc/jenkins.yaml — Jenkins 所有配置集中在这里
#
# 修改说明:
# adminUser/adminPassword:从环境变量读取,不要硬编码
# privateKey:从挂载的文件读取,不要硬编码
# =============================================================================
jenkins:
numExecutors: 2 # 同时最多并行 2 个构建(按机器 CPU 核数调整)
mode: NORMAL
# 权限:管理员全权,匿名只读(内网环境可以放开,公网建议收紧)
authorizationStrategy:
roleBased:
roles:
global:
- name: "admin"
permissions:
- "Overall/Administer"
- name: "anonymous"
permissions:
- "Overall/Read"
- "Job/Read"
# 安全配置
securityRealm:
local:
allowsSignup: false
users:
- id: "${JENKINS_ADMIN_ID}"
password: "${JENKINS_ADMIN_PASSWORD}"
properties:
- timezone:
timeZoneName: "Asia/Shanghai"
# 全局工具链
jdks:
- name: "JDK17"
home: "/opt/java/jdk-17"
# 凭据(SSH 私钥从挂载文件读取)
credentials:
system:
domainCredentials:
- credentials:
- basicSSHUserPrivateKey:
scope: GLOBAL
id: "github-ssh-key"
description: "GitHub SSH Key"
username: "git"
privateKeySource:
directEntry:
# ${file:路径} 语法:从文件读内容,不硬编码私钥
privateKey: "${file:/var/jenkins_home/.ssh/id_rsa}"
# 全局环境变量
globalNodeProperties:
- envVars:
env:
- key: "ANDROID_HOME"
value: "/opt/android-sdk"
- key: "JAVA17_HOME"
value: "/opt/java/jdk-17"
# 构建历史保留策略
unclassified:
buildDiscarderProperty:
strategy:
logRotator:
numToKeepStr: "30"
artifactNumToKeepStr: "10"
# 时间戳格式
timestamper:
systemTimeFormat: "yyyy-MM-dd HH:mm:ss"
elapsedTimeFormat: "''HH:mm:ss.S''"
十、Jenkinsfile.myapp(Pipeline 脚本模板)
这是最核心的文件。适配新项目时,只需要修改顶部几个变量。
// =============================================================================
// Jenkinsfile.myapp — Android APK 构建 Pipeline 模板
//
// ★ 适配新项目时,只需修改下面"项目配置区"的内容 ★
// =============================================================================
// ─── 项目配置区(必改)────────────────────────────────────────────────────────
def GIT_REPO_URL = 'git@github.com:your-org/your-repo.git' // 代码仓库地址
def DEFAULT_BRANCH = 'develop' // 默认分支
def APP_MODULE = 'app' // Gradle 模块名
def APP_PACKAGE = 'com.example.myapp' // 应用包名
// ─────────────────────────────────────────────────────────────────────────────
pipeline {
agent { label 'android' }
options {
timestamps()
ansiColor('xterm')
timeout(time: 60, unit: 'MINUTES')
buildDiscarder(logRotator(numToKeepStr: '30', artifactNumToKeepStr: '10'))
disableConcurrentBuilds()
}
parameters {
// 1. 分支选择(实时从远端拉取)
listGitBranches(
name: 'GIT_BRANCH',
remoteURL: "${GIT_REPO_URL}",
credentialsId: 'github-ssh-key',
type: 'PT_BRANCH',
defaultValue: "${DEFAULT_BRANCH}",
branchFilter: '.*refs/heads/(.*)',
sortMode: 'ASCENDING_SMART',
selectedValue: 'DEFAULT',
quickFilterEnabled: true,
listSize: '0',
description: "构建分支。默认 ${DEFAULT_BRANCH}"
)
// 2. 后端环境
choice(
name: 'SERVER',
choices: ['pre', 'prod'], // ← 按项目实际环境修改
description: '后端环境。pre = 测试环境,prod = 生产环境'
)
// 3. 构建类型
choice(
name: 'BUILD_TYPE',
choices: ['debug', 'release'],
description: 'debug:快,不混淆;release:慢,开启混淆和资源压缩'
)
// 4. 全量重编
booleanParam(
name: 'CLEAN',
defaultValue: false,
description: '勾选则先执行 ./gradlew clean,全量重编(慢)。' +
'不勾则增量构建,速度快 3–5 倍。怀疑缓存有问题时才勾。'
)
// 5. 调试工具(可选,没有调试补丁可删掉这个参数)
booleanParam(
name: 'ENABLE_DEV_TOOLS',
defaultValue: false,
description: '注入调试工具(手势切换服务器环境等)。<b>仅用于内部测试包。</b>'
)
// 6. adb install 支持(仅当 App 用了系统签名时才需要)
booleanParam(
name: 'REMOVE_SHARED_USER_ID',
defaultValue: false,
description: '移除 Manifest 中的 android:sharedUserId,使 APK 可通过 adb install 安装。<br>' +
"<b>注意:</b>安装前需先 <code>adb uninstall ${APP_PACKAGE}</code>"
)
// 7. 版本号(留空则用 build.gradle 默认值)
string(
name: 'VERSION_NAME',
defaultValue: '1.0.0',
description: '版本名。留空则使用 build.gradle 里的默认值。'
)
}
environment {
JAVA_HOME = '/opt/java/jdk-17'
PATH = "/opt/java/jdk-17/bin:${env.PATH}"
GRADLE_OPTS = '-Dorg.gradle.daemon=false -Dfile.encoding=UTF-8'
GRADLE_USER_HOME = '/var/jenkins_home/.gradle'
}
stages {
// ── 阶段 1:拉取代码 ──────────────────────────────────────────────────
stage('拉取代码') {
steps {
script {
// 构建名称打 tag,历史列表里一眼区分特殊包
def tags = []
if (params.ENABLE_DEV_TOOLS) tags << 'devTools'
if (params.REMOVE_SHARED_USER_ID) tags << 'adb'
def versionSuffix = params.VERSION_NAME?.trim()
? " v${params.VERSION_NAME.trim()}(${BUILD_NUMBER})"
: " #${BUILD_NUMBER}"
currentBuild.displayName =
"${params.GIT_BRANCH} ${params.BUILD_TYPE}" +
"${tags ? ' [' + tags.join(',') + ']' : ''}" +
versionSuffix
currentBuild.description =
"server=${params.SERVER}" +
"${tags ? ' | ' + tags.join(', ') : ''}"
}
retry(2) {
// 清除可能残留的 git 锁(上次构建异常中断可能留下)
sh 'rm -f "$WORKSPACE"/.git/*.lock || true'
checkout([$class: 'GitSCM',
branches: [[name: "*/${params.GIT_BRANCH}"]],
userRemoteConfigs: [[
url: GIT_REPO_URL,
credentialsId: 'github-ssh-key'
]],
extensions: [[$class: 'CloneOption', timeout: 30]],
])
}
sh '''
git log --oneline -5
echo "工作区大小: $(du -sh "$WORKSPACE" | cut -f1)"
'''
script {
def commitMsg = sh(
script: 'git log -1 --format="%h %s"',
returnStdout: true
).trim()
currentBuild.description += " | ${commitMsg}"
}
}
}
// ── 阶段 2:应用补丁 ─────────────────────────────────────────────────
stage('应用补丁') {
steps {
// 通用补丁:每次构建都注入(不依赖参数)
sh '''
PATCH_DIR="/opt/android-patches"
FILE_COUNT=$(find "$PATCH_DIR" -mindepth 1 -type f 2>/dev/null | wc -l)
if [ "$FILE_COUNT" -gt 0 ]; then
echo "▶ 应用通用补丁(${FILE_COUNT} 个文件)"
rsync -av --checksum "$PATCH_DIR/" "$WORKSPACE/"
else
echo "无通用补丁,跳过"
fi
'''
script {
// 调试工具补丁
if (params.ENABLE_DEV_TOOLS) {
sh '''
DEV_DIR="/opt/android-patches-dev"
FILE_COUNT=$(find "$DEV_DIR" -mindepth 1 -type f 2>/dev/null | wc -l)
if [ "$FILE_COUNT" -gt 0 ]; then
echo "▶ 应用调试补丁(${FILE_COUNT} 个文件)"
rsync -av --checksum "$DEV_DIR/" "$WORKSPACE/"
fi
'''
}
// adb install 补丁:移除系统签名声明
if (params.REMOVE_SHARED_USER_ID) {
sh """
sed -i 's/[[:space:]]*android:sharedUserId="[^"]*"//g' \
${APP_MODULE}/src/main/AndroidManifest.xml
echo "▶ 已移除 sharedUserId(APK 可 adb install)"
"""
}
// ──────────────────────────────────────────────────────────
// 在这里添加其他构建时动态注入逻辑
// 示例:修改某个常量值
//
// if (params.SOME_FLAG) {
// def file = 'module/src/main/java/com/example/Config.kt'
// def text = readFile(file: file, encoding: 'UTF-8')
// text = text.replace('OLD_VALUE', 'NEW_VALUE')
// writeFile file: file, text: text, encoding: 'UTF-8'
// echo '▶ Config.kt 已注入'
// }
// ──────────────────────────────────────────────────────────
}
}
}
// ── 阶段 3:全量清理(可选)──────────────────────────────────────────
stage('Clean') {
when { expression { params.CLEAN } }
steps {
sh './gradlew clean --stacktrace'
}
}
// ── 阶段 4:构建 ─────────────────────────────────────────────────────
stage('构建') {
steps {
sh '''
echo "===== 工具链检查 ====="
echo "JAVA_HOME=$JAVA_HOME"
java -version
echo "ANDROID_HOME=$ANDROID_HOME"
echo "Gradle 版本:"
./gradlew --version | head -5
echo "====================="
'''
script {
def versionName = params.VERSION_NAME?.trim() ?: ''
def initScriptArg = ''
// 如果指定了版本号,通过 Gradle init script 注入
if (versionName) {
def finalVersionName = "${versionName}(${BUILD_NUMBER})"
def initScriptPath = "/tmp/version-override-${BUILD_NUMBER}.gradle"
writeFile file: initScriptPath, text: """\
allprojects {
afterEvaluate { project ->
if (project.plugins.hasPlugin('com.android.application')) {
project.android.defaultConfig {
versionName "${finalVersionName}"
versionCode ${BUILD_NUMBER}
}
}
}
}
"""
initScriptArg = "--init-script ${initScriptPath}"
echo "版本覆盖:versionName=${finalVersionName} versionCode=${BUILD_NUMBER}"
}
withEnv([
"APP_MODULE=${APP_MODULE}",
"SERVER=${params.SERVER}",
"BUILD_TYPE=${params.BUILD_TYPE}",
"INIT_SCRIPT_ARG=${initScriptArg}",
]) {
sh '''
set -e
# 首字母大写:debug → Debug,用于拼 Gradle task 名
BUILD_TYPE_CAP=$(echo "$BUILD_TYPE" | \
awk '{print toupper(substr($0,1,1)) substr($0,2)}')
TASK=":${APP_MODULE}:assemble${BUILD_TYPE_CAP}"
echo "▶ 开始构建"
echo " TASK : $TASK"
echo " SERVER : $SERVER"
echo " BUILD_TYPE: $BUILD_TYPE"
./gradlew ${TASK} \
-Pserver=${SERVER} \
${INIT_SCRIPT_ARG} \
--stacktrace \
--no-daemon
'''
}
}
}
}
// ── 阶段 5:归档产物 ─────────────────────────────────────────────────
stage('归档产物') {
steps {
script {
def apkGlob = "${APP_MODULE}/build/outputs/apk/**/*.apk"
def apks = findFiles(glob: apkGlob)
if (apks.length == 0) {
error(
"未找到 APK 产物(路径:${APP_MODULE}/build/outputs/apk/)\n" +
"排查步骤:\n" +
" 1. 在上方日志找 "> Task :${APP_MODULE}:assemble... FAILED"\n" +
" 2. 确认 JAVA_HOME 指向 JDK 17(./gradlew -version)\n" +
" 3. 确认 SERVER 参数在项目里已定义"
)
}
echo "找到 ${apks.length} 个 APK:"
apks.each { echo " ✓ ${it.path} (${it.length} bytes)" }
archiveArtifacts(
artifacts: apkGlob,
allowEmptyArchive: false,
fingerprint: true // 生成产物指纹,可溯源到具体构建
)
}
}
}
}
post {
success {
echo "✅ 构建成功!APK 已归档,点击右侧"Build Artifacts"下载。"
}
failure {
echo """
╔══════════════════════════════════════╗
║ 构建失败排查指引 ║
╠══════════════════════════════════════╣
║ 1. 搜索日志关键字 "FAILED" 或 "ERROR"║
║ 2. 确认 JDK 版本正确(需要 JDK 17) ║
║ ./gradlew -version ║
║ 3. 确认 SERVER 参数在项目里已定义 ║
║ 4. 尝试勾选 CLEAN 参数全量重编 ║
╚══════════════════════════════════════╝"""
}
cleanup {
// 只删 APK 产物目录,保留 .git 和 build/ 目录给下次增量编译用
sh "rm -rf \"\$WORKSPACE/${APP_MODULE}/build/outputs/apk\" || true"
}
}
}
十一、init.groovy.d/10-myapp-job.groovy(自动创建构建任务)
// =============================================================================
// init.groovy.d/10-myapp-job.groovy
//
// Jenkins 每次启动时执行,自动创建/更新构建任务。
// 好处:参数页在首次构建前就是完整的,不需要先跑一次"空构建"。
//
// 修改说明(适配新项目时改这两处):
// PIPELINE_FILE:对应 Jenkinsfile 文件名
// JOB_NAME:Jenkins 里显示的任务名
// =============================================================================
import jenkins.model.Jenkins
import org.jenkinsci.plugins.workflow.job.WorkflowJob
import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition
import hudson.model.*
// ★ 适配新项目时修改这两行 ★
final String PIPELINE_FILE = '/usr/share/jenkins/ref/pipelines/Jenkinsfile.myapp'
final String JOB_NAME = 'myapp_build'
def pipelineFile = new File(PIPELINE_FILE)
if (!pipelineFile.exists()) {
println "[init] ⚠ 找不到 ${PIPELINE_FILE},跳过 ${JOB_NAME} 的创建"
return
}
final String pipelineScript = pipelineFile.getText('UTF-8')
try {
MyAppJob.syncJob(JOB_NAME, pipelineScript)
} catch (Throwable t) {
println "[init] ❌ ${JOB_NAME} 同步失败: ${t}"
}
// ── 包在 class 里是为了避免脚本级方法的类名问题(JVM 限制)──
class MyAppJob {
static void syncJob(String jobName, String pipelineScript) {
def jenkins = Jenkins.get()
// 任务不存在则创建,存在则更新
def job = jenkins.getItemByFullName(jobName, WorkflowJob)
boolean created = (job == null)
if (created) {
job = jenkins.createProject(WorkflowJob, jobName)
}
// 更新 Pipeline 脚本(内容有变化时才写,避免不必要的 save)
def existingDef = job.definition
boolean scriptChanged = !(existingDef instanceof CpsFlowDefinition) ||
existingDef.script != pipelineScript
if (created || scriptChanged) {
job.definition = new CpsFlowDefinition(pipelineScript, true)
job.description = "自动构建任务,由 ${jobName}.groovy 管理"
job.save()
println "[init] ${created ? '已创建' : '已更新'} 任务 ${jobName}"
}
// 同步参数(保留用户已设置的值,只补充新增参数,删除已移除参数)
def desired = buildParamDefs()
def paramProp = job.getProperty(ParametersDefinitionProperty)
def registered = [:]
paramProp?.parameterDefinitions?.each { registered[it.name] = it }
// 已注册的保留,没注册的用默认值补上
def merged = desired.collect { registered[it.name] ?: it }
// 在 Jenkinsfile 里已经没有的参数,从注册列表里删掉
def stale = registered.keySet().findAll { name ->
!desired.any { it.name == name } &&
!(pipelineScript.contains("name: '${name}'") ||
pipelineScript.contains("name: \"${name}\""))
}
boolean needsUpdate = merged.size() != (paramProp?.parameterDefinitions?.size() ?: 0) ||
!stale.isEmpty() ||
merged.any { !registered.containsKey(it.name) }
if (needsUpdate) {
job.removeProperty(ParametersDefinitionProperty)
if (merged) job.addProperty(new ParametersDefinitionProperty(merged))
job.save()
println "[init] ${jobName} 参数同步完成(共 ${merged.size()} 个,删除过期: ${stale ?: '无'})"
}
}
// ── 与 Jenkinsfile 的 parameters{} 保持结构一致 ──────────────────────────
// booleanParam 的增删不需要改这里(Jenkins 自动补全)
// choice / string / listGitBranches 新增或改名时必须同步
static List buildParamDefs() {
def out = []
// 1. 分支(listGitBranches)
out << new com.syhuang.hudson.plugins.listgitbranchesparameter
.ListGitBranchesParameterDefinition(
'GIT_BRANCH',
'构建分支,下拉列表实时从远端拉取',
'git@github.com:your-org/your-repo.git', // ← 改成实际仓库地址
'github-ssh-key',
'develop',
com.syhuang.hudson.plugins.listgitbranchesparameter.SortMode.ASCENDING_SMART,
com.syhuang.hudson.plugins.listgitbranchesparameter.SelectedValue.DEFAULT,
Boolean.TRUE, 'PT_BRANCH', '*', '.*refs/heads/(.*)', '0'
)
// 2. 后端环境
out << new ChoiceParameterDefinition(
'SERVER', ['pre', 'prod'] as String[], // ← 按项目实际环境修改
'后端环境'
)
// 3. 构建类型
out << new ChoiceParameterDefinition(
'BUILD_TYPE', ['debug', 'release'] as String[],
'构建类型'
)
// 4–7. booleanParam 和 string(Jenkins 自动补全,这里是备份)
out << new BooleanParameterDefinition('CLEAN', Boolean.FALSE, '全量重编')
out << new BooleanParameterDefinition('ENABLE_DEV_TOOLS', Boolean.FALSE, '调试工具')
out << new BooleanParameterDefinition('REMOVE_SHARED_USER_ID', Boolean.FALSE, 'adb install')
out << new StringParameterDefinition('VERSION_NAME', '1.0.0', '版本名')
return out
}
}
十二、scripts/up.sh(一键启动脚本)
#!/usr/bin/env bash
# =============================================================================
# scripts/up.sh — 一键启动 Jenkins
#
# 用法:
# ./scripts/up.sh # 首次启动或常规启动
# ./scripts/up.sh --rebuild # 代码有改动后重建镜像
# ./scripts/up.sh --key ~/.ssh/jenkins_ci # 指定 SSH 私钥路径
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
cd "$PROJECT_DIR"
# ── 参数解析 ────────────────────────────────────────────────────────────────
REBUILD=false
SSH_KEY=""
while [[ $# -gt 0 ]]; do
case $1 in
--rebuild) REBUILD=true; shift ;;
--key) SSH_KEY="$2"; shift 2 ;;
*) echo "未知参数: $1"; exit 1 ;;
esac
done
# ── 检查 Docker ──────────────────────────────────────────────────────────────
if ! command -v docker &>/dev/null; then
echo "❌ 未找到 Docker,请先按文档第二节安装 Docker"
exit 1
fi
# ── 生成 .env(首次运行)────────────────────────────────────────────────────
if [[ ! -f .env ]]; then
echo "首次运行,从 .env.example 生成 .env ..."
cp .env.example .env
# 生成随机密码
RANDOM_PASS=$(openssl rand -base64 16 | tr -dc 'a-zA-Z0-9' | head -c 20)
sed -i "s/请改成强密码/${RANDOM_PASS}/" .env
echo "✅ 已生成 .env,管理员密码:${RANDOM_PASS}"
echo " (密码已保存在 .env 文件里,请妥善保管)"
fi
# ── 处理 SSH 私钥 ────────────────────────────────────────────────────────────
if [[ -n "$SSH_KEY" ]]; then
SSH_DIR=$(dirname "$SSH_KEY")
sed -i "s|HOST_SSH_DIR=.*|HOST_SSH_DIR=${SSH_DIR}|" .env
chmod 600 "$SSH_KEY"
echo "✅ SSH 私钥目录已更新:${SSH_DIR}"
fi
# ── 构建 / 启动 ──────────────────────────────────────────────────────────────
if [[ "$REBUILD" == "true" ]]; then
echo "▶ 重建镜像(--rebuild)..."
docker compose build --no-cache
fi
echo "▶ 启动 Jenkins ..."
docker compose up -d
# ── 等待就绪 ─────────────────────────────────────────────────────────────────
echo -n "⏳ 等待 Jenkins 启动"
JENKINS_PORT=$(grep JENKINS_HTTP_PORT .env | cut -d= -f2 || echo 8080)
for i in $(seq 1 60); do
if curl -fsS "http://localhost:${JENKINS_PORT}/login" &>/dev/null; then
echo ""
break
fi
echo -n "."
sleep 3
done
# ── 打印访问信息 ──────────────────────────────────────────────────────────────
ADMIN_PASS=$(grep JENKINS_ADMIN_PASSWORD .env | cut -d= -f2)
LOCAL_IP=$(hostname -I | awk '{print $1}')
echo ""
echo "╔══════════════════════════════════════════════╗"
echo "║ Jenkins 已就绪 🎉 ║"
echo "╠══════════════════════════════════════════════╣"
echo "║ 本机访问:http://localhost:${JENKINS_PORT} ║"
echo "║ 局域网 :http://${LOCAL_IP}:${JENKINS_PORT} ║"
echo "║ 账号 :admin ║"
echo "║ 密码 :${ADMIN_PASS} ║"
echo "╚══════════════════════════════════════════════╝"
echo ""
echo "常用命令:"
echo " 查看日志 :docker compose logs -f"
echo " 停止 :docker compose stop"
echo " 重建镜像 :./scripts/up.sh --rebuild"
# 给脚本加执行权限
chmod +x scripts/up.sh
十三、适配新项目(只改 5 处)
拿到这套模板后,适配新的 Android 项目只需要修改以下内容:
第 1 处:Dockerfile — Android SDK 版本
ARG ANDROID_PACKAGES="\
platform-tools \
platforms;android-35 \ ← 改成项目的 compileSdk
build-tools;35.0.0" ← 改成项目的 buildToolsVersion
第 2 处:Jenkinsfile — 项目配置区(顶部 4 行)
def GIT_REPO_URL = 'git@github.com:your-org/your-repo.git' // ← 改成实际仓库地址
def DEFAULT_BRANCH = 'develop' // ← 改成默认分支
def APP_MODULE = 'app' // ← 改成 Gradle 模块名
def APP_PACKAGE = 'com.example.myapp' // ← 改成应用包名
第 3 处:Jenkinsfile — SERVER 环境选项
choice(name: 'SERVER', choices: ['pre', 'prod'], // ← 改成项目实际的环境名
第 4 处:init.groovy.d — 文件名和任务名
final String PIPELINE_FILE = '/usr/share/jenkins/ref/pipelines/Jenkinsfile.myapp' // ← 改成实际文件名
final String JOB_NAME = 'myapp_build' // ← 改成任务名
以及 buildParamDefs() 里的仓库地址和环境选项:
'git@github.com:your-org/your-repo.git', // ← 改成实际仓库地址
out << new ChoiceParameterDefinition(
'SERVER', ['pre', 'prod'] as String[], // ← 和 Jenkinsfile 保持一致
第 5 处:文件重命名
cp Jenkinsfile.myapp Jenkinsfile.yourapp
cp init.groovy.d/10-myapp-job.groovy init.groovy.d/10-yourapp-job.groovy
改完后重建镜像生效:
./scripts/up.sh --rebuild
十四、首次构建操作步骤
-
打开 Jenkins:浏览器访问
http://服务器IP:8080,用up.sh打印的账号密码登录 -
找到构建任务:左侧菜单点任务名(如
myapp_build) -
点击"Build with Parameters":
GIT_BRANCH:从下拉列表选择分支SERVER:选pre(先用测试环境验证)BUILD_TYPE:选debug(首次构建先用 debug,编译快)- 其他参数保持默认
-
点"Build"按钮:开始构建
-
查看进度:左侧"Build History"里点最新一条,再点"Console Output"查看实时日志
-
下载 APK:构建成功后,任务页右侧"Last Successful Artifacts"里点 APK 文件下载
十五、日常维护
改了 Jenkinsfile 或 init.groovy 后如何生效
# 必须重建镜像,这些文件是在构建镜像时拷进去的
./scripts/up.sh --rebuild
改了 .env 后如何生效
# 只需重建容器(不需要重建镜像,比 --rebuild 快很多)
docker compose up -d
查看构建日志
# 实时查看 Jenkins 容器日志
docker compose logs -f
# 或者在网页里查看某次构建的详细日志
# Jenkins UI → 任务名 → Build History → 某次构建 → Console Output
备份和恢复
# 备份数据卷(包含所有构建历史、配置、Gradle 缓存)
docker run --rm \
-v jenkins_android_home:/data \
-v $(pwd):/backup \
ubuntu tar czf /backup/jenkins_backup_$(date +%Y%m%d).tar.gz -C /data .
# 恢复
docker run --rm \
-v jenkins_android_home:/data \
-v $(pwd):/backup \
ubuntu tar xzf /backup/jenkins_backup_20240101.tar.gz -C /data
十六、常见问题
Q:第一次启动很慢,一直转圈
原因:首次构建镜像需要下载 Android SDK(约 1–2 GB),耗时 10–30 分钟,取决于网速。
解法:等待即可。下次启动(不带 --rebuild)直接用缓存的镜像,秒级启动。
Q:登录后任务参数页是空的
原因:init.groovy.d 脚本执行失败,参数没有注册成功。
排查:
docker compose logs | grep "\[init\]"
找报错信息,通常是 Groovy 语法错误或插件未安装。
Q:拉代码时报 Permission denied (publickey)
原因:Jenkins 容器读不到私钥文件,或私钥文件权限不对。
验证:
docker exec jenkins-android \
test -r /var/jenkins_home/.ssh/id_rsa && echo "可读" || echo "无权限"
解法:
# 确保私钥文件权限是 600
chmod 600 ~/.ssh/id_rsa
# 重新启动
docker compose restart
Q:构建时报 Kotlin daemon...OutOfMemoryError
原因:宿主机可用内存不足,Kotlin 编译器被杀掉。
解法:
# 查看当前内存使用
free -h
# 在 docker-compose.yml 里限制 Kotlin 编译器内存(加到 environment 里)
- GRADLE_OPTS=-Dorg.gradle.daemon=false -Dkotlin.daemon.jvm.options="-Xmx2g"
Q:改了 Pipeline 脚本后网页上还是老的
原因:init.groovy.d 脚本在每次 Jenkins 启动时执行,用镜像内的 Jenkinsfile 覆盖网页里改的脚本。
解法:要改 Pipeline,必须改 Git 里的 Jenkinsfile.*,然后:
./scripts/up.sh --rebuild
Q:release 包意外上传了符号表
原因:build.sh 里默认开启了混淆映射上传(对外操作,不可撤销)。
规范:
- 日常测试固定用
debug - 要出
release包前,确认当前选的SERVER是正确的目标环境 - 可以在 Pipeline 里加提示:
if (params.BUILD_TYPE == 'release') {
echo "⚠️ release 包将上传混淆映射到符号表服务,目标环境:${params.SERVER}"
// 如果需要二次确认,可以接入 input 步骤
// input message: "确认向 ${params.SERVER} 上传符号表?"
}
十七、总结
| 设计决策 | 解决的问题 |
|---|---|
| 编译环境进 Docker 镜像 | 环境一致性,任意机器一条命令重建 |
| JCasC 管理 Jenkins 配置 | 配置版本化,重建后自动恢复 |
| init.groovy.d 创建任务 | 参数页首次就完整,不需要空跑一次 |
| 数据卷持久化 | 重建镜像不丢构建历史和 Gradle 缓存 |
| rsync 补丁机制 | 调试代码与主干解耦,正式包不受污染 |
| readFile/writeFile 动态注入 | 无外部工具依赖,逻辑与参数定义在一起 |
| Gradle 缓存共享 | 多任务共用,增量构建快 3–5 倍 |
| 私钥不进镜像 | 安全隔离,volume 运行时注入 |
整套方案的核心思路是让配置成为代码:Dockerfile、JCasC、Pipeline 脚本全部 Git 管理,任何人拿到仓库都能重建出一模一样的环境。适配新项目只需要改 5 处配置,其他全部复用。
如有问题欢迎评论区讨论。
标签:Jenkins / Android / CI/CD / Docker / Gradle / DevOps

浙公网安备 33010602011771号