前阵子组长丢给我一个需求把app-release.apk按 12 个市场渠道重新打一遍包。一开始我觉得很简单不就是 Gradle 里的productFlavors配一下嘛结果跑一次全量构建 40 多分钟中途产品又改了一次渠道名单等于白等一趟。后来我干脆自己做了一个“主包只打一次、脚本批量补渠道号”的小工具——核心就是用 Android 新版的 v2 签名方案把渠道信息塞进 APK 的APK Signing Block里不重新构建、不重新签名秒出所有渠道包。这篇文章想把 v2 签名和渠道包这两件事彻底讲透v1 和 v2 到底差在哪、为什么以前用 zip 注释写渠道号的老办法在 v2 时代会失效、以及一套能直接抄作业的自研渠道包工具的完整实现思路。适合独立开发者、中小团队里负责打包发版的 Android 开发也适合那些想在 CI/CD 里省掉一大截构建时间的同学。1. 为什么 v2 签名时代传统渠道包方案玩不转了1.1 v1 和 v2 签名的核心差异先回顾一下 APK 签名的演进。v1 签名也就是传统的 JAR 签名从 Android 诞生第一天就有它做的事情是逐一对 APK 里的每个文件计算摘要然后把这些摘要写进META-INF/目录下的.SF和.RSA文件。安装的时候系统逐个文件解压、算摘要、跟签名文件比对。这套方式的优点是灵活缺点是慢而且保护粒度太细APK 里哪怕有一堆文件没被校验到也不影响安装。v2 签名是 Android 7.0 开始引入的官方叫APK Signature Scheme v2。它不再逐个文件处理而是把整个 APK 当成一个大文件用特殊方式计算摘要然后把摘要和证书信息放进一个专门的区域——APK Signing Block。安装时系统一次性校验整个 APK 的完整性任何字节被改动都会导致校验失败。v3 签名在 Android 9 上又做了一次升级支持密钥轮转但底层思路和 v2 一致。打个比方v1 像是给每个快递盒单独贴封条v2 像是给整个集装箱焊了一圈封条。v1 时代你把盒子里某个泡沫挪个位置封条不受影响v2 时代你碰一下箱子哪怕换个螺丝封条当场失效。1.2 老办法“改 zip 注释写渠道”为什么会失效早年间做渠道包很多方案都依赖一个特性APK 本质是个 zip 包zip 格式允许在文件末尾的注释区EOCD comment写自定义数据。v1 签名不校验这段注释所以往里面塞一个渠道号APK 的签名依然有效应用市场也能通过包名加渠道号区分渠道。这个方案在 v1 时代很香因为零成本、不用重打包、不用重新签名。但 v2 签名出现后整个局面变了v2 的摘要覆盖了 APK 文件中除签名块本身之外的几乎全部内容包括 zip 中央目录和 EOCD。你一旦动了注释区哪怕只多一个字节v2 校验就会失败。更麻烦的是Android 7.0 及以上系统在安装 APK 时如果检测到包里有 v2 签名块会优先用 v2 规则做完整性校验。也就是说你想“只用 v1 签名不签 v2”来规避这个问题会在新系统上直接被拒之门外。唯一正确的方向就是顺着 v2 的设计思路走既然签名块本身开放给开发者做自定义数据扩展那渠道信息就放到签名块里去。2. 渠道包工具的方案选型与整体设计2.1 主流多渠道打包方案横向对比市面上做渠道包的成熟方案不少我在动手前把主流的都过了一遍思路归纳下来大致分三类。方案核心原理是否需要重签名优点缺点Gradle productFlavors 重打包每个渠道跑一次完整构建生成独立 APK是完全正统渠道号写在代码里构建时间长、渠道多时资源浪费美团 Walle在 v2/v3 签名块中插入渠道信息不需要秒级出包签名不受影响只支持 v2/v3 签名v1-only 包无效腾讯 VasDolly在签名块中插入渠道信息同时兼容 v1不需要兼容性好v1/v2/v3 都考虑到了依赖开源库定制成本略高自研脚本原理同 Walle自己解析签名块并注入不需要可控性强CI/CD 定制灵活需要自己维护解析代码2.2 为什么我选择“主包一次签名脚本注入渠道”我当时权衡了很久最终决定不自找麻烦去搞一套完整的重建流程而是把工具定位成“签名后处理脚本”。核心思路很简单主包用标准流程构建好签名、对齐都做完整然后脚本把渠道号批量写进 APK 的签名块。这个方案有几个非常实际的好处。第一快。一次构建出一个基准包后续每个渠道包只是复制文件加改签名块毫秒级完成。对比 Gradle 重打包动辄半个小时的构建体验是天壤之别。第二对现有工程零侵入。不需要在build.gradle里加一堆productFlavors的配置也不需要在代码里写一堆BuildConfig.FLAVOR之类的条件判断。打包组拿到的产物和普通 APK 完全一致。第三渠道信息写在签名块里签名验证会天然忽略这个区域所以不需要重新签名。这样既保证了 APK 的签名完整又留出了渠道扩展空间是 v2 签名在设计阶段就留好的路子。2.3 工具的使用流程与功能清单我最终做出来的工具是一个命令行脚本调用方式长这样python make_channels.py -i app-release.apk -c channels.txt -o ./outputchannels.txt每行写一个渠道号支持#注释。脚本会先校验基准包的 v2 签名确认没问题后遍历渠道列表逐个生成渠道包。工具的功能清单大致如下自动识别并校验 APK 的 v2/v3 签名状态从渠道列表文件批量读取渠道号支持去重在 APK Signing Block 中插入自定义 ID-value 渠道信息自动同步更新 EOCD 中的中央目录偏移输出渠道包清单包含包名、大小、MD5、生成时间支持指定输出目录支持对已加固 APK 做二次签名后注入3. 核心实现v2 签名与渠道注入的实操细节3.1 准备一个规范的 v2 签名基准 APK在动脚本之前先把基准包生成对。Android Studio 里配置签名信息常规写法如下android { signingConfigs { release { storeFile file(../keystore/release.jks) storePassword your-store-password keyAlias release keyPassword your-key-password } } buildTypes { release { minifyEnabled true shrinkResources true signingConfig signingConfigs.release } } }需要注意build-tools 版本要足够新至少 24.0.3 以上才有apksigner工具也才有完整的 v2 签名输出。我建议直接用 Android Studio 自带的 build-tools基本都在最新版本。如果你不走 Gradle想手动签名可以这样# 先对齐再签名顺序不能反 zipalign -v -p 4 app-release-unsigned.apk app-release-aligned.apk # 使用 v2、v3 签名 apksigner sign \ --ks release.jks \ --ks-key-alias release \ --ks-pass pass:123456 \ --out app-release.apk \ app-release-aligned.apk签名完成后用下面这行命令验证签名信息apksigner verify --verbose --print-certs app-release.apk输出里可以看到v2: true、v3: true这样的签名标记以及证书的 SHA256 指纹。顺便说一句做微信开放平台、高德地图之类的 SDK 授权时后台填的签名 MD5 或 SHA1也可以用--print-certs拿。这里有一个非常关键的注意事项zipalign必须排在apksigner sign前面。因为zipalign会调整 zip 文件内部条目的字节偏移如果先签名再对齐v2 签名摘要会被破坏包就废了。3.2 APK 的字节结构与签名块注入原理要把渠道信息写进签名块必须先搞清楚 APK 的字节布局。一个完整的 APK 文件从前往后分别是这样几段zip 本地文件条目区也就是所有文件内容按顺序排列的区域APK Signing Blockv2/v3 签名数据存在这里zip 中央目录记录每个条目的元信息EOCD记录中央目录的偏移和总大小APK Signing Block的格式是[uint64 size1] [ID-value 对序列] [uint64 size2] [16字节 magic: APK Sig Block 42]其中size1和size2的值相同表示从 ID-value 区域开始到 magic 末尾的长度。ID-value 对本身也是一个嵌套结构先是 uint64 表示整对的长度然后是 uint32 的 ID最后是 ID 对应的数据内容。v2 签名在校验时会忽略整个 APK Signing Block 区域也就是size1到magic之间的部分。所以我们可以在这个区域里新增一个自定义 ID-value 对写入渠道号信息而不影响签名有效性。渠道号一般用0x71777777这个 ID这是行业内比较通用的“通用渠道”标识 IDWalle 也用的这个值。这里有个容易踩坑的细节插入新的 ID-value 对之后签名块的长度变了而签名块位于中央目录之前所以中央目录在文件中的偏移也会跟着变。EOCD 里记录中央目录偏移的字段必须同步更新否则安卓系统解析 APK 时会直接报错安装提示“解析包时出现问题”。3.3 核心注入代码实现我写了一个 Python 版本的脚本核心分三步定位签名块、构造 ID-value 对、重组文件并更新 EOCD。import struct import os V2_MAGIC bAPK Sig Block 42 CHANNEL_BLOCK_ID 0x71777777 EOCD_MAGIC b\x50\x4b\x05\x06 MAX_COMMENT_SIZE 65535 def read_eocd(data): # zip 的 EOCD 在文件末尾前面可能带注释所以从后往前找 for i in range(len(data) - 22, max(0, len(data) - 22 - MAX_COMMENT_SIZE), -1): if data[i:i 4] EOCD_MAGIC: fields struct.unpack_from(HHHHIIH, data, i 4) _, _, _, _, _, cd_offset, _ fields return i, cd_offset raise ValueError(EOCD not found) def find_signing_block(data, cd_offset): # 签名块紧挨在中央目录前面末尾是 magic 和 size if cd_offset 24: return None magic data[cd_offset - 16:cd_offset] if magic ! V2_MAGIC: return None size_field struct.unpack_from(Q, data, cd_offset - 24)[0] block_start cd_offset - (size_field 8) return block_start, cd_offset def parse_id_value_pairs(data, block_start, block_end): pairs {} pos block_start 8 while pos block_end - 24: pair_len struct.unpack_from(Q, data, pos)[0] pair_id struct.unpack_from(I, data, pos 8)[0] pair_value data[pos 12:pos 8 pair_len] pairs[pair_id] pair_value pos 8 pair_len return pairs def build_signing_block(pairs): inner b for pair_id, pair_value in pairs.items(): pair_data struct.pack(I, pair_id) pair_value inner struct.pack(Q, len(pair_data) 4) pair_data block_size len(inner) 24 return struct.pack(Q, block_size - 8) inner \ struct.pack(Q, block_size - 8) V2_MAGIC def inject_channel(apk_path, channel, out_path): data bytearray(open(apk_path, rb).read()) eocd_index, cd_offset read_eocd(data) block_start, block_end find_signing_block(data, cd_offset) if block_start is None: raise RuntimeError(APK 中没有找到 v2 签名块请确认基准包已使用 v2 签名) pairs parse_id_value_pairs(data, block_start, block_end) pairs[CHANNEL_BLOCK_ID] channel.encode(utf-8) new_block build_signing_block(pairs) new_file data[:block_start] new_block data[block_end:] # 新签名块会占用不同长度中央目录整体位移需更新 EOCD new_cd_offset block_start len(new_block) cur_offset eocd_index 16 struct.pack_into(I, new_file, cur_offset, new_cd_offset) with open(out_path, wb) as f: f.write(new_file)脚本的核心逻辑不复杂但有一个地方值得多说一句build_signing_block里构造 ID-value 对时pair_len指的是从 ID 字段开头到该对结束的长度。我在最初版本里把这个长度算了两次结果所有渠道包都装不上后来对着签名块规范逐字节核对才找到问题。如果你也想自己实现建议写完之后用apksigner verify -v验证一遍所有生成的渠道包。3.4 操作顺序与“对齐、签名、注入”的坑在实际使用中我最常被问到的一个问题是为什么zipalign必须在签名前做而渠道注入可以放在签名后原因在于zipalign会改动 zip 条目的本地文件头和字节对齐方式它会改变 APK 的字节内容所以必须在 v2 签名之前执行确保签名后的 APK 不再发生字节变化。而渠道注入只修改签名块内部数据以及 EOCD 中记录的中央目录偏移这两处都不在 v2 摘要的保护范围内所以注入后不需要重新签名。因此一个规范的打包流程是gradle assembleRelease # 1. 出未签名包 zipalign -v -p 4 in.apk aligned.apk # 2. 对齐 apksigner sign ... aligned.apk # 3. v2/v3 签名 python make_channels.py ... # 4. 注入渠道如果项目里有加固需求顺序就变成原包 - 加固 - 重新签名 - 注入渠道。加固工具会解包再封包原有的签名块会被直接扔掉所以必须在加固后重新签名再走渠道注入。4. 常见问题与排查技巧实录4.1 安装报错“解析包时出现问题”或“App not installed”这类问题八成出在签名块本身被改坏了。排查思路按下面三步走先跑apksigner verify --verbose app-release.apk确认 v2/v3 签名状态是否为 true。如果显示 v2 校验失败说明注入或者签名环节改动了受保护区域。再跑zipalign -c -v 4 app-release.apk检查对齐状态。如果输出里出现Verification FAILED说明对齐被破坏需要重新走一遍标准流程。最后检查脚本是否更新了 EOCD 的中央目录偏移。这个偏移如果没更新或者更新错了地方APK 在解析阶段就找不到中央目录系统会直接判定包损坏。我见过太多人把偏移写进了 EOCD 的注释区结果就是包能生成但装不上。4.2 加固后的渠道包需要二次签名如果你用腾讯乐固、360 加固之类的工具请务必记住一个流程先加固再签名最后注入渠道。加固过程会把 APK 进行解包、加密、重打包原有的 v2 签名块会被破坏甚至删除。所以加固后的 APK 必须重新跑一遍apksigner sign然后再用渠道脚本做注入。如果反过来先注入渠道再加固渠道信息会被加固过程冲掉等于白干。另外有些加固服务商会提供一个“自动签名”开关默认开启。如果你自己也要再签一次注意别签两次导致证书不一致。建议加固时关掉自动签名统一用自己手里的 jks 走一遍标准签名流程。4.3 运行时渠道号读不到渠道号写入签名块后App 里需要在运行时自己把它读出来。这里分享一个关键经验一定要通过context.getApplicationInfo().sourceDir拿到 APK 文件路径然后按同样的解析逻辑去读签名块里的 ID-value。不要尝试去读/storage/emulated/0/Android/data/...这种外部存储路径那里面一般没有安装包文件而且不同厂商的 FileProvider 还会继续把路径改来改去。运行时读取渠道号的 Java 核心代码大概是这样的public static String getChannel(Context context) { try { File apkFile new File(context.getApplicationInfo().sourceDir); // 1. 读取文件尾部 EOCD // 2. 通过中央目录偏移定位签名块 // 3. 遍历 ID-value找到 0x71777777 对应的值 } catch (Exception e) { return ; } return ; }注意一点读取渠道号的逻辑只跟 APK 文件结构有关不依赖系统版本是不是 7.0 以上。就算手机是 Android 6.0只要 APK 里存在 v2 签名块你也能解析出来。真正的问题会出现在设备上的包如果被某个渠道做了二次处理把签名块冲掉了那就读不到了。这种情况在海外发行、接入某些 SDK 后偶尔会遇到排查时先apksigner verify看一下线上包的签名块结构。4.4 批量出包时的文件一致性校验渠道包数量一多最怕的就是漏包、混包、或者生成到一半中断。我在脚本里加了两个保险一个是在生成时对每个渠道包计算 MD5最后输出一张清单表格包含渠道名、文件大小、MD5、生成时间。交付给测试或者上传应用市场时这张表格直接可以作为附件。另一个是渠道列表去重。以前我用过一个渠道名单里面huawei和HUAWEI都出现了结果生成了两个完全一样的包上传时差点重复上架。现在脚本启动时会先做一次去重和大小写统一提醒操作者确认。4.5 环境问题apksigner 找不到、JDK 版本冲突apksigner依赖 Java 8 及以上环境所以在 CI 机器上跑的时候经常遇到UnsupportedClassVersionError之类的报错。解决方法是把 JDK 统一到 11 或 17并在脚本里显式指定JAVA_HOME。同时apksigner位于 Android SDK 的build-tools目录下版本有很多个。脚本里建议自动探测最新版本BUILD_TOOLS$(ls $ANDROID_HOME/build-tools/ | sort -V | tail -1) APKSIGNER$ANDROID_HOME/build-tools/$BUILD_TOOLS/apksigner这样不管构建机上的 build-tools 装了多少个版本都能自动找到最新版避免因为版本太旧导致 v2 签名功能缺失。最后再分享一个小细节这个工具我用到现在已经快两年了打包流程彻底稳定下来后基本没再出过乱子。我个人的体会是渠道包工具这类事情最怕的不是实现复杂而是你没有一个固定顺序。只要把“先对齐、再签名、最后注入渠道”这个顺序焊死在流程里剩下的就都是体力活了。还有一个小技巧渠道号的命名尽量用英文、拼音或数字不要用中文。中文字符在部分统计平台的 URL 回传里会乱码渠道数据直接对不上后期排查起来特别麻烦。建议团队内部统一好渠道命名规则比如都用小写加下划线写入channels.txt之前先过一遍脚本做格式校验。如果你所在的团队发版频繁、渠道动不动就二三十个完全可以照着这个思路做一套属于你们自己的打包流水线。先把原理搞清楚再决定是直接用 Walle 还是自研脚本。对我来说自己把签名块解析写一遍比单纯调别人封装好的库更让人踏实因为出问题的时候你能第一时间定位到根因。