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 反向代理、备份、监控与固定镜像版本;不要长期依赖浮动标签自动升级。
首次登录后完成三件事:
- 创建 Android 项目,例如 Project Key 为
robot_android_app; - 创建只用于该项目分析的 Token,不使用管理员账号 Token;
- 定义 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 条件保护凭据。
这条流水线有几个关键点:
- 质量门禁在签名之前。 SonarQube 不通过就不会读取发布凭据,也不会生成发布包。
- 只有
main可以发布。 PR 构建不会接触签名文件和 Google Play 服务账号。 - 凭据只在最小作用域内绑定。 不把密钥复制进仓库,也不在 Groovy 双引号中拼接敏感值。
- 扫描前已完成编译和测试。
sonar只消费已有结果,流水线行为更明确。 - 失败也收集报告。 即使 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 时间会直接影响开发反馈速度。优化时优先保证可重复性,再追求速度:
- 固定工具链。 固定 JDK、SDK、AGP、Gradle Wrapper 和 NDK,避免构建节点漂移。
- 合理使用缓存。 启用 Gradle Build Cache 与依赖缓存,缓存键应包含 Wrapper、依赖锁和关键构建配置。
- 减少无意义的
clean。 干净的临时 Agent 本身就是新工作区;长期 Agent 才需要结合实际问题决定是否清理。 - 并行但不要重复工作。 Lint、测试可并行;Sonar 分析必须等覆盖率文件准备好。
- 区分 PR 与发布流水线。 PR 快速反馈,主分支再执行签名、完整测试和发布。
- 多模块聚合覆盖率。 每个模块生成 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 则让"代码质量"从评审中的主观印象,变成持续、可执行的合并条件。
参考资料
- Jenkins Pipeline 官方文档
- Jenkins:Using a Jenkinsfile
- SonarQube Server:Jenkins Extension
- SonarScanner for Gradle
- Gradle Plugin Portal:SonarQube Plugin
- SonarQube:Quality Gates
- Android Developers:Configure your build
- Android Developers:Lint
- Gradle:JaCoCo Plugin
- fastlane:upload_to_play_store
- Google Play Console:Set up an open, closed, or internal test