
做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到输出全渠道包中间不允许有任何一步需要人在终端前盯着。做不到这点就不叫自动化只是把手工步骤搬了个地方。第二条必须兼容多平台。开发机上通常是WindowsCI上大概率是Linux偶尔还有人在macOS上跑三个平台的加固工具、SDK路径、可执行文件后缀全都不一样脚本里必须自动识别。第三条加固和渠道包的生命周期要可控。构建可以随时重跑产物要干净不能出现上一次构建的残留包混进本次输出目录。每个任务都应该是可独立执行的某一环节出问题能单独重跑不用从头再来。第四条也是我特别看重的每一步都要有清晰的日志和校验。下载资源要检查哈希加固产物要检查APK完整性重签后要用apksigner验证签名渠道包生成后要回读渠道ID。自动化最怕黑盒一旦哪一步静默失败线上包出问题才追查就晚了。1.3 整条流水线的流程设计整条流水线的依赖关系用文字串起来就是下面这个样子assembleRelease原始签名APK ↓ downloadJiaguTools多平台下载加固工具与资源 ↓ jiaguReleaseApk执行加固生成加固APK ↓ reSignJiaguApkzipalign apksigner v2重签 ↓ generateChannelApksWalle核心库写入渠道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?apkxxx这样干净的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_VERSION4.3.0 JIAGU_DOWNLOAD_BASE_URLhttps://mirror.example.com/jiagu JIAGU_ACCOUNTbuild_account JIAGU_PASSWORD${env.JIAGU_PASS} # 签名信息实际使用时建议从环境变量读取 RELEASE_STORE_FILEkeystore/release.keystore RELEASE_STORE_PASSWORD${env.STORE_PASS} RELEASE_KEY_ALIASmyapp RELEASE_KEY_PASSWORD${env.KEY_PASS} # 渠道表文件 CHANNEL_FILEchannels.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 和 V2V2 是 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(); ListString lines Files.readAllLines(channelFile.toPath(), StandardCharsets.UTF_8); MapString, 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 autoPackageGradle会倒推执行整条依赖链先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里的可执行文件带.exeLinux下不带后缀而且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/V2Windows下找不到zipalign命令可执行文件名缺.exe后缀按平台动态拼文件名Linux解压后 Permission denied文件缺少执行权限解压后执行chmod -R x加固工具登录失败账号密码未正确传入检查环境变量JIAGU_PASS渠道包目录混入旧包未清理输出目录每次生成前delete旧产物apksigner报 command not foundJAVA_HOME未配置确保构建机Java环境正确重签后签名指纹跟原始包不一致用了不同keystore统一使用原始发布keystore加固后包体明显变大选择了错误兼容参数按目标平台调整加固参数5. 写在最后的经验这套自动打包流水线跑起来之后我最大的感受是把重复劳动交给脚本不只是省时间更是把出错概率降了一个量级。手动打包时你总抱着一丝侥幸——这个包我自己试过能装应该没问题吧。自动化之后每次产物的生成路径完全一致签名固定、渠道固定、日志可回溯出问题能找到根源而不是靠“再打一次碰运气”。如果你也要在自己的工程里搭一套我给三点建议。第一先从最痛的一步开始改造不必一上来就追求全自动。比如先把“加固后重签”做成半自动Task人只负责上传下载后面再一步步把下载、渠道包、验证都吞进来。步子大了容易失控。第二所有外部参数尽量收敛到一处。工具版本、下载地址、账号、密码、keystore路径全部放在gradle.properties或环境变量里不要在Task代码里散落一堆魔数。后来人接手时只看配置就能理解整个流程。第三一定要给自己留下“逃生舱”。自动化再顺总有它覆盖不到的场景比如某个市场要特殊的包名、某个渠道要预埋不同的启动参数。所以我的工程里保留了单独执行任意一环Task的能力你想只跑渠道包就./gradlew generateChannelApks不必永远走完整链路。最后分享一个我自己后来的扩展思路把这条Gradle流水线再接到CI的通知链路上渠道包生成后自动上传到内部分发平台并推送消息到发布群。到这里打包这件事才算真正从“人肉值班”变成了“无人值守”。你早晚也会走到这一步的——趁早把脚本写好后面会有更多时间用来干真正有意义的事。