美团的 Walle 方案:https://github.com/Meituan-Dianping/walle
腾讯的 VasDolly 方案:https://github.com/Tencent/VasDolly
packer-ng-plugin 方案:https://github.com/mcxiaoke/packer-ng-plugin
先从 Github 开源维护的情况看,packer-ng-plugin 项目已经停止维护,Walle 最新的维护是在2年前,VasDolly 最新的维护在5个月前。从开源维护的角度来说,腾讯的 VasDolly 方案,更胜一筹。

VasDolly
先说使用后的体验:
因为 VasDolly官方最新的版本为 v3.0.4,所以直接集成最新版本的情况,没去试历史旧版本的情况。
项目环境为:
dependencies {
classpath 'com.android.tools.build:gradle:7.0.3'
classpath 'com.tencent.vasdolly:plugin:3.0.4'
}
distributionUrl=https://services.gradle.org/distributions/gradle-7.0.2-all.zip
即可成功编译,并且按教程配置,可以打出对应的渠道包。官方的 Demo 给的就是这个 Gradle 编译环境。打20个渠道包,时间可以控制在1分钟左右。
但是,例如项目环境为:
dependencies {
classpath "com.android.tools.build:gradle:4.1.3"
classpath 'com.tencent.vasdolly:plugin:3.0.4'
}
distributionUrl=https://services.gradle.org/distributions/gradle-6.6-all.zip
编译项目会报这个错:
Unable to load class 'com.android.build.api.extension.AndroidComponentsExtension'.
This is an unexpected error. Please file a bug containing the idea.log file.
VasDolly 实现原理:
https://github.com/Tencent/VasDolly/wiki/VasDolly%E5%AE%9E%E7%8E%B0%E5%8E%9F%E7%90%86
通过Gradle生成多渠道包
若是直接编译生成多渠道包,首先要配置渠道文件、渠道包的输出目录和渠道包的命名规则:
channel{
//指定渠道文件
channelFile = file("/Users/leon/Downloads/testChannel.txt")
//多渠道包的输出目录,默认为new File(project.buildDir,"channel")
outputDir = new File(project.buildDir,"xxx")
//多渠道包的命名规则,默认为:${appName}-${versionName}-${versionCode}-${flavorName}-${buildType}-${buildTime}
apkNameFormat ='${appName}-${versionName}-${versionCode}-${flavorName}-${buildType}'
//快速模式:生成渠道包时不进行校验(速度可以提升10倍以上,默认为false)
fastMode = false
//buildTime的时间格式,默认格式:yyyyMMdd-HHmmss
buildTimeDateFormat = 'yyyyMMdd-HH:mm:ss'
//低内存模式(仅针对V2签名,默认为false):只把签名块、中央目录和EOCD读取到内存,不把最大头的内容块读取到内存,在手机上合成APK时,可以使用该模式
lowMemory = false
}
其中,多渠道包的命名规则中,可使用以下字段:
-
appName : 当前project的name
-
versionName : 当前Variant的versionName
-
versionCode : 当前Variant的versionCode
-
buildType : 当前Variant的buildType,即debug or release
-
flavorName : 当前的渠道名称
-
appId : 当前Variant的applicationId
-
buildTime : 当前编译构建日期时间,时间格式可以自定义,默认格式:yyyyMMdd-HHmmss
然后,通过 gradle channelDebug、gradle channelRelease 命令分别生成 Debug 和 Release 的多渠道包。
为了方便临时生成渠道包进行测试,从v2.0.0开始支持添加渠道参数:gradle channelDebug(channelRelease) -Pchannels=yingyongbao,gamecenter,这里通过属性 channels 指定的渠道列表拥有更高的优先级,且和原始的文件方式是互斥的。
根据已有基础包重新生成多渠道包
若是根据已有基础包重新生成多渠道包,首先要配置渠道文件、基础包的路径和渠道包的输出目录:
rebuildChannel {
//指定渠道文件
channelFile = file("/Users/leon/Downloads/testReChannel.txt")
// 已有APK文件地址(必填),如new File(project.rootDir, "/baseApk/app_base.apk"),文件名中的base将被替换为渠道名
baseApk = 已有APK文件地址(必填)
//默认为new File(project.buildDir, "rebuildChannel")
outputDir = 渠道包输出目录
//快速模式:生成渠道包时不进行校验(速度可以提升10倍以上,默认为false)
fastMode = false
//低内存模式(仅针对V2签名,默认为false):只把签名块、中央目录和EOCD读取到内存,不把最大头的内容块读取到内存,在手机上合成APK时,可以使用该模式
lowMemory = false
}
通过命令行生成渠道包、读取渠道信息:
https://github.com/Tencent/VasDolly/blob/master/command/README.md
读取渠道信息
通过 helper 类库中的 ChannelReaderUtil 类读取渠道信息。
String channel = ChannelReaderUtil.getChannel(getApplicationContext());
如果没有渠道信息,那么这里返回 null,开发者需要自己判断。
Walle
先说使用后的体验:
Walle 官方库已经2年多没更新,v1.1.7 为最新的版本。打20个渠道包,时间也可以控制在1分钟左右。
项目环境为:
dependencies {
classpath 'com.android.tools.build:gradle:4.1.3'
classpath 'com.meituan.android.walle:plugin:1.1.7'
}
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-6.6-all.zip
即可成功编译,并且按教程配置,可以打出对应的渠道包。
因为 Walle 最新的维护是在2年前,很多依赖的库和 Gradle 版本都太低,所以自己在 Github 仓库去尝试升级维护这个库,地址如下:https://github.com/jeffreyxuworld/MeituanWalle
Walle 实现原理
可看下美团的官方链接:https://tech.meituan.com/2017/01/13/android-apk-v2-signature-scheme.html
捋了一遍原理后,总结如下:
大前提都是针对 APK 签名方案去做的相关事情。
Android 支持以下三种应用签名方案:
-
v1 方案 :基于
JAR签名。https://source.android.com/security/apksigning#v1 -
v2 方案 :
APK签名方案 v2(在Android 7.0中引入)。https://source.android.com/security/apksigning/v2 -
v3 方案 :
APK签名方案 v3(在Android 9中引入)。https://source.android.com/security/apksigning/v3
更细节的要点如下:
-
v1 签名不保护
APK的某些部分,例如ZIP元数据。APK验证程序需要处理大量不可信(尚未经过验证)的数据结构,然后会舍弃不受签名保护的数据。这会导致相当大的受攻击面。此外,APK验证程序必须解压所有已压缩的条目,而这需要花费更多时间和内存。为了解决这些问题,Android 7.0中引入了APK签名方案 v2。Walle是基于APK签名方案 v2 去做的相关处理,而美团上一代分渠道打包方案是根据APK签名方案 v1 去做的,所以现在已经不适用。 -
该方案会对
APK的内容进行哈希处理和签名,然后将生成的"APK签名分块"插入到APK中。如需详细了解如何在应用中使用 v2+ 方案,可以阅读以下链接:https://developer.android.com/about/versions/nougat/android-7.0#apk_signature_v2 -
在验证期间,v2+ 方案会将
APK文件视为blob,并对整个文件进行签名检查。对APK进行的任何修改(包括对ZIP元数据进行的修改)都会使APK签名作废。这种形式的APK验证不仅速度要快得多,而且能够发现更多种未经授权的修改。 -
APK签名方案 v2 是一种全文件签名方案,该方案能够发现对APK的受保护部分进行的所有更改,从而有助于加快验证速度并增强完整性保证。使用APK签名方案 v2 进行签名时,会在APK文件中插入一个APK签名分块,该分块位于"ZIP中央目录"部分之前并紧邻该部分。在"APK签名分块"内,v2 签名和签名者身份信息会存储在APK签名方案 v2 分块中。

新的签名方案会在
ZIP文件格式的Central Directory区块所在文件位置的前面添加一个APK Signing Block区块,下面按照ZIP文件的格式来分析新应用签名方案签名后的APK包。
整个APK(ZIP文件格式)会被分为以下四个区块: 1.Contents of ZIP entries(from offset 0 until the start of APK Signing Block)2.APK Signing Block3.ZIP Central Directory4.ZIP End of Central Directory

新应用签名方案的签名信息会被保存在区块2(
APK Signing Block)中, 而区块1(Contents of ZIP entries)、区块3(ZIP Central Directory)、区块4(ZIP End of Central Directory)是受保护的,在签名后任何对区块1、3、4的修改都逃不过新的应用签名方案的检查。
之前的渠道包生成方案是通过在META-INF目录下添加空文件,用空文件的名称来作为渠道的唯一标识,之前在META-INF下添加文件是不需要重新签名应用的,这样会节省不少打包的时间,从而提高打渠道包的速度。但在新的应用签名方案下META-INF已经被列入了保护区了,向META-INF添加空文件的方案会对区块1、3、4都会有影响。 -
可以看出因为
APK包的区块1、3、4都是受保护的,任何修改在签名后对它们的修改,都会在安装过程中被签名校验检测失败,而区块2(APK Signing Block)是不受签名校验规则保护的,那是否可以在这个不受签名保护的区块2(APK Signing Block)上做文章呢?我们先来看看对区块2格式的描述:

-
区块2中
APK Signing Block是由这几部分组成:2个用来标示这个区块长度的8字节 + 这个区块的魔数(APK Sig Block 42)+ 这个区块所承载的数据(ID-value)。我们重点来看一下这个ID-value,它由一个8字节的长度标示+4字节的ID+它的负载组成。V2的签名信息是以 ID(0x7109871a)的ID-value来保存在这个区块中,不知大家有没有注意这是一组ID-value,也就是说它是可以有若干个这样的ID-value来组成。 -
那
Android应用在安装时新的应用签名方案是怎么进行校验的呢?通过翻阅Android相关部分的源码,发现下面代码段是用来处理上面所说的ID-value的:public static ByteBuffer findApkSignatureSchemeV2Block( ByteBuffer apkSigningBlock, Result result) throws SignatureNotFoundException { checkByteOrderLittleEndian(apkSigningBlock); // FORMAT: // OFFSET DATA TYPE DESCRIPTION // * @+0 bytes uint64: size in bytes (excluding this field) // * @+8 bytes pairs // * @-24 bytes uint64: size in bytes (same as the one above) // * @-16 bytes uint128: magic ByteBuffer pairs = sliceFromTo(apkSigningBlock, 8, apkSigningBlock.capacity() - 24); int entryCount = 0; while (pairs.hasRemaining()) { entryCount++; if (pairs.remaining() < 8) { throw new SignatureNotFoundException( "Insufficient data to read size of APK Signing Block entry #" + entryCount); } long lenLong = pairs.getLong(); if ((lenLong < 4) || (lenLong > Integer.MAX_VALUE)) { throw new SignatureNotFoundException( "APK Signing Block entry #" + entryCount + " size out of range: " + lenLong); } int len = (int) lenLong; int nextEntryPos = pairs.position() + len; if (len > pairs.remaining()) { throw new SignatureNotFoundException( "APK Signing Block entry #" + entryCount + " size out of range: " + len + ", available: " + pairs.remaining()); } int id = pairs.getInt(); if (id == APK_SIGNATURE_SCHEME_V2_BLOCK_ID) { return getByteBuffer(pairs, len - 4); } result.addWarning(Issue.APK_SIG_BLOCK_UNKNOWN_ENTRY_ID, id); pairs.position(nextEntryPos); } throw new SignatureNotFoundException( "No APK Signature Scheme v2 block in APK Signing Block"); }上述代码中关键的一个位置是
if (id == APK_SIGNATURE_SCHEME_V2_BLOCK_ID) {return getByteBuffer(pairs, len - 4);},通过源代码可以看出Android是通过查找ID为APK_SIGNATURE_SCHEME_V2_BLOCK_ID = 0x7109871a的ID-value,来获取APK Signature Scheme v2 Block,对这个区块中其他的ID-value选择了忽略。
当看到这里时,我们可不可以设想一下,提供一个自定义的ID-value并写入该区域,从而为快速生成渠道包服务呢?
怎么向ID-value中添加信息呢?通过阅读ZIP的文件格式和APK Signing Block格式的描述,笔者通过编写下面的代码片段进行验证,发现通过在已经被新的应用签名方案签名后的APK中添加自定义的ID-value,是不需要再次经过签名就能安装的,下面是部分代码片段。public void writeApkSigningBlock(DataOutput dataOutput) { long length = 24; for (int index = 0; index < payloads.size(); ++index) { ApkSigningPayload payload = payloads.get(index); byte[] bytes = payload.getByteBuffer(); length += 12 + bytes.length; } ByteBuffer byteBuffer = ByteBuffer.allocate(Long.BYTES); byteBuffer.order(ByteOrder.LITTLE_ENDIAN); byteBuffer.putLong(length); dataOutput.write(byteBuffer.array()); for (int index = 0; index < payloads.size(); ++index) { ApkSigningPayload payload = payloads.get(index); byte[] bytes = payload.getByteBuffer(); byteBuffer = ByteBuffer.allocate(Integer.BYTES); byteBuffer.order(ByteOrder.LITTLE_ENDIAN); byteBuffer.putInt(payload.getId()); dataOutput.write(byteBuffer.array()); dataOutput.write(bytes); } ... } -
对新的应用签名方案生成的
APK包中的ID-value进行扩展,提供自定义ID-value(渠道信息),并保存在APK中。而APK在安装过程中进行的签名校验,是忽略我们添加的这个ID-value的,这样就能正常安装了。 -
在
App运行阶段,可以通过ZIP的EOCD(End of central directory)、Central directory等结构中的信息(会涉及ZIP格式的相关知识,这里不做展开描述)找到我们自己添加的ID-value,从而实现获取渠道信息的功能。 -
新一代渠道包生成工具完全是基于
ZIP文件格式和APK Signing Block存储格式而构建,基于文件的二进制流进行处理,有着良好的处理速度和兼容性,能够满足不同的语言编写的要求,目前美团的方案采用的是Java+Groovy开发, 该工具主要有四部分组成:-
用于写入
ID-value信息的Java类库 -
Gradle构建插件用来和Android的打包流程进行结合 -
用于读取
ID-value信息的Java类库 -
用于供
com.android.application使用的读取渠道信息的AAR
这样,每打一个渠道包只需复制一个APK,然后在APK中添加一个ID-value即可,这种打包方式速度非常快,对一个30M大小的APK包只需要100多毫秒(包含文件复制时间)就能生成一个渠道包,而在运行时获取渠道信息只需要大约几毫秒的时间。
-
最后总结:
- 如果项目之前是通过
AS手动打包的形式,在主App工程的build.gradle和AndroidManifest.xml里做了一些渠道包相关信息的配置。现在用了VasDolly、Walle的方案,那么也要对自己工程里相关的代码进行更改。
例如:
-
AndroidManifest.xml里,友盟SDK需要获取应用的渠道名称 -
在主
App工程的build.gradle中,如果写了如下代码:flavorDimensions "versionCode", "serverUrl"
applicationVariants.all { variant -> variant.outputs.all { output -> def fileName if (variant.buildType.name == "release") { fileName = "XXAPP-${variant.productFlavors[0].name}-${variant.versionName}-Android.apk" } else { fileName = "XXAPP-Android.apk" } outputFileName = fileName } }productFlavors { yingyongbao { dimension "versionCode" manifestPlaceholders = [UMENG_CHANNEL_VALUE: "yingyongbao"] } huawei { dimension "versionCode" manifestPlaceholders = [UMENG_CHANNEL_VALUE: "huawei"] } xiaomi { dimension "versionCode" manifestPlaceholders = [UMENG_CHANNEL_VALUE: "xiaomi"] } oppo { dimension "versionCode" manifestPlaceholders = [UMENG_CHANNEL_VALUE: "oppo"] } vivo { dimension "versionCode" manifestPlaceholders = [UMENG_CHANNEL_VALUE: "vivo"] } weibo { dimension "versionCode" manifestPlaceholders = [UMENG_CHANNEL_VALUE: "weibo"] } bzhan { dimension "versionCode" manifestPlaceholders = [UMENG_CHANNEL_VALUE: "bzhan"] } toutiao { dimension "versionCode" manifestPlaceholders = [UMENG_CHANNEL_VALUE: "toutiao"] } guangdiantong { dimension "versionCode" manifestPlaceholders = [UMENG_CHANNEL_VALUE: "guangdiantong"] } baidu { dimension "versionCode" manifestPlaceholders = [UMENG_CHANNEL_VALUE: "baidu"] } urlTest { dimension "serverUrl" buildConfigField("int", "SERVER_TYPE", "1") } urlOnline { dimension "serverUrl" buildConfigField("int", "SERVER_TYPE", "2") } }
这些渠道包相关的配置,都会和 VasDolly 、Walle 的方案有所冲突,所以要按照 VasDolly 、Walle 官方教程里的写法来写。
-
因为
packer-ng-plugin项目已经停止维护,V2签名方案也不支持,所以没去试这个的情况。 -
Walle对项目里的gradle的版本,没有较高版本的要求,这一点对于一些使用较低版本gradle的项目,可以更方便的去集成。也许VasDolly的旧版本也可以做到,但是既然有最新版本的SDK,一般不建议去使用历史的旧版本。
51工具盒子