从零搭建 Android APK 自动化构建平台:Docker + Jenkins 完整实践

前言

Android 项目随着规模扩大,往往面临以下痛点:

  • 多 App、多环境(测试 / 预发 / 生产)、多签名,人工打包流程繁琐且容易出错
  • 打包环境只在某一台机器上,机器故障或人员离职就得从头配置
  • 测试包、调试包、正式包区分不清,容易发错给用户

本文从零开始,介绍如何用 Docker + Jenkins 搭建一套 Android APK 自动化构建平台。所有配置文件纳入 Git 管理,在任意 Linux 机器执行一条命令即可重建完整环境,拿到模板后只需修改少量参数即可适配自己的项目。

适用读者:有一定 Android 开发经验,了解基本的 Linux 命令,没有 Docker / Jenkins 使用经验也没关系,本文会逐步说明。

一、整体思路

1.1 为什么用 Docker

传统方案是在服务器上手动安装 JDK、Android SDK,然后在 Jenkins 里配置环境变量。这种方式有三个问题:

  1. 不可复现:装过什么包、改过哪些配置全靠记忆,出了问题很难排查
  2. 难以迁移:换机器就要重装一遍,费时费力
  3. 版本冲突:不同项目可能需要不同 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 把公钥添加到代码托管平台

GitHubSettings → SSH and GPG keys → New SSH key,粘贴 jenkins_ci.pub 的内容。

GitLabUser 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

十四、首次构建操作步骤

  1. 打开 Jenkins:浏览器访问 http://服务器IP:8080,用 up.sh 打印的账号密码登录

  2. 找到构建任务:左侧菜单点任务名(如 myapp_build

  3. 点击"Build with Parameters"

    • GIT_BRANCH:从下拉列表选择分支
    • SERVER:选 pre(先用测试环境验证)
    • BUILD_TYPE:选 debug(首次构建先用 debug,编译快)
    • 其他参数保持默认
  4. 点"Build"按钮:开始构建

  5. 查看进度:左侧"Build History"里点最新一条,再点"Console Output"查看实时日志

  6. 下载 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

posted @ 2026-08-31 15:41  重庆_伦儿  阅读(27)  评论(0)    收藏  举报