Android 使用 Jenkins 实现 CI/CD,并用 SonarQube 建立质量门禁

Android 项目在本地能成功打包,并不代表它已经具备稳定交付能力。真正进入多人协作后,团队通常会遇到这些问题:有人忘了跑单元测试,有人的 JDK 或 Android SDK 版本不同,主分支偶尔无法构建,签名文件散落在开发机上,代码扫描做了却没人处理结果,发布过程还依赖某位同事手动点击。

Jenkins 和 SonarQube 解决的是两类不同问题:

  • Jenkins 负责把检出代码、编译、测试、扫描、打包和发布串成可重复执行的流水线;
  • SonarQube 负责持续分析 Bug、漏洞、代码异味、重复率和覆盖率,并通过 Quality Gate 决定代码能否继续交付。

这篇文章搭建的目标不是"让 Jenkins 能执行一条 Gradle 命令",而是形成一条真正可执行的交付链:

text 复制代码
提交 / Pull Request
        ↓
检出代码 → 编译 → Android Lint → 单元测试 → 覆盖率
        ↓
SonarQube 分析 → Quality Gate
        ↓ 通过
生成签名 AAB → 上传 Google Play Internal Track
        ↓
人工审批后逐步发布到生产

其中,Pull Request 只执行 CI;合并到 main 后才允许读取发布凭据并部署到内测渠道。这个边界非常重要:不能让来自不可信分支的 Jenkinsfile 获得签名文件、Sonar Token 或 Google Play 密钥。

一、先确定整体架构

一套适合 Android 团队的基本架构如下:

组件 职责 建议部署方式
Git 仓库 保存业务代码和 Jenkinsfile GitHub、GitLab 或企业 Git 服务
Jenkins Controller 调度任务、保存流水线配置 独立服务器,不直接承担 Android 构建
Android Agent JDK、Android SDK、Gradle 构建环境 独立 Linux 节点或固定版本的容器镜像
SonarQube 静态分析、覆盖率展示、质量门禁 SonarQube + PostgreSQL
制品与发布平台 保存 AAB/APK、分发测试或正式版本 Jenkins Artifact、制品库、Firebase App Distribution、Google Play

不建议在 Jenkins Controller 上直接构建 Android 项目。构建过程会占用大量 CPU、内存和磁盘,Gradle Daemon 与 Android SDK 也会增加 Controller 的维护复杂度。更稳妥的做法是给 Android Agent 打上 android 标签,让 Pipeline 只在该节点运行。

二、准备 Jenkins Android 构建节点

构建节点至少需要以下工具:

  • 与项目 Android Gradle Plugin(AGP)兼容的 JDK;
  • Android SDK Command-line Tools;
  • 项目 compileSdk 对应的 Platform 与 Build Tools;
  • Git、unzip、Ruby/Bundler(使用 fastlane 发布时需要);
  • 足够的磁盘空间,以及可持久化的 Gradle 缓存。

JDK 版本不要凭经验随意选择。例如 AGP 8.x 通常要求 JDK 17,但具体要求仍应以项目使用的 AGP 版本说明为准。SDK 组件也应该明确固定:

bash 复制代码
sdkmanager \
  "platform-tools" \
  "platforms;android-36" \
  "build-tools;36.0.0"

sdkmanager --licenses

上面的 API 级别只是示例,应与项目配置保持一致。生产节点最好提前接受许可证并制作成可复用镜像,不要让每次构建临时下载 SDK。还应确保仓库中的 Gradle Wrapper 被提交,并在 CI 中始终使用 ./gradlew,避免 Agent 上的全局 Gradle 版本影响构建。

在 Jenkins 中安装或确认以下插件:

  • Pipeline;
  • Git;
  • Credentials Binding;
  • JUnit;
  • SonarQube Scanner for Jenkins。

Android 构建本身不依赖某个专用 Jenkins Android 插件,调用 Gradle Wrapper 就够了。

三、部署 SonarQube

SonarQube 不应使用内置数据库承载生产数据。下面用 PostgreSQL 展示一套最小化部署结构:

yaml 复制代码
# compose.yaml
services:
  db:
    image: postgres:16
    restart: unless-stopped
    environment:
      POSTGRES_DB: sonar
      POSTGRES_USER: sonar
      POSTGRES_PASSWORD: ${SONAR_DB_PASSWORD}
    volumes:
      - sonar_db:/var/lib/postgresql/data

  sonarqube:
    image: sonarqube:lts-community
    restart: unless-stopped
    depends_on:
      - db
    ports:
      - "9000:9000"
    environment:
      SONAR_JDBC_URL: jdbc:postgresql://db:5432/sonar
      SONAR_JDBC_USERNAME: sonar
      SONAR_JDBC_PASSWORD: ${SONAR_DB_PASSWORD}
    ulimits:
      nofile:
        soft: 131072
        hard: 131072
      nproc: 8192
    volumes:
      - sonar_data:/opt/sonarqube/data
      - sonar_logs:/opt/sonarqube/logs
      - sonar_extensions:/opt/sonarqube/extensions

volumes:
  sonar_db:
  sonar_data:
  sonar_logs:
  sonar_extensions:

密码应来自部署平台的 Secret 或仅保存在服务器上的 .env,不能提交到 Git。Linux 主机还需要满足 SonarQube 对虚拟内存等内核参数的要求,例如:

bash 复制代码
sudo sysctl -w vm.max_map_count=524288
sudo sysctl -w fs.file-max=131072

验证后把参数写入主机的持久化系统配置。正式环境还应增加 HTTPS 反向代理、备份、监控与固定镜像版本;不要长期依赖浮动标签自动升级。

首次登录后完成三件事:

  1. 创建 Android 项目,例如 Project Key 为 robot_android_app;
  2. 创建只用于该项目分析的 Token,不使用管理员账号 Token;
  3. 定义 Quality Gate 和 New Code 基线。

Community 版本适合分析主分支。若需要 SonarQube 原生的多分支或 Pull Request 装饰能力,应先核对当前 SonarQube 版本与版本授权;不要默认 Community 版本包含所有分支能力。

四、在 Android 项目中接入 SonarScanner 与 JaCoCo

SonarQube 可以分析 Kotlin/Java 源码,但覆盖率不是它自己执行测试得到的。正确的顺序是:

text 复制代码
Gradle 执行测试 → JaCoCo 生成 XML → SonarScanner 读取 XML → SonarQube 展示覆盖率

下面以单模块 app、debug 变体和 Kotlin DSL 为例。插件版本应固定为团队已经验证、且与当前 Gradle/JDK 兼容的版本;升级时单独提交并跑完整流水线。

根目录 build.gradle.kts:

kotlin 复制代码
plugins {
    // 保留项目已有的 Android、Kotlin 等插件声明。
    id("org.sonarqube") version "7.5.0.8588"
}

sonar {
    properties {
        property("sonar.projectKey", "robot_android_app")
        property("sonar.projectName", "Robot Android App")

        // 多模块时可以填写多个以逗号分隔的 XML 路径。
        property(
            "sonar.coverage.jacoco.xmlReportPaths",
            "app/build/reports/jacoco/jacocoDebugTestReport/jacocoDebugTestReport.xml"
        )

        // 不把生成代码计入业务覆盖率,范围要尽量克制。
        property(
            "sonar.coverage.exclusions",
            "**/R.class,**/R$*.class,**/BuildConfig.*,**/*_Factory.*,**/*Hilt*.*"
        )
    }
}

不要把 SonarQube 地址和 Token 写进 build.gradle.kts。本地分析时可通过环境变量或命令行安全传入;Jenkins 中由 SonarQube 插件注入。示例在执行 sonar 时通过 -Dsonar.gradle.skipCompile=true 明确关闭隐式编译;使用已经将扫描任务与编译解耦的新版插件时,这个参数可以根据官方版本说明移除。

app/build.gradle.kts 中增加 JaCoCo 报告任务:

kotlin 复制代码
import org.gradle.testing.jacoco.tasks.JacocoReport

plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
    jacoco
}

jacoco {
    toolVersion = "0.8.15"
}

android {
    // 省略 namespace、compileSdk 等已有配置

    buildTypes {
        getByName("debug") {
            enableUnitTestCoverage = true
        }
    }
}

val coverageExcludes = listOf(
    "**/R.class",
    "**/R$*.class",
    "**/BuildConfig.*",
    "**/Manifest*.*",
    "**/*Test*.*",
    "**/*_Factory.*",
    "**/*_MembersInjector.*",
    "**/*Hilt*.*"
)

tasks.register<JacocoReport>("jacocoDebugTestReport") {
    dependsOn("testDebugUnitTest")

    reports {
        xml.required.set(true)
        html.required.set(true)
        csv.required.set(false)
    }

    sourceDirectories.setFrom(
        files("src/main/java", "src/main/kotlin")
    )

    classDirectories.setFrom(
        files(
            fileTree(layout.buildDirectory.dir("tmp/kotlin-classes/debug")) {
                exclude(coverageExcludes)
            },
            fileTree(
                layout.buildDirectory.dir(
                    "intermediates/javac/debug/compileDebugJavaWithJavac/classes"
                )
            ) {
                exclude(coverageExcludes)
            }
        )
    )

    executionData.setFrom(
        fileTree(layout.buildDirectory) {
            include(
                "jacoco/testDebugUnitTest.exec",
                "outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec"
            )
        }
    )
}

不同 AGP 版本、Product Flavor 和测试框架产生的 class/exec 路径可能不同。先执行:

bash 复制代码
./gradlew testDebugUnitTest jacocoDebugTestReport

然后确认 XML 确实存在且包含业务类:

text 复制代码
app/build/reports/jacoco/jacocoDebugTestReport/jacocoDebugTestReport.xml

如果项目实际分析的是 prodDebug,对应任务可能是 testProdDebugUnitTest,Kotlin class 目录也会变成 tmp/kotlin-classes/prodDebug。不要为了让覆盖率"看起来正常"而盲目扩大排除规则。

五、把 Jenkins 与 SonarQube 连接起来

进入 Jenkins:

text 复制代码
Manage Jenkins
  → System
  → SonarQube installations

新增一项:

  • Name:sonarqube-prod;
  • Server URL:SonarQube 的 HTTPS 地址;
  • Credentials:类型为 Secret text,内容是项目分析 Token。

接着在 SonarQube 项目中配置 Webhook:

text 复制代码
https://jenkins.example.com/sonarqube-webhook/

末尾的 / 不要省略。Webhook 的作用不是触发扫描,而是让 SonarQube 在后台分析完成后通知 Jenkins。没有它,waitForQualityGate 往往只能等到超时。

如果 Jenkins 不允许公网访问,应在内网打通 SonarQube 到 Jenkins 的回调路径。不要为了省事把 Jenkins 管理界面整体暴露到公网。

六、编写 Jenkinsfile:从 CI 到内测发布

下面是一份适合 Multibranch Pipeline 的基础示例:

groovy 复制代码
pipeline {
    agent { label 'android' }

    options {
        timestamps()
        timeout(time: 45, unit: 'MINUTES')
        disableConcurrentBuilds(abortPrevious: true)
    }

    environment {
        GRADLE_USER_HOME = "${WORKSPACE}/.gradle"
    }

    stages {
        stage('Checkout') {
            steps {
                checkout scm
                sh 'chmod +x gradlew'
                sh './gradlew --version'
            }
        }

        stage('Build, Lint & Test') {
            steps {
                sh '''
                    ./gradlew --no-daemon --stacktrace \
                      clean \
                      lintDebug \
                      testDebugUnitTest \
                      jacocoDebugTestReport \
                      assembleDebug
                '''
            }
            post {
                always {
                    junit allowEmptyResults: true,
                          testResults: '**/build/test-results/**/*.xml'
                    archiveArtifacts allowEmptyArchive: true,
                          artifacts: '**/build/reports/lint-results-*.html,**/build/reports/jacoco/**'
                }
            }
        }

        stage('SonarQube Analysis') {
            // Community 版本兼容写法:只分析主分支。
            when {
                branch 'main'
            }
            steps {
                withSonarQubeEnv('sonarqube-prod') {
                    sh './gradlew --no-daemon sonar -Dsonar.gradle.skipCompile=true'
                }
            }
        }

        stage('Quality Gate') {
            when {
                branch 'main'
            }
            steps {
                timeout(time: 10, unit: 'MINUTES') {
                    waitForQualityGate abortPipeline: true
                }
            }
        }

        stage('Bundle Release') {
            when {
                branch 'main'
            }
            steps {
                withCredentials([
                    file(
                        credentialsId: 'android-upload-keystore',
                        variable: 'ANDROID_KEYSTORE_PATH'
                    ),
                    string(
                        credentialsId: 'android-store-password',
                        variable: 'ANDROID_STORE_PASSWORD'
                    ),
                    string(
                        credentialsId: 'android-key-alias',
                        variable: 'ANDROID_KEY_ALIAS'
                    ),
                    string(
                        credentialsId: 'android-key-password',
                        variable: 'ANDROID_KEY_PASSWORD'
                    )
                ]) {
                    sh './gradlew --no-daemon bundleRelease'
                }
            }
            post {
                success {
                    archiveArtifacts artifacts: 'app/build/outputs/bundle/release/*.aab',
                                     fingerprint: true
                }
            }
        }

        stage('Deploy Internal') {
            when {
                branch 'main'
            }
            steps {
                withCredentials([
                    file(
                        credentialsId: 'google-play-service-account',
                        variable: 'PLAY_JSON_KEY'
                    )
                ]) {
                    sh 'bundle config set path vendor/bundle'
                    sh 'bundle install --jobs 4 --retry 3'
                    sh 'bundle exec fastlane android internal'
                }
            }
        }
    }

    post {
        always {
            deleteDir()
        }
    }
}

上面的 SonarQube 阶段只分析 main,因此可兼容不提供多分支/PR 分析的 Community 版本,Quality Gate 会在内测发布前阻断不合格构建。如果当前 SonarQube 版本支持 Pull Request 分析,并且已完成代码托管平台集成,可以把两个阶段的 when 调整为:

groovy 复制代码
when {
    anyOf {
        branch 'main'
        changeRequest()
    }
}

此时 PR 也能在合并前得到 SonarQube 门禁结果。来自 Fork 的不可信 PR 应使用不含 Secret 的独立任务或受信任的 Jenkinsfile,不能仅依赖 when 条件保护凭据。

这条流水线有几个关键点:

  1. 质量门禁在签名之前。 SonarQube 不通过就不会读取发布凭据,也不会生成发布包。
  2. 只有 main 可以发布。 PR 构建不会接触签名文件和 Google Play 服务账号。
  3. 凭据只在最小作用域内绑定。 不把密钥复制进仓库,也不在 Groovy 双引号中拼接敏感值。
  4. 扫描前已完成编译和测试。 sonar 只消费已有结果,流水线行为更明确。
  5. 失败也收集报告。 即使 Lint 或测试失败,Jenkins 页面仍能显示测试结果和已有报告。

示例末尾执行了 deleteDir(),因此 ${WORKSPACE}/.gradle 只在单次流水线内复用。如果希望跨构建缓存,应在 Agent 或容器层配置独立、受控的 Gradle 缓存,并定期清理;不要让不同信任级别的任务共用可被任意写入的缓存目录。

七、安全读取 Android 签名信息

在 app/build.gradle.kts 中从环境变量读取签名信息:

kotlin 复制代码
android {
    signingConfigs {
        create("release") {
            val keystorePath = System.getenv("ANDROID_KEYSTORE_PATH")
            if (!keystorePath.isNullOrBlank()) {
                storeFile = file(keystorePath)
                storePassword = System.getenv("ANDROID_STORE_PASSWORD")
                keyAlias = System.getenv("ANDROID_KEY_ALIAS")
                keyPassword = System.getenv("ANDROID_KEY_PASSWORD")
            }
        }
    }

    buildTypes {
        getByName("release") {
            isMinifyEnabled = true
            isShrinkResources = true
            signingConfig = signingConfigs.getByName("release")
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }
}

Jenkins Credentials 建议分别保存:

Credential ID 类型 内容
android-upload-keystore Secret file Upload Keystore
android-store-password Secret text Keystore 密码
android-key-alias Secret text Key Alias
android-key-password Secret text Key 密码
google-play-service-account Secret file Google Play 服务账号 JSON

优先使用 Google Play App Signing,并让 Jenkins 持有 Upload Key,而不是最终的 App Signing Key。为服务账号只分配发布所需的最小权限,定期轮换,避免多人共享个人账号。

八、使用 fastlane 上传 Google Play 内测渠道

在项目根目录添加 Gemfile 并提交锁文件:

ruby 复制代码
source "https://rubygems.org"

gem "fastlane"

fastlane/Fastfile:

ruby 复制代码
default_platform(:android)

platform :android do
  desc "Upload the signed AAB to Google Play internal track"
  lane :internal do
    upload_to_play_store(
      json_key: ENV["PLAY_JSON_KEY"],
      aab: Dir["app/build/outputs/bundle/release/*.aab"].first,
      track: "internal",
      release_status: "completed",
      skip_upload_metadata: true,
      skip_upload_images: true,
      skip_upload_screenshots: true
    )
  end
end

第一次接入时,建议先用 Internal Track 验证服务账号权限、包名和 versionCode。Google Play 要求每次上传的 versionCode 单调递增,因此团队应制定统一版本策略,例如由版本文件、Git Tag 与 CI 构建号共同生成,不能让不同分支随意写同一个版本号。

持续部署不等于"每次合并都直接全量上生产"。更合理的发布层次是:

text 复制代码
main 合并成功 → 自动发布 Internal Track
        ↓ 测试验证
Release Tag → 人工审批 → 小比例灰度
        ↓ 指标稳定
逐步扩大比例 → 全量

生产发布阶段应保留 Jenkins input 审批或独立的受保护发布任务,并限制谁可以触发。出现崩溃率、ANR 或核心业务指标异常时,优先停止灰度;Android 版本通常不能简单"覆盖回滚"为更低 versionCode,因此还要准备可快速发布的修复版本和服务端开关。

九、如何设计真正有用的 Quality Gate

质量门禁最容易走向两个极端:规则太松,永远是绿色;规则太严,一接入就有几千个历史问题,团队只能选择忽略。

推荐使用 Clean as You Code 思路,先约束 New Code:

指标 新代码建议门槛 目的
Reliability Rating A 新增代码不引入明确 Bug
Security Rating A 不引入新漏洞
Maintainability Rating A 控制新增技术债务
新代码测试覆盖率 ≥ 80% 让新增核心逻辑具备回归保护
新代码重复率 ≤ 3% 避免复制粘贴扩散
Security Hotspots Reviewed 100% 所有新安全热点都经过人工判断

80% 不是所有项目的真理。UI 壳层、生成代码与核心领域逻辑的测试价值不同。重点是让阈值可执行、排除项可解释,并逐步提高,而不是通过大量 sonar.coverage.exclusions 把数字"优化"出来。

建议把 New Code 定义为"相对上一发布版本"或"最近固定天数",并在每次正式发布后更新基线。老项目可以先冻结历史债务,要求新代码不再恶化;再按模块逐步清理存量问题。

十、Android Lint、SonarQube 和测试不能互相替代

三者覆盖的范围不同:

工具 更擅长发现的问题
Android Lint Android API 误用、资源、Manifest、可访问性、兼容性
SonarQube 通用 Bug、漏洞、代码异味、重复、复杂度和质量趋势
单元/集成/UI 测试 业务行为是否符合预期,改动是否造成回归

因此 Pipeline 应分别执行并分别失败。SonarQube 显示绿色,不代表 Android Lint 一定通过;覆盖率高也不代表断言有效。对关键项目,还可以加入 Detekt、依赖漏洞扫描、Secret 扫描、仪器测试和基准测试,但要明确每个检查解决什么风险。

十一、多模块项目如何提速

Android 项目变大后,CI 时间会直接影响开发反馈速度。优化时优先保证可重复性,再追求速度:

  1. 固定工具链。 固定 JDK、SDK、AGP、Gradle Wrapper 和 NDK,避免构建节点漂移。
  2. 合理使用缓存。 启用 Gradle Build Cache 与依赖缓存,缓存键应包含 Wrapper、依赖锁和关键构建配置。
  3. 减少无意义的 clean。 干净的临时 Agent 本身就是新工作区;长期 Agent 才需要结合实际问题决定是否清理。
  4. 并行但不要重复工作。 Lint、测试可并行;Sonar 分析必须等覆盖率文件准备好。
  5. 区分 PR 与发布流水线。 PR 快速反馈,主分支再执行签名、完整测试和发布。
  6. 多模块聚合覆盖率。 每个模块生成 XML,Sonar 配置多个报告路径;不要只分析 app 而遗漏核心 library。

示例 Jenkinsfile 为了容易理解执行了 clean。当流水线稳定后,可以在短生命周期 Agent 上去掉它,测量真实收益后再保留这一优化。

十二、常见故障排查

1. waitForQualityGate 一直超时

依次检查:

  • SonarQube Webhook URL 是否以 /sonarqube-webhook/ 结尾;
  • SonarQube 能否访问 Jenkins;
  • Jenkins 日志中是否收到 Webhook;
  • SonarQube Background Tasks 是否已经完成;
  • Pipeline 是否在 withSonarQubeEnv 中执行扫描。

2. SonarQube 显示覆盖率为 0

不要先改排除规则,先检查:

bash 复制代码
test -s app/build/reports/jacoco/jacocoDebugTestReport/jacocoDebugTestReport.xml

再确认测试任务、分析变体、class 目录和 XML 路径一致。常见原因是测试跑了 prodDebug,报告却仍指向 debug。

3. 本地能打包,Jenkins 找不到 SDK

确认 Agent 用户能读取 ANDROID_SDK_ROOT,并检查 local.properties 是否被错误提交。CI 应通过节点环境配置 SDK 路径,不应依赖某位开发者机器上的绝对路径。

4. Jenkins 日志泄露密码

避免在 Groovy 双引号字符串中展开凭据,不要执行 env、printenv,也不要开启会输出完整参数的调试脚本。Credentials Masking 只能降低意外泄露风险,不能阻止恶意流水线主动外传 Secret。

5. Sonar 扫描通过,但发布包不可用

SonarQube 是源码质量门禁,不验证 R8 后的运行行为、签名、安装或后端兼容性。发布前至少对 Release 包执行一次安装/冒烟测试;复杂项目应在签名后跑关键设备测试。

十三、一份可落地的接入顺序

不要第一天就把所有门禁设为阻断。更稳妥的落地顺序是:

text 复制代码
第 1 步:统一 JDK、SDK、Gradle Wrapper,Jenkins 能稳定 assembleDebug
第 2 步:接入 Lint 与单元测试,失败时可查看报告
第 3 步:生成 JaCoCo XML,并核对业务类覆盖率
第 4 步:接入 SonarQube,只观察一段时间并清理误报
第 5 步:对 New Code 启用 Quality Gate 阻断
第 6 步:将签名文件迁入 Jenkins Credentials
第 7 步:自动发布 Internal Track
第 8 步:增加审批、灰度、监控和应急发布机制

最终,一条可靠的 Android CI/CD 流水线应该满足四个特征:环境可复现、检查有证据、密钥有边界、发布可追踪。Jenkins 负责把这些环节稳定地串起来,SonarQube 则让"代码质量"从评审中的主观印象,变成持续、可执行的合并条件。

参考资料

相关推荐
其实防守也摸鱼1 小时前
DeepSeek Harness 开源贡献手记:从 Issue 到 Merge 的完整旅程
android·数据库·学习·ai·oracle·自动化
恋猫de小郭1 小时前
Android CLI 支持 AI Agent 通过 Device Streaming 调试云真机
android·前端·flutter
素师良码19 小时前
第5篇:显示驱动必备调试工具浅析
android
维克兜率天19 小时前
【维克】配对交易的季节性:哪些品种适合长拿?
android·开发语言·笔记·python·算法·kotlin·量化
墨天梦20 小时前
B06_XML控件布局与ViewBinding
android·kotlin
晚风叙码20 小时前
MySQL 表的操作:从建表到删表,一篇讲清楚
android·mysql·oracle
事圆则缓1 天前
Android 性能优化常见工具与使用场景:从症状到证据的排查指南
android·性能优化
avi91111 天前
Unity 非后台,非自动构建,但统计报表 BuildReport ,构建自动化系统C#
android·webserver·ios打包·admin·c#后台·unity打包系统·unity自动构建
新鲜势力呀1 天前
PHP 定时任务系统实战:从 Cron 混乱执行到任务调度中心 + Redis队列 + 失败重试
android·java·redis