接手公司安卓项目之后,我最烦的一件事就是发版。每次发版都要重复同一套动作:用某加固平台客户端把打好的APK拖进去、等进度条走完、导出加固包、重新签名、再打开一个命令窗口对着渠道工具一顿操作,一个渠道一个渠道地生成。赶上渠道多的时候,一盯就是半小时起步。更别提换台构建机,加固客户端版本不一样、下载链接对不上,直接白给。
后来我把这套流程整个搬进了 Gradle,用几个自定义 Task 把“自动下载对应平台的加固资源 → 执行加固 → 重新签名 → 瓦力批量打渠道包”串成一条流水线,运行gradle buildAndPublish一个命令,剩下的全部自动完成。这篇文章就把这套流程的完整思路、核心代码和真实踩坑记录分享出来,给还在手动发版的同学一条可以直接抄的作业。
1. 手动流程的痛点和自动化改造的必要性
1.1 发版那天我都经历了什么
先说下手动打包的典型流程,你感受一下。
上午十点,开发说代码合完了,可以出包。我先在 Android Studio 里跑一遍assembleRelease,等它编译完。然后打开加固平台的客户端,这个客户端还分 Windows、macOS、Linux 三个版本,每台机器装的不一定一样。登录、导入签名信息、把 APK 拖进去、点加固,然后盯着进度条。加固完它会吐出一个新的 APK,但这个 APK 的签名已经被抹掉了,得重新用签名工具签一遍。签完名,如果只是发一个包还好,但要发几十个渠道包就麻烦了。我打开渠道工具,传入渠道列表文件,等它挨个写入。中途要是某个渠道写入失败,还得找到那个渠道包重新跑一遍。
整个过程里,真正耗时间的不是编译,而是人的盯守和重复操作。更难受的是,每个环节用的工具都依赖当前机器的操作系统。换一台 Linux 构建机,加固客户端要重新下载安装;换一台 Windows 机器,脚本路径分隔符可能就炸了。
1.2 自动化改造的目标
我们做自动化不是追求炫技,就三个目标。
第一,一条命令出所有渠道包。编译、加固、签名、写渠道、归档,全部由 Gradle 串起来,不需要人工在中途干预。
第二,构建机无关。无论在哪台机器上执行,脚本自动识别当前操作系统,下载对应平台的加固资源包,路径、权限、编码全部自动适配。
第三,可追溯、可重复。每次构建产出的渠道包统一归档到带时间戳的目录,日志里能看到每一步的输入输出和耗时,出了问题能倒查。
这套方案说到底就是给发版流程装了一条流水线:你只负责按下启动按钮,剩下的工序自动流转。它不是给“只有一个包、一年发一次版”的小项目准备的,而是给那种渠道多、发版频繁、经常要在一个下午内交付几十个渠道包的业务场景用的。
1.3 适合谁看、需要什么基础
如果你是安卓开发、构建负责人,或者要搭 CI 出包流程的运维同学,这篇文章适合你。你需要的基础知识不多:看得懂 Groovy 写的 Gradle 脚本、知道 APK 签名和渠道包的概念,就够了。底层原理我会用大白话讲清楚,保证你就算没写过 Gradle Task,也能把代码抄回去用起来。
2. 整体方案设计:为什么用 Gradle Task 而不是 Shell 脚本
2.1 选 Gradle Task 的理由
你可能会问:这套流程用 Shell 脚本写不就行了吗?为什么非要折腾 Gradle Task?
Shell 脚本确实能实现大部分功能,但它有几个绕不开的问题。第一,跨平台性差。同一段脚本在 Windows 的 Git Bash、macOS 的 zsh、Linux 的 bash 里,语法和行为都有差异,你还得处理路径分隔符、换行符、可执行权限这些细碎问题。第二,Shell 脚本和 Android 构建流程是割裂的。你要自己去找 APK 产物路径、自己监听构建时机,很容易和 Gradle 的 Task 执行顺序冲突。第三,团队协作时,Shell 脚本很难拿到 Gradle 项目里已有的配置信息,比如buildTypes、flavors、签名配置,你得重复维护一套。
Gradle Task 完全是另一个体验。它天然运行在 Gradle 的 Task 依赖图里,你可以用dependsOn声明一个 Task 依赖assembleRelease,Gradle 就保证构建完 APK 才执行你的加固逻辑。你可以直接读取项目里的android {}配置拿到产物目录,不需要硬编码路径。而且 Groovy 本质是 Java 生态,调用外部命令、操作文件、做网络请求都很顺手。
2.2 完整流水线长什么样
整个流水线我拆成了五个阶段,每个阶段对应一个或者一组 Task。
第一阶段,编译母包。依赖 Android Gradle Plugin 自带的assembleReleaseTask,产出未加固的 APK,我们叫它母包,因为之后所有渠道包都从这一个包衍生出来。
第二阶段,下载加固资源。执行一个自定义 Task,先判断操作系统,再从配置的下载地址拉取对应平台的加固客户端资源包,解压到build目录,并校验完整性。
第三阶段,执行加固。调用加固平台提供的命令行工具,对母包进行加固。这一步会输出加固后的 APK,同时抹掉原有签名。
第四阶段,重新签名。用 Android SDK 自带的apksigner,将加固后的 APK 重新签上正式签名,供后续渠道包使用。
第五阶段,写入渠道。用开源的渠道打包工具,以“批量写渠道”的方式,把渠道列表里的每一个渠道 ID 写入已签名 APK,产出最终的多渠道包。
写成 Task 依赖链就是:
assembleRelease -> downloadGuardTool -> doGuard -> reSignApk -> writeChannels -> archiveChannels这条链上的assembleRelease是 AGP 自带的,其他都是我们自定义的 Gradle Task。
2.3 必须先加固、再签名、最后写渠道
这个顺序是整个方案里最核心、也最容易搞反的地方,我必须单独拿出来讲。
先说你直觉上可能会选择的顺序:先打多个渠道包,再对每个渠道包逐个加固签名。这在技术上可行,但效率极低。因为加固工具每处理一个 APK 都要做一次全量重打包,几十个渠道包就要处理几十次,耗时成倍增长。
正确的策略是先打一个母包,加固、签名完,最后用写渠道的方式批量产出多个渠道包。市面上成熟的渠道打包工具,比如这里用的开源方案,都是往 APK 的 Signing Block 区块里写入一段渠道信息,不需要解压、不需要重新压缩、也不需要重新签名,所以写一个渠道包的耗时只有几十毫秒。一个母包瞬间就能复制出几十个渠道包。
这样做的另一个关键点是顺序绑定:如果先写渠道再加固,加固工具重打包时会把你写入的渠道信息原样抹掉;如果加固后不重新签名就写渠道,最终产出的包没有有效签名,装不上。所以顺序必须是“加固 → 重新签名 → 写渠道”,一个都不能乱。
2.4 多平台下载的设计思路
标题里的“多平台自动下载加固资源”,说白了就是解决一个问题:加固平台的客户端工具分 Windows 版、macOS 版、Linux 版,文件名和下载地址不一样,你不可能让每个开发都在自己机器上手动装好对应版本。
我的做法是在 Gradle 脚本里通过OperatingSystem.current()判断当前运行环境,再拼出对应的下载 URL 和文件名。资源包下载后统一解压到build/guardTools目录,不污染项目源码目录,也不依赖全局安装。
这里有一个经验:不要直接依赖加固平台官网的下载地址,因为官网链接可能随时调整、还可能存在防盗链。稳妥的做法是把对应版本的资源包先下载到你自己的内部文件服务器或者对象存储里,脚本里写内网固定地址。这样构建环境稳定可控,不受外网波动影响。
3. 核心实现:Gradle Task 逐段拆解
3.1 统一约定与变量定义
在写 Task 之前,我先把所有需要外部化的参数放到了gradle.properties里。不要把这些配置硬编码在 Task 代码里,否则换版本、换构建机时你就要改脚本。
# 加固平台客户端资源版本 GUARD_TOOL_VERSION=3.1.6 # 加固平台资源下载地址前缀,指向内网文件服务器 GUARD_DOWNLOAD_BASE_URL=http://build.internal/files/guard-tool # 渠道列表文件路径,默认读项目根目录下的 channels.txt CHANNEL_FILE=channels.txt # 最终产物归档目录 ARCHIVE_DIR=build/outputs/archive在build.gradle里定义全局配置对象,方便不同 Task 复用:
ext { guardToolVersion = project.hasProperty('GUARD_TOOL_VERSION') ? project.GUARD_TOOL_VERSION : '3.1.6' guardDownloadBaseUrl = project.hasProperty('GUARD_DOWNLOAD_BASE_URL') ? project.GUARD_DOWNLOAD_BASE_URL : '' channelFile = rootProject.file(project.hasProperty('CHANNEL_FILE') ? project.CHANNEL_FILE : 'channels.txt') archiveDir = file(project.hasProperty('ARCHIVE_DIR') ? project.ARCHIVE_DIR : 'build/outputs/archive') // 统一放到 build 目录下,避免污染 src buildToolsDir = file("${buildDir}/tools") guardToolDir = file("${buildToolsDir}/guard/${guardToolVersion}") }把这些参数统一管理有几个好处:CI 上构建时可以直接用-PGUARD_TOOL_VERSION=3.2.0覆盖版本;运营临时加了渠道,不需要改代码,只要更新channels.txt;写轮子的人一眼就知道哪些地方是要按项目实际情况改的。
3.2 自动下载加固资源 Task
下载这块我一开始用的是第三方下载插件,后来发现为了一个下载功能引入插件没必要。直接用 JDK 自带的HttpURLConnection就够了,代码简单、无额外依赖,还能自己控制超时和校验逻辑。这里要注意:网络请求一定要放在doLast里执行,不能放在 Task 配置阶段,否则每次运行 Gradle 都会触发一次无意义的网络请求,还容易让配置缓存失效。
tasks.register('downloadGuardTool') { doLast { def os = org.gradle.internal.os.OperatingSystem.current() def platformName = '' def fileSuffix = '' if (os.isWindows()) { platformName = 'windows' fileSuffix = 'zip' } else if (os.isMacOsX()) { platformName = 'macos' fileSuffix = 'zip' } else if (os.isLinux()) { platformName = 'linux' fileSuffix = 'tar.gz' } else { throw new GradleException("Unsupported operating system: ${os.name}") } def downloadUrl = "${guardDownloadBaseUrl}/guard-${guardToolVersion}-${platformName}.${fileSuffix}" def targetFile = file("${buildToolsDir}/guard-${guardToolVersion}-${platformName}.${fileSuffix}") def expectedMd5 = project.hasProperty('GUARD_TOOL_MD5') ? project.GUARD_TOOL_MD5 : '' // 如果目标文件已存在且校验通过,跳过下载,实现增量复用 if (targetFile.exists()) { if (expectedMd5.isEmpty() || md5(targetFile) == expectedMd5) { println "Guard tool already exists, skip download: ${targetFile}" return } } println "Downloading guard tool from ${downloadUrl}" buildToolsDir.mkdirs() def connection = new URL(downloadUrl).openConnection() as HttpURLConnection connection.connectTimeout = 15000 connection.readTimeout = 60000 connection.setRequestProperty('User-Agent', 'Gradle-Build-Script') if (connection.responseCode != 200) { throw new GradleException("Download failed, HTTP ${connection.responseCode}: ${downloadUrl}") } targetFile.withOutputStream { out -> out << connection.inputStream } connection.disconnect() if (!expectedMd5.isEmpty() && md5(targetFile) != expectedMd5) { throw new GradleException("MD5 mismatch after download, file corrupted: ${targetFile}") } // 按平台解压 copy { from platformName == 'windows' || platformName == 'macos' ? zipTree(targetFile) : tarTree(targetFile) into guardToolDir } // Linux/macOS 下的可执行脚本需要加执行权限 if (!os.isWindows()) { fileTree(guardToolDir).each { f -> if (f.name.endsWith('.sh') || f.name.endsWith('.jar') == false) { f.setExecutable(true, false) } } } } }代码里我加了一个 MD5 校验。下载链接是内网固定地址,理论上不存在被劫持的风险,但下载中断、文件不完整是实际发生过的,校验机制能避免你拿着半个包去加固然后报一堆莫名其妙错误。
再补一个计算 MD5 的辅助函数,放在构建脚本底部:
def md5(File file) { def digest = java.security.MessageDigest.getInstance('MD5') file.withInputStream { is -> byte[] buffer = new byte[8192] int len while ((len = is.read(buffer)) != -1) { digest.update(buffer, 0, len) } } new BigInteger(1, digest.digest()).toString(16).padLeft(32, '0') }3.3 执行加固 Task
加固平台的命令工具拿到手后,核心就是三步:登录账号、导入签名信息、执行加固命令。不同版本的加固工具命令参数略有差异,但都离不开这三个动作。
我为加固平台封装了一个单独的 Task,依赖assembleRelease和downloadGuardTool,确保在执行前母包已经存在、加固工具已经就位。
tasks.register('doGuard') { dependsOn 'assembleRelease', 'downloadGuardTool' doLast { def releaseApk = file("${buildDir}/outputs/apk/release/app-release.apk") if (!releaseApk.exists()) { throw new GradleException("Release APK not found: ${releaseApk}") } def guardJar = file("${guardToolDir}/jiagu.jar") if (!guardJar.exists()) { throw new GradleException("Guard jar not found: ${guardJar}") } // 加固平台登录账号,从环境变量读取,避免写到构建脚本里 def guardAccount = System.getenv('GUARD_ACCOUNT') def guardPassword = System.getenv('GUARD_PASSWORD') if (!guardAccount || !guardPassword) { throw new GradleException("GUARD_ACCOUNT / GUARD_PASSWORD env not set") } // 第一步:登录 exec { commandLine 'java', '-jar', guardJar.absolutePath, '-login', guardAccount, guardPassword, '-sso', '1' } // 第二步:导入签名信息,让加固工具在加固时自动帮我们重新签名 // 这里必须用正式签名,否则渠道包装上后会闪退 exec { commandLine 'java', '-jar', guardJar.absolutePath, '-importsign', '-keystore', signingConfig.storeFile.absolutePath, '-storepass', signingConfig.storePassword, '-keypass', signingConfig.keyPassword, '-alias', signingConfig.keyAlias } // 第三步:执行加固,输出到 build/outputs/guard 目录 def guardOutputDir = file("${buildDir}/outputs/guard") guardOutputDir.mkdirs() exec { commandLine 'java', '-jar', guardJar.absolutePath, '-jiagu', releaseApk.absolutePath, guardOutputDir.absolutePath, '-autosign' } } }这里要说明一个细节:-autosign参数表示加固完成后自动用你导入的签名信息重新签名。如果你的加固工具版本不支持自动签名,那后续的重新签名 Task 就是必须的兜底方案。我在实际项目中两个都留着,自动签名成功就直接用,失败也能手动补签,双保险。
还有一个坑:加固平台的账号密码写在环境变量里。很多人喜欢直接写死在构建脚本里,一旦仓库权限泄露,签名信息跟着一起泄露,后果很严重。建议所有人(包括 CI 机器)都从环境变量或者 CI 的 Secret 配置里读取,别偷懒。
3.4 加固后自动重签名 Task
如果你的加固工具支持-autosign,这个 Task 可以跳过。但我这里仍然单独实现了它,主要考虑到两种场景:一是某些加固版本默认关闭自动签名;二是加固工具签出来的包在部分 Android 版本上安装失败,用官方apksigner重新签一遍可以规避很多兼容性问题。
tasks.register('reSignApk') { dependsOn 'doGuard' doLast { def guardOutputDir = file("${buildDir}/outputs/guard") def unsignedApk = file("${guardOutputDir}/app-release_guard.apk") if (!unsignedApk.exists()) { throw new GradleException("Guard APK not found: ${unsignedApk}") } def apksigner = file("${android.sdkDirectory}/build-tools/${android.buildToolsVersion}/apksigner") if (!apksigner.exists()) { throw new GradleException("apksigner not found: ${apksigner}") } def signedApk = file("${guardOutputDir}/app-release_guard_signed.apk") def signatureFile = signingConfig.storeFile exec { commandLine apksigner.absolutePath, 'sign', '--ks', signatureFile.absolutePath, '--ks-pass', "pass:${signingConfig.storePassword}", '--ks-key-alias', signingConfig.keyAlias, '--key-pass', "pass:${signingConfig.keyPassword}", '--v1-signing-enabled', 'true', '--v2-signing-enabled', 'true', '--out', signedApk.absolutePath, unsignedApk.absolutePath } } }特别注意参数里--v1-signing-enabled true --v2-signing-enabled true。为什么两个都要开?因为 Android 7.0 以下只认 V1 签名,Android 7.0 及以上用 V2 签名;如果你只签 V2,低版本手机会提示无法安装;如果只签 V1,虽然也能装,但后面写渠道工具可能检测不到 V2 签名块。两个都上是最稳的。
3.5 瓦力渠道包 Task
渠道包这一步我用的是开源的渠道打包工具,它会把渠道信息写到 APK 的 Signing Block 区块里。因为不是解压重打包,所以速度快、APK 体积不会膨胀。
先构建渠道工具的命令参数。我写了一个集合了“批量写渠道”功能的 Task:
tasks.register('writeChannels') { dependsOn 'reSignApk' doLast { def signedApk = file("${buildDir}/outputs/guard/app-release_guard_signed.apk") if (!signedApk.exists()) { throw new GradleException("Signed APK not found: ${signedApk}") } def channelList = [] if (!channelFile.exists()) { throw new GradleException("Channel file not found: ${channelFile}") } channelFile.eachLine { line, index -> def ch = line.trim() if (ch && !ch.startsWith('#')) { channelList << ch } } if (channelList.isEmpty()) { throw new GradleException("No channel config in ${channelFile}") } def channelOutputDir = file("${buildDir}/outputs/channels") channelOutputDir.mkdirs() // 用批处理模式,一次性写入全部渠道 exec { commandLine 'java', '-jar', channelToolJar.absolutePath, 'batch', '-f', channelFile.absolutePath, signedApk.absolutePath, channelOutputDir.absolutePath } // 重命名产物,统一命名规则:app-${渠道}-release.apk channelList.each { ch -> def rawFile = file("${channelOutputDir}/app-release_guard_signed_${ch}.apk") def destFile = file("${channelOutputDir}/app-${ch}-release.apk") if (rawFile.exists()) { rawFile.renameTo(destFile) } } } }渠道工具是通过exec调用外部 Java 命令,所以 JDK 环境必须有。这里我踩过一个坑:Gradle 脚本本身跑在 Gradle 的 JVM 里,但commandLine 'java'调用的是系统 PATH 里的 Java,两者版本可能不一样。如果发现“Java 版本不一致导致工具启动失败”,优先检查 JAVA_HOME,在脚本开头显式指定一下。
3.6 将多个 Task 串成一条完整流水线
有了上面几个 Task,最后再提供一个总入口 Task,用dependsOn串起来,并且把产物统一归档。
tasks.register('buildAndPublish') { dependsOn 'assembleRelease', 'doGuard', 'reSignApk', 'writeChannels', 'archiveChannels' group = 'publish' description = '一键完成:构建 -> 加固 -> 重签名 -> 渠道包 -> 归档' }归档 Task 的实现很简单,就是把build/outputs/channels下所有 APK 复制到带时间戳的目录,顺便生成一个校验文件,方便日后追溯。
tasks.register('archiveChannels') { dependsOn 'writeChannels' doLast { def timestamp = new Date().format('yyyyMMdd_HHmmss') def destDir = file("${archiveDir}/${timestamp}") destDir.mkdirs() fileTree("${buildDir}/outputs/channels").matching { include '*.apk' }.each { apk -> copy { from apk into destDir } } // 生成 MD5 校验清单 def md5File = file("${destDir}/md5sums.txt") md5File.withWriter { writer -> fileTree(destDir).matching { include '*.apk' }.each { apk -> writer.println("${md5(apk)} ${apk.name}") } } println "Archive done: ${destDir}" } }到这里,一条完整的自动化打包流水线就算是搭起来了。在终端里输入:
gradle buildAndPublish之后就是泡杯茶等结果,中间不需要任何手动干预。
4. 参数选择与实操实录
4.1 完整构建输出长什么样
我拿一个实际项目的构建过程举例。假设渠道文件channels.txt内容如下:
# 渠道列表,支持 # 注释 huawei xiaomi oppo vivo qq执行gradle buildAndPublish --info,日志里能看到流水线按依赖顺序依次执行。我摘录一段关键输出:
> Task :app:assembleRelease > Task :app:downloadGuardTool Guard tool already exists, skip download: /home/jenkins/build/app/build/tools/guard-3.1.6-linux.tar.gz > Task :app:doGuard > Task :app:reSignApk > Task :app:writeChannels > Task :app:archiveChannels Archive done: /home/jenkins/build/app/build/outputs/archive/20250611_153200执行完后,最终产物目录是这样:
build/outputs/archive/20250611_153200/ ├── app-huawei-release.apk ├── app-xiaomi-release.apk ├── app-oppo-release.apk ├── app-vivo-release.apk ├── app-qq-release.apk └── md5sums.txt整个过程的时间分布大致是:编译 2 分钟、加固 40 秒、签名 5 秒、写渠道 1 秒。60 个渠道包的话,从手动 30 分钟压缩到 5 分钟以内,这是真实体验下来的数据。
4.2 签名信息与渠道列表管理
签名信息是整个自动化流程里最重要的敏感数据。我的管理原则是:签名文件放本地加密盘或 CI 的机密存储里,密码一律走环境变量。
在build.gradle里通过环境变量读取签名配置,而不是写在build.gradle里:
android { signingConfigs { release { storeFile file(System.getenv('RELEASE_STORE_FILE') ?: 'keystore/release.jks') storePassword System.getenv('RELEASE_STORE_PASSWORD') keyAlias System.getenv('RELEASE_KEY_ALIAS') keyPassword System.getenv('RELEASE_KEY_PASSWORD') } } buildTypes { release { signingConfig signingConfigs.release } } }渠道列表文件channels.txt放在项目根目录,支持#注释,方便和运营同学沟通。日常新增渠道时,只需要在这个文件里加一行,重新跑一次构建脚本,不需要碰任何 Java/Groovy 代码。
4.3 接入 CI 与多构建机注意点
这套脚本在本地跑没问题,但要接入 Jenkins 或 GitLab CI,有几个细节必须先处理好。
第一,CI 构建机如果是 Linux 系统,加固工具里的可执行脚本必须具备执行权限。我在downloadGuardToolTask 里已经加了setExecutable(true),但前提是构建用户对目标目录有写权限。建议把build目录设置为 CI 的临时工作空间,避免旧包残留导致产物混乱。
第二,CI 上不建议每次都重新下载加固资源。在 Task 里我已经做了“文件存在且 MD5 校验通过就跳过下载”的判断,这样同一台构建机第二次构建就秒过了。
第三,CI 的环境变量管理。加固平台的账号密码、签名密码,一定要配置在 CI 的 Secret 变量里,构建脚本里只读取环境变量,不硬编码。哪怕仓库代码泄露,敏感信息也不会跟着暴露。
第四,构建机的 JDK 版本要统一。这里踩过一个真实的坑:本地是 JDK 8,CI 构建机是 JDK 11,加固工具默认用系统 Java 启动,结果在 JDK 11 上报了反射权限的错。解决方法是把 JDK 固定到同一个大版本,并且在脚本里显式指定JAVA_HOME。
5. 踩坑记录与排查手册
5.1 加固后 APK 安装提示“未安装应用”
这个问题的表现是:加固流程走完,生成的 APK 在手机上直接安装失败,提示“应用未安装”或“解析包时出现问题”。
原因绝大多数是加固工具加固后把原 APK 的签名删掉了,但没有自动重新签名,或者自动签名使用的还是原来的 debug 签名。我见过有人在doGuard里忘了配置-autosign,加固完直接就拿去写渠道,写出来的包没有一个能装。
排查思路很简单:用 SDK 自带的apksigner verify命令检查 APK 的签名状态。
apksigner verify --print-certs app-release.apk如果输出里没有任何证书信息,说明这个 APK 是无签名的,需要走reSignApkTask 重新签。如果证书信息存在但安装还失败,再检查一下 V1、V2 签名是否都在:
apksigner verify --verbose app-release.apk看到Verified using v1 scheme: true和Verified using v2 scheme: true才算正常。缺哪个就在apksigner sign命令里补哪个参数。
5.2 渠道包能装上但渠道号读不出来
渠道号读不出来的场景分两种:一种是 App 内通过代码读取渠道信息,读出来是空;另一种是渠道工具写完后,用工具自带的校验命令检查不到。
先说第一种。市面上常用的开源渠道工具,读取渠道信息依赖的是 APK 的 V2 签名块。如果你的 APK 只有 V1 签名,渠道工具写入的那些数据在部分系统上可能读不到。解决方案就是我前面强调过的:重新签名时把 V1 和 V2 都开起来。
第二种是写入时机错了。渠道信息如果先于加固写入,加固工具重打包时会把渠道信息抹掉,最终包里有渠道工具的文件结构,但数据是空的。务必按照“加固 → 签名 → 写渠道”的顺序执行。
我自己还加了一个保险手段:在writeChannels执行完后,顺手调用渠道工具的校验命令对每个产出包做一次渠道号回读,一旦发现读不到渠道号,立刻让构建脚本抛异常。自动失败总比带着坏包上线好。
5.3 Linux 构建机上加固工具没有执行权限
第一次在 Linux CI 机器上跑这套脚本,加固工具报了一堆Permission denied。原因很简单:从 zip 包解压出来的 shell 脚本默认没有执行权限,Gradle 的exec直接执行当然会失败。
downloadGuardToolTask 里那段setExecutable(true, false)就是解决这个问题的。注意第二参数false表示只给当前用户加执行权限、不影响其他用户,安全性更好。macOS 也有同样的问题,所以我在if (!os.isWindows())分支里统一处理了。
如果你的加固工具是用java -jar启动的 jar 包,那不存在执行权限问题,但要确认系统里装了对应版本的 JDK/JRE。Linux CI 机器经常是最小化安装,连 Java 都没有,别等到跑构建才发现。
5.4 下载加固资源时报 403 Forbidden
内网文件服务器如果开了防盗链校验,或者要求特定的 User-Agent,直接用HttpURLConnection下载容易吃 403。
我在下载代码里加了一行connection.setRequestProperty('User-Agent', 'Gradle-Build-Script'),一般能绕过大部分基础校验。如果还不行,检查一下下载 URL 是否需要 token 参数,或者在服务器的访问白名单里加上构建机 IP。
另外强烈建议把加固资源放到内网文件服务器,而不是直接依赖外网官网链接。官网改个版本、换个路径,你的构建就直接失败,而且在外网拉取容易受网络波动影响。内网下载基本秒开,还能在下载层做 MD5 校验。
5.5 Gradle 配置缓存导致 Task 不执行
这个坑比较隐蔽。我一开始把下载加固资源的逻辑写在了 Task 的配置阶段,也就是直接写在tasks.register(...)的花括号外面,而不是doLast里面。结果启用了 Gradle 配置缓存之后,配置阶段的代码只在第一次构建时执行,后续构建直接跳过下载逻辑,导致加固工具不存在、构建失败。
把耗时操作全部挪到doLast或者doFirst里,是 Gradle Task 的标准做法。配置阶段只负责声明 Task 之间的关系和参数,执行阶段才做实际的文件操作、网络请求和命令执行。这也是我在写的所有 Task 里都严格遵守的纪律。
5.6 Windows 下路径分隔符和编码问题
Windows 构建机上,路径分隔符用反斜杠\,在 Groovy 字符串里如果不转义,分分钟给你拼出奇怪的路径。我在脚本里统一使用 Gradle 的file()方法操作文件,它在底层已经处理了系统分隔符的差异,不要自己手动拼路径字符串。
另一个是编码问题。渠道列表文件如果包含中文渠道名,读取时要用 UTF-8。我见过因为 Windows 默认 GBK 编码导致渠道名乱码、最终写出了一堆乱码文件名的情况。解决方法是让运营同学统一保存 UTF-8 编码,或者在读取文件时显式指定:
channelFile.getText('UTF-8').split('\n')...最后再分享一个实用技巧
整套流程跑顺之后,日常发版基本可以无脑执行了。我个人的习惯是维护两个独立的 Task:一个叫buildAndPublish,走完整流水线,适合正式发版;另一个叫buildAndPublishDebugGuard,跳过加固和签名,直接走渠道写入,适合给测试同学出快速验证包。这两个 Task 共用下载资源和渠道写入逻辑,只是依赖链不同,代码成本很低但日常用起来特别方便。
后来我还把渠道列表抽成了可以从命令行传入的参数,运营要临时加渠道时,直接执行:
gradle buildAndPublish -PCHANNEL_FILE=channels_extra.txt不用改任何代码、不用碰渠道配置文件,加渠道这件事从“开发配合”变成了“运营自助”。自动化流程这东西,最舒服的状态就是你把它调教好了之后,它安安静静地帮你干活,而你可以把时间花在真正值得花的地方。