做Android发布最烦的是什么?不是写代码,是打包。一个签好名的APK,先丢去360加固服务等它处理完,再把加固包下回来,接着用Walle批量生成几十个渠道包,最后逐个验证签名、渠道信息、能不能装。这套操作但凡手动碰过的都懂那个痛——尤其当你同时还维护着好几个项目,或者要在Windows开发机、Linux构建机上反复切换,环境差异稍微大一点,流程直接卡住。
所以我把整个流程塞进了Gradle Task:打原始签名包、多平台自动下载360加固资源、执行加固、重新签名、再用Walle生成渠道包,一条命令跑到底。今天这篇就是这套自动打包流程的完整复盘,覆盖原理、完整配置、关键Task的实现代码,以及我实际踩过的坑。无论你是刚接触自动打包的新手,还是想进一步压榨构建流程的老手,都能在里面找到可以直接抄作业的东西。
1. 为什么要做这套自动打包流程
1.1 手动流程的痛点
先还原一下手动打包的一天是什么样子。早上拿到Release包,先确认代码分支、版本号、签名配置没问题,然后执行一次完整构建,等到一个干净的签名APK产物。接着打开360加固的客户端或者网页,登录账号,把APK传上去,填加固选项,等待云端出包。这个过程少则几分钟,多则十几分钟,如果排队人多还要更久。好不容易等到加固完成,下载加固后的APK,结果发现直接装不上——因为加固会把原始签名去掉,必须先重新签名。
重签之后你以为完事了?还早。拿到一个签名正确的加固包,接下来要用Walle生成各个应用市场的渠道包。如果手动操作,是复制一份APK、写入一个渠道ID、再复制一份、再写一个,几十个渠道下来,体力活倒是其次,最怕中间哪个渠道写错、哪个包漏生成,事后还得逐个验证。等所有包都出来了,还得抽查签名是否一致、渠道信息能不能正常读到。整套流程一个人守着机器,快的话40分钟,运气不好遇到加固排队或者哪个参数配错,一个上午就没了。
多平台环境下更难受。开发者在Windows上跑通的命令行,到Linux CI服务器上路径不对、工具链缺失、加密算法库不一样,处处是坑。加固工具在不同操作系统下又是不同的发行包,手动找下载链接再传上去,跟瞎子摸象一样。
1.2 自动化方案的设计目标
我给自己定了几条硬性目标,这些目标决定了后面的技术选型。
第一条,全程免人工介入。从构建原始APK到输出全渠道包,中间不允许有任何一步需要人在终端前盯着。做不到这点就不叫自动化,只是把手工步骤搬了个地方。
第二条,必须兼容多平台。开发机上通常是Windows,CI上大概率是Linux,偶尔还有人在macOS上跑,三个平台的加固工具、SDK路径、可执行文件后缀全都不一样,脚本里必须自动识别。
第三条,加固和渠道包的生命周期要可控。构建可以随时重跑,产物要干净,不能出现上一次构建的残留包混进本次输出目录。每个任务都应该是可独立执行的,某一环节出问题能单独重跑,不用从头再来。
第四条,也是我特别看重的:每一步都要有清晰的日志和校验。下载资源要检查哈希,加固产物要检查APK完整性,重签后要用apksigner验证签名,渠道包生成后要回读渠道ID。自动化最怕黑盒,一旦哪一步静默失败,线上包出问题才追查就晚了。
1.3 整条流水线的流程设计
整条流水线的依赖关系,用文字串起来就是下面这个样子:
assembleRelease(原始签名APK) ↓ downloadJiaguTools(多平台下载加固工具与资源) ↓ jiaguReleaseApk(执行加固,生成加固APK) ↓ reSignJiaguApk(zipalign + apksigner v2重签) ↓ generateChannelApks(Walle核心库写入渠道ID,批量产包) ↓ verifyOutputApks(自动化验证签名与渠道信息)如果你了解Gradle,会发现这套依赖链天然适合用Task组织。每个Task对应一个独立步骤,再通过dependsOn声明依赖关系,Gradle会按顺序执行并自动跳过那些输入没有变化的任务。我后续会把每个Task的完整写法拆开讲,这里先记住这条主线:打原始包、下载加固资源、加固、重签、渠道包、验证。
你可能会问,为什么不直接在Android的assembleRelease之后挂一个doLast把后面的步骤全写进去?原因有两个。第一,耦合太严重,某一环节失败无法单独重跑;第二,Gradle配置阶段和执行阶段要分清,真正的耗时操作都应该放在执行阶段,否则配置一次卡一次。后面所有Task我都严格遵循这个原则。
2. 先弄清楚原理再动手:加固、签名和瓦力
2.1 360加固资源到底在“下载”什么
标题里提到的“多平台自动下载360加固资源”,我解释一下这句背后的实际含义。你有没有想过,加固工具本身并不是一个跨平台的可执行文件,360加固在Windows下有对应发行版,Linux和macOS下也有对应发行版,即便都是Java调用,底层依赖的加密库、动态库依然分平台。所以“自动下载加固资源”的第一层意思,就是根据当前操作系统自动下载对应平台的加固工具包,并解压到本地构建目录。
第二层意思更重要。360加固的官方服务端通常不会提供类似GET /download?apk=xxx这样干净的API给你在Gradle里直接调用,更多时候是通过一个本地命令行工具或Java接口发起加固请求,由云端或本地引擎完成加固后,再把产物拿回来。这个过程里涉及账号登录态、签名信息导入、加固参数传递,如果你手动操作就是打开客户端点来点去,自动化之后就是脚本替你做这些事。所以“下载资源”不是简单下载一个APK,而是把工具链、依赖库、还有加固产物的拉取动作都封装起来。
最后还要多说一句,加固工具的下载地址不是一成不变的,工具版本更新后URL可能整个变掉。我在工程里把工具版本、下载根地址、平台后缀全部抽到了gradle.properties,这样升级工具时只改一处,不需要翻Task代码。
2.2 Walle为什么依赖V2签名
Walle的原理一句话就能讲清:它利用APK V2签名方案里的APK Signing Block区块来存储渠道ID和额外信息。V2签名会把签名信息和摘要写在这个专属区块里,Walle往这个区块里塞渠道数据,不会破坏APK本身的签名有效性,所以渠道包之间只是字节级别的微小差异,生成速度快得惊人。
这也就解释了为什么“加固后重新签名”这一步如此关键。360加固会处理DEX的加密和壳逻辑,加固完成后APK的签名信息基本失效,必须重新签名。如果你重签时只用V1签名方案,那Walle就找不到V2的APK Signing Block,渠道信息根本写不进去,运行时也读不出来。正确做法是用apksigner同时开启V1和V2签名,或者至少在确认不需要兼容Android 7.0以下的情况下只开V2。
再补充一个细节:执行顺序必须是先zipalign对齐,再签名。V1签名要求APK内文件按4字节对齐,V2签名虽然不看这个,但很多市场审核和兼容性检测依赖对齐状态,顺序反了要么签名报错,要么包体性能受影响。所以我的重签Task里永远是先zipalign -f -p 4,再apksigner sign。
2.3 Gradle Task为什么适合当调度中心
你当然可以把这些步骤写成一个Shell脚本或Python脚本,但Gradle Task有几个特有的优势。
第一,Gradle本身就在Android工程的最前线,它天然知道你什么时候构建完Release包,也知道SDK路径、Build Tools版本、签名配置这些信息,不需要在脚本里手工硬编码一堆路径。第二,Task支持dependsOn、finalizedBy、输入输出缓存,重跑的时候能自动跳过没有变化的步骤,省不少时间。第三,Gradle Plugin DSL让每一步的配置项都变成代码,所有参数都在工程目录里,版本管理、同事协作、CI拉取都方便,不用在CI脚本的某个隐藏角落找线索。
当然,Task编排也不是没有坑。最大的坑是有些人习惯把耗时的操作写在Task配置块里,而不是doLast里,结果每次执行任何Task都跑一次下载或构建,体验极差。记住:配置阶段只做声明和参数校验,真正干活全部放doLast或者用@TaskAction标注的方法里。
3. 核心实现:Gradle Task 一步一步搭出打包流水线
3.1 项目基本配置与渠道表准备
开始写Task之前,先把工程基础准备好。我的做法是在根目录gradle.properties里集中存放所有可变参数,这样后续改版本、换账号都不动代码。
# 加固工具相关 JIAGU_TOOL_VERSION=4.3.0 JIAGU_DOWNLOAD_BASE_URL=https://mirror.example.com/jiagu JIAGU_ACCOUNT=build_account JIAGU_PASSWORD=${env.JIAGU_PASS} # 签名信息,实际使用时建议从环境变量读取 RELEASE_STORE_FILE=keystore/release.keystore RELEASE_STORE_PASSWORD=${env.STORE_PASS} RELEASE_KEY_ALIAS=myapp RELEASE_KEY_PASSWORD=${env.KEY_PASS} # 渠道表文件 CHANNEL_FILE=channels.txt渠道表channels.txt格式很简单,一行一个渠道ID,我额外支持了#注释和空行,方便维护:
# 主流应用市场 xiaomi huawei oppo vivo # 其他渠道 yingyongbao kengdie baidu这里有一个经验:渠道ID不要用中文也不要带特殊符号,Walle写入没问题,但部分市场后台和统计系统对渠道ID的字符集有限制,纯小写英文加下划线是最稳妥的。另外渠道表最好放仓库里走版本管理,每次发版新增渠道时走MR流程,有迹可循。
3.2 多平台自动下载加固资源的Task写法
这个Task是整个流程里“多平台”三个字体现得最充分的地方。核心逻辑是先判断当前操作系统,拼出对应的下载地址,再执行下载、校验、解压。
def getPlatformKey() { def os = System.getProperty("os.name").toLowerCase() if (os.contains("windows")) { return "windows" } else if (os.contains("mac")) { return "darwin" } else { return "linux" } } tasks.register("downloadJiaguTools") { def toolVersion = project.property("JIAGU_TOOL_VERSION") def baseUrl = project.property("JIAGU_DOWNLOAD_BASE_URL") def platform = getPlatformKey() def outputDir = file("${buildDir}/jiagu/tools") inputs.property("toolVersion", toolVersion) inputs.property("platform", platform) outputs.dir(outputDir) doLast { outputDir.mkdirs() def (ext, unzipCmd) = platform == "windows" ? ["zip", "unzip"] : ["tar.gz", "tar"] def fileName = "jiagu-${toolVersion}-${platform}.${ext}" def destFile = new File(outputDir, fileName) // 已存在且校验过就跳过重复下载 if (destFile.exists()) { println "[jiagu] tool already exists: ${destFile.name}" } else { def url = new URL("${baseUrl}/${toolVersion}/${fileName}") println "[jiagu] downloading from ${url}" url.withInputStream { input -> destFile.withOutputStream { output -> output << input } } // 强烈建议这里校验SHA-256,避免工具包被污染 } // 解压到tool-home目录 def toolHome = new File(outputDir, "tool-home") if (!toolHome.exists()) { if (platform == "windows") { project.copy { from zipTree(destFile) into toolHome } } else { exec { commandLine "tar", "-xzf", destFile.absolutePath, "-C", toolHome.absolutePath } } } println "[jiagu] tools ready at ${toolHome.absolutePath}" } }你可能会注意到我用了inputs.property和outputs.dir,这等于告诉Gradle:只要版本、平台、输出目录没变,这个Task就是UP-TO-DATE的,下次执行直接跳过。这对CI上频繁构建的场景能省下每次重新下载工具的时间。
文件校验这一条我单拎出来强调。下载工具包不校验哈希等于没设防,工具包本身就是要对APK做加壳处理的,如果下载链路被劫持,后果远比打出来的包有问题更严重。我当时是在下载后用MessageDigest算SHA-256跟预期值比对,不一致直接抛异常终止构建,宁可不打包也不能出一个不可信的包。
3.3 执行加固并重新签名的关键Task
下载完工具,接下来就是调用加固命令行工具。这里我直接以java -jar jiagu.jar为例,因为它天然跨平台,不需要针对Windows和Linux各写一套。实际命令格式因工具版本而异,重点是结构:登录、导入签名信息、执行加固、指定输出目录。
tasks.register("jiaguReleaseApk") { dependsOn "assembleRelease" def inputApk = file("${buildDir}/outputs/apk/release/myapp-release.apk") def toolHome = file("${buildDir}/jiagu/tools/tool-home") def workDir = file("${buildDir}/jiagu/work") def outApk = file("${workDir}/myapp-jiagu-release.apk") inputs.file(inputApk) outputs.file(outApk) doLast { workDir.mkdirs() // 登录加固工具 exec { workingDir toolHome commandLine "java", "-jar", "jiagu.jar", "-login", project.property("JIAGU_ACCOUNT"), System.getenv("JIAGU_PASS") } // 本地执行加固 exec { workingDir toolHome commandLine "java", "-jar", "jiagu.jar", "-jiagu", inputApk.absolutePath, "-out", workDir.absolutePath, "-x86" } // 加固产物可能存在同名字段,做一层防御性检查 if (!outApk.exists()) { throw new GradleException("jiagu output not found: ${outApk}") } println "[jiagu] done: ${outApk.name}" } }加固之后就是重签,这步我单独拆成一个Task,因为它的输入是加固产物的文件名和签名配置,输出是重签后的APK,拆开之后万一加固成功但签名失败,可以只重跑这一个环节。
tasks.register("reSignJiaguApk") { dependsOn "jiaguReleaseApk" def inputApk = file("${buildDir}/jiagu/work/myapp-jiagu-release.apk") def alignedApk = file("${buildDir}/jiagu/work/aligned-release.apk") def signedApk = file("${buildDir}/jiagu/output/myapp-release-v2signed.apk") def sdkDir = android.sdkDirectory def buildTools = android.buildToolsVersion inputs.file(inputApk) outputs.file(signedApk) doLast { signedApk.parentFile.mkdirs() // zipalign 必须在签名之前 exec { commandLine "${sdkDir}/build-tools/${buildTools}/zipalign", "-f", "-p", "4", inputApk.absolutePath, alignedApk.absolutePath } // apksigner 同时开启 V1 和 V2,V2 是 Walle 的硬依赖 exec { commandLine "${sdkDir}/build-tools/${buildTools}/apksigner", "sign", "--ks", file("keystore/release.keystore").absolutePath, "--ks-key-alias", "myapp", "--ks-pass", "pass:${System.getenv('STORE_PASS')}", "--v1-signing-enabled", "true", "--v2-signing-enabled", "true", "--out", signedApk.absolutePath, alignedApk.absolutePath } // 签名验证,失败立刻中止 exec { commandLine "${sdkDir}/build-tools/${buildTools}/apksigner", "verify", "--print-certs", signedApk.absolutePath } println "[resign] done: ${signedApk.name}" } }重签这个Task里最容易翻车的细节是Windows环境。你在Windows上执行时,zipalign和apksigner可能是zipalign.exe和apksigner.bat,直接写死不带后缀在Linux CI上没问题,Windows上就找不到文件。我的做法是写一个getExeName(name)辅助函数,根据当前系统补全.exe或.bat后缀,两个平台都跑得通。另外,apksigner执行依赖Java环境,确保构建机的JAVA_HOME正确,否则它直接报“command not found”,让人误以为是脚本写错。
还有一点很多人会忽略:加固后重新签名的keystore必须和上传加固前那个原始签名包一致。原因很好理解,市场后台校验签名指纹,如果换了key相当于换了一个应用,升级安装一定会失败。所以自动化流程里我做了个防御,读取原始APK的签名证书SHA-256,重签后再读一次,不一致就抛异常。
3.4 用Walle批量生成渠道包
到了最期待的部分:渠道包批量生成。前面说过,Walle自带的Gradle插件是挂在Android构建变体上的,适合对原生构建产物做渠道分发,但我们现在的输入是加固后重签的APK,已经脱离标准构建链了,所以我直接调Walle的核心库,用一个自定义Java工具类来实现。
先看工具类代码,这是一个独立的Java文件,我放在buildSrc/src/main/java下:
import com.meituan.android.walle.ChannelInfo; import com.meituan.android.walle.ChannelWriter; import java.io.File; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.util.HashMap; import java.util.List; import java.util.Map; public class ChannelGenerator { public static void main(String[] args) throws Exception { if (args.length < 3) { throw new IllegalArgumentException("usage: baseApk outDir channelFile"); } File baseApk = new File(args[0]); File outDir = new File(args[1]); File channelFile = new File(args[2]); if (!baseApk.exists()) { throw new IllegalArgumentException("base apk not found: " + baseApk); } outDir.mkdirs(); List<String> lines = Files.readAllLines(channelFile.toPath(), StandardCharsets.UTF_8); Map<String, String> extraInfo = new HashMap<>(); extraInfo.put("buildTime", String.valueOf(System.currentTimeMillis())); for (String line : lines) { String channel = line.trim(); if (channel.isEmpty() || channel.startsWith("#")) { continue; } String outName = baseApk.getName().replace(".apk", "-" + channel + ".apk"); File outFile = new File(outDir, outName); ChannelInfo channelInfo = new ChannelInfo(channel); ChannelWriter.put(baseApk, outFile, channelInfo, extraInfo); System.out.println("[channel] " + outName); } System.out.println("[channel] done, total channels: " + lines.size()); } }有了这个工具类,Gradle侧就简单了,我用JavaExec方式运行:
configurations { walleLib } dependencies { walleLib "com.meituan.android.walle:library:1.1.7" } tasks.register("generateChannelApks") { dependsOn "reSignJiaguApk" def signedApk = file("${buildDir}/jiagu/output/myapp-release-v2signed.apk") def channelFile = file(project.property("CHANNEL_FILE")) def outRoot = file("${buildDir}/outputs/jiagu-channels") inputs.file(signedApk) inputs.file(channelFile) outputs.dir(outRoot) doLast { outRoot.mkdirs() // 清理旧渠道包,避免残留 outRoot.listFiles()?.each { it.delete() } javaexec { classpath = configurations.walleLib mainClass = "ChannelGenerator" args = [signedApk.absolutePath, outRoot.absolutePath, channelFile.absolutePath] } } }这里每次生成前先清空输出目录,是个容易忽略但很重要的细节。Gradle的outputs.dir只能帮你判断是否增量,不能阻止上次的包残留在目录里。如果不清空,渠道表删掉一个渠道后,旧渠道包还静静躺在那里,最后很可能被当成正式产物一起发出去,这属于我在实战中最不想见到的一类事故。
生成的每个渠道包,Walle只是往APK Signing Block里写入几KB渠道信息,包本身几乎不会变大。这也意味着所有渠道包签名都有效,但它们的哈希值各不相同,市场后台可以接受。你可以在App里依赖com.meituan.android.walle:library,在启动时调用WalleChannelReader.getChannelInfo(getApplicationContext())拿到渠道ID,来做渠道统计和运营配置。
3.5 用一个汇总Task串起整条流水线
单个Task都齐了,最后用一个汇总Task把它们挂起来:
tasks.register("autoPackage") { dependsOn "generateChannelApks" doLast { def outRoot = file("${buildDir}/outputs/jiagu-channels") def apks = outRoot.listFiles({ it.name.endsWith(".apk") } as FileFilter) println "===============================================" println "auto package finished, total apks: ${apks.size()}" println "output dir: ${outRoot.absolutePath}" println "===============================================" } }执行./gradlew autoPackage,Gradle会倒推执行整条依赖链:先assembleRelease,再downloadJiaguTools,再jiaguReleaseApk,再reSignJiaguApk,最后generateChannelApks。整条链上任何一步失败,后续Task都不会执行,失败原因直接显示在终端里。
你可能会想,assembleRelease会不会重复执行?其实不用担心,Gradle自带增量机制,同一个Task在一条执行链中只会执行一次。而且如果其他工程已经跑过assembleRelease且产物没变,它默认是UP-TO-DATE的,整条流水线速度会快很多。
4. 实际使用中的问题排查与避坑指南
纸上得来终觉浅,这套流程在CI上稳定跑了几个月后,我才敢说它“靠谱”。中间踩过的坑实在不少,挑几个最有代表性的说说。
4.1 渠道信息读不出来:大半是签名顺序错了
有一段时间,渠道包生成后App里读不到渠道ID,查了半天发现是加固后重签时只开了V1签名。Walle的ChannelWriter.put执行时没报错,但它找不到V2的Signing Block,渠道信息根本没写进去,运行时自然读出来是null。
解决方案就是我前面写的:apksigner sign时明确加--v1-signing-enabled true --v2-signing-enabled true两个参数,然后生成完渠道包后用工具类里的ChannelReader回读一次验证。现在我的generateChannelApksTask最后一个动作就是遍历所有产物,逐个读出channel打印出来,人眼扫一遍就知道有没有问题。
4.2 Linux构建机跑不通的几类原因
CI从Windows切到Linux后,前三个问题全是环境类。
第一个是zipalign和apksigner路径。Windows下SDK里的可执行文件带.exe,Linux下不带后缀,而且apksigner在Linux下是个shell脚本,执行时需要sh环境。写路径时一定要按平台动态拼接。
第二个是加固工具的Linux发行版依赖某些系统库(比如libc++相关),干净的CI镜像里经常缺失。我后来在CI构建前加了一步apt-get install,把依赖库装齐才跑通。
第三个是文件权限。解压出来的加固工具文件,在Windows下没有执行权限的概念,Linux下就报Permission denied。Gradle里解压后要手动加执行权限:
exec { commandLine "chmod", "-R", "+x", toolHome.absolutePath }这种问题在本地开发机上看不见,一上CI就原形毕露,所以建议自动化脚本一定要尽早适配Linux。
4.3 加固后的APK行为异常的排查
还有一次是加固后的包在部分真机上启动崩溃,开发环境里又复现不出来。后来抓取崩溃日志发现是加固工具的某个加载参数跟目标机型不兼容,需要指定兼容so库的架构选项(也就是我前面命令行里那个-x86参数的作用之一)。这个坑提醒我:加固命令行参数不能照抄默认值,要根据你们的机型分布、是否有模拟器包、是否需要兼容32位来调整。
真机质检这一步自动化再牛也替代不了,但可以用设备云平台做冒烟回归。我现在的流程是渠道包生成完后,自动推一台测试机安装启动,跑一遍核心链路再放行,虽然增加几分钟,但省下的人工沟通成本远超这个时间。
4.4 常见问题速查表
| 现象 | 根因 | 解决方法 |
|---|---|---|
| 渠道ID读取为null | 重签时缺少V2签名 | 使用apksigner同时开启V1/V2 |
| Windows下找不到zipalign命令 | 可执行文件名缺.exe后缀 | 按平台动态拼文件名 |
| Linux解压后 Permission denied | 文件缺少执行权限 | 解压后执行chmod -R +x |
| 加固工具登录失败 | 账号密码未正确传入 | 检查环境变量JIAGU_PASS |
| 渠道包目录混入旧包 | 未清理输出目录 | 每次生成前delete旧产物 |
| apksigner报 command not found | JAVA_HOME未配置 | 确保构建机Java环境正确 |
| 重签后签名指纹跟原始包不一致 | 用了不同keystore | 统一使用原始发布keystore |
| 加固后包体明显变大 | 选择了错误兼容参数 | 按目标平台调整加固参数 |
5. 写在最后的经验
这套自动打包流水线跑起来之后,我最大的感受是:把重复劳动交给脚本,不只是省时间,更是把出错概率降了一个量级。手动打包时你总抱着一丝侥幸——这个包我自己试过能装,应该没问题吧。自动化之后,每次产物的生成路径完全一致,签名固定、渠道固定、日志可回溯,出问题能找到根源,而不是靠“再打一次碰运气”。
如果你也要在自己的工程里搭一套,我给三点建议。
第一,先从最痛的一步开始改造,不必一上来就追求全自动。比如先把“加固后重签”做成半自动Task,人只负责上传下载,后面再一步步把下载、渠道包、验证都吞进来。步子大了容易失控。
第二,所有外部参数尽量收敛到一处。工具版本、下载地址、账号、密码、keystore路径,全部放在gradle.properties或环境变量里,不要在Task代码里散落一堆魔数。后来人接手时,只看配置就能理解整个流程。
第三,一定要给自己留下“逃生舱”。自动化再顺,总有它覆盖不到的场景,比如某个市场要特殊的包名、某个渠道要预埋不同的启动参数。所以我的工程里保留了单独执行任意一环Task的能力:你想只跑渠道包,就./gradlew generateChannelApks,不必永远走完整链路。
最后分享一个我自己后来的扩展思路:把这条Gradle流水线再接到CI的通知链路上,渠道包生成后自动上传到内部分发平台,并推送消息到发布群。到这里,打包这件事才算真正从“人肉值班”变成了“无人值守”。你早晚也会走到这一步的——趁早把脚本写好,后面会有更多时间用来干真正有意义的事。