美团的 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 Block
3.ZIP Central Directory
4.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
开发, 该工具主要有四部分组成:
1. 用于写入ID-value
信息的Java
类库
2.Gradle
构建插件用来和Android的打包流程进行结合
3. 用于读取ID-value
信息的Java
类库
4. 用于供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
,一般不建议去使用历史的旧版本。