笔者现在在负责一个新的
Android
项目,前期功能不太复杂,安装包的体积小,渠道要求也较少,所以打渠道包使用Android Studio
自带的打包方法。原生方法打渠道包大约八分钟左右就搞定了,顺便可以悠闲地享受一下这种打包方式的乐趣。但是,随着重的功能的加入和渠道的增加,原生方法打渠道包就显示有点慢了,所以集成了美团的多渠道打包工具Walle
,顺便看了一下里面的实现原理。
一、概述
这一次的原理分析仅仅针对Android Signature V2 Scheme
。
在上一家公司的时候,笔者所在的Android
团队经历了Android Signature V1
到Android Signature V2
的变更,其中因为未及时从V1
升级到V2
而导致上线受阻,当时也紧急更换了新的多渠道打包工具来解决问题。在我自己使用多渠道打包工具时,不免对V2
签名验证的方式有了一丝好奇,想去看看V2
签名验证和多渠道打包的实现原理。
该文章先从安装包V2
签名验证入手,再从打包过程中分析Walle
是怎么绕过签名验证在安装包上加入渠道信息,最后看Walle
怎么从应用中读取渠道信息。在这里我就不讲Walle
的使用了,建议读者在看原理前先了解一下使用方式。
二、APK Signature Scheme v2
APK Signature Scheme v2
的签名验证,我们先从官方一张图入手
一般情况下,我们用到的zip
格式由三个部分组成:文件数据区+中央目录结构+中央目录结束标志,分别对应上图的Contents Of ZIP entries
、Central Directory``、End of Central Directory
(下文简称为EOCD
)。正如图中After signing
所示,APK Signature Scheme v2
是在ZIP文件格式的 Central Directory
区块所在文件位置的前面添加一个APK Signing Block
区块,用于检验以上三个区块的完整性。
APK Signing Block
区块的构成是这样的
偏移 | 字节数 | 描述 |
---|---|---|
@+0 | 8 | 这个Block的长度(本字段的长度不计算在内) |
@+8 | n | 一组ID-value |
@-24 | 8 | 这个Block的长度(和第一个字段一样值) |
@-16 | 16 | 魔数 “APK Sig Block 42” |
区块2中APK Signing Block
是由这几部分组成:2个用来标示这个区块长度的8字节 + 这个区块的魔数 + 这个区块所承载的数据(ID-value)。
其中Android
是通过ID-value
对中的ID
为0x7109871a
的ID-value
进行校验,对对中的其它ID-value
是不做检验处理的,那么我们可以向ID-value
对中添加我们自己的ID-value
,即渠道信息,这样使安装包可以在增加了渠道信息的情况下通过Android
的安装包检验。
三、写入渠道信息
通过上面的分析我们得知,写入渠道信息需要修改安装包,这时候肯定会想到使用gradle
插件对编译后的安装包文件进行修改。如下图所示,我们也可以看到,Walle
的源码目录中的plugin插件。
通过分析plugin
的gradle
依赖,我们知道这个插件的功能实现由plugin
、payload_writer
、payload_reader
三个模块构成。我们先看实现了org.gradle.api.Plugin
的GradlePlugin
类。抛开异常检查和配置相关的代码,我们从主功能代码开始看。
@Override
void apply(Project project) {
...
applyExtension(project);
applyTask(project);
}
void applyTask(Project project) {
project.afterEvaluate {
project.android.applicationVariants.all { BaseVariant variant ->
...
ChannelMaker channelMaker = project.tasks.create("assemble${variantName}Channels", ChannelMaker);
channelMaker.targetProject = project;
channelMaker.variant = variant;
channelMaker.setup();
channelMaker.dependsOn variant.assemble;
}
}
}
在gradle脚本运行时会调用实现了org.gradle.api.Plugin
接口的类的void apply(Project project)
方法,我们从该方法开始跟踪。这里主要调用了applyTask(project)
。而applyTask(project)
中创建了一个ChannelMaker
的gradle
任务对象,并把这个任务对象放在assemble
任务(即完成了打包任务)后,可见Walle
是通过ChannelMaker
保存渠道信息的。接下来,我们便看ChannelMaker
这个groovy
文件。
@TaskAction
public void packaging() {
...
checkV2Signature(apkFile)
...
if (targetProject.hasProperty(PROPERTY_CHANNEL_LIST)) {
...
channelList.each { channel ->
generateChannelApk(apkFile, channelOutputFolder, nameVariantMap, channel, extraInfo, null)
}
} else if (targetProject.hasProperty(PROPERTY_CONFIG_FILE)) {
...
generateChannelApkByConfigFile(configFile, apkFile, channelOutputFolder, nameVariantMap)
} else if (targetProject.hasProperty(PROPERTY_CHANNEL_FILE)) {
...
generateChannelApkByChannelFile(channelFile, apkFile, channelOutputFolder, nameVariantMap)
} else if (extension.configFile instanceof File) {
...
generateChannelApkByConfigFile(extension.configFile, apkFile, channelOutputFolder, nameVariantMap)
} else if (extension.channelFile instanceof File) {
...
generateChannelApkByChannelFile(extension.channelFile, apkFile, channelOutputFolder, nameVariantMap)
}
}
...
}
在ChannelMaker.groovy
的packaging()
方法中,做了检验操作和一堆条件判断,最后都会调用以generateChannel
为开头命名的方法。至于判断了什么,我们不要在意这些细节。这些名字以generateChannel
开头的方法最后都会调用到generateChannelApk()
,看代码:
def generateChannelApk(File apkFile, File channelOutputFolder, Map nameVariantMap, channel, extraInfo, alias) {
...
ChannelWriter.put(channelApkFile, channel, extraInfo)
...
}
这个方法中比较关键的一段代码是ChannelWriter.put(channelApkFile, channel, extraInfo)
即传入文件地址、渠道信息、extra
信息后交由ChannelWriter
完成写入工作。
ChannelWriter
封装在由payload_writer
模块中,里面封装了方法调用。其中void put(final File apkFile, final String channel, final Map
间接调用了void putRaw(final File apkFile, final String string, final boolean lowMemory)
:
public static void putRaw(final File apkFile, final String string, final boolean lowMemory) throws IOException, SignatureNotFoundException {
PayloadWriter.put(apkFile, ApkUtil.APK_CHANNEL_BLOCK_ID, string, lowMemory);
}
这时调用进入了PayloadWriter
类,渠道信息写入的关键代码便在这里面。这里从void put(final File apkFile, final int id, final ByteBuffer buffer, final boolean lowMemory)
调用到void putAll(final File apkFile, final Map
:
public static void putAll(final File apkFile, final Map idValues, final boolean lowMemory) throws IOException, SignatureNotFoundException {
handleApkSigningBlock(apkFile, new ApkSigningBlockHandler() {
@Override
public ApkSigningBlock handle(final Map originIdValues) {
if (idValues != null && !idValues.isEmpty()) {
originIdValues.putAll(idValues);
}
final ApkSigningBlock apkSigningBlock = new ApkSigningBlock();
final Set> entrySet = originIdValues.entrySet();
for (Map.Entry entry : entrySet) {
final ApkSigningPayload payload = new ApkSigningPayload(entry.getKey(), entry.getValue());
apkSigningBlock.addPayload(payload);
}
return apkSigningBlock;
}
}, lowMemory);
}
在void putAll()
中调用了handleApkSigningBlock()
,顾名思义,这个方法是处理APK Signing Block
的,将渠道信息写入Block
中。
static void handleApkSigningBlock(final File apkFile, final ApkSigningBlockHandler handler, final boolean lowMemory) throws IOException, SignatureNotFoundException {
RandomAccessFile fIn = null;
FileChannel fileChannel = null;
try {
// 由安装包路径构建一个RandomAccessFile对象,用于自由访问文件位置
fIn = new RandomAccessFile(apkFile, "rw");
// 获取fileChannel,通过fileChannel写文件
fileChannel = fIn.getChannel();
// 获取zip文件的comment长度
final long commentLength = ApkUtil.getCommentLength(fileChannel);
// 找到Central Directory的初始偏移量
final long centralDirStartOffset = ApkUtil.findCentralDirStartOffset(fileChannel, commentLength);
// 找到APK Signing Block
final Pair apkSigningBlockAndOffset = ApkUtil.findApkSigningBlock(fileChannel, centralDirStartOffset);
final ByteBuffer apkSigningBlock2 = apkSigningBlockAndOffset.getFirst();
final long apkSigningBlockOffset = apkSigningBlockAndOffset.getSecond();
// 找到APK Signature Scheme v2的ID-value
final Map originIdValues = ApkUtil.findIdValues(apkSigningBlock2);
// 找到V2签名信息
final ByteBuffer apkSignatureSchemeV2Block = originIdValues.get(ApkUtil.APK_SIGNATURE_SCHEME_V2_BLOCK_ID);
// 校验签名信息是否存在
if (apkSignatureSchemeV2Block == null) {
throw new IOException(
"No APK Signature Scheme v2 block in APK Signing Block");
}
final ApkSigningBlock apkSigningBlock = handler.handle(originIdValues);
if (apkSigningBlockOffset != 0 && centralDirStartOffset != 0) {
// read CentralDir
fIn.seek(centralDirStartOffset);
byte[] centralDirBytes = null;
File tempCentralBytesFile = null;
// read CentralDir
...
centralDirBytes = new byte[(int) (fileChannel.size() - centralDirStartOffset)];
fIn.read(centralDirBytes);
...
//update apk sign
fileChannel.position(apkSigningBlockOffset);
final long length = apkSigningBlock.writeApkSigningBlock(fIn);
// update CentralDir
...
// store CentralDir
fIn.write(centralDirBytes);
...
// update length
fIn.setLength(fIn.getFilePointer());
// update CentralDir Offset
// End of central directory record (EOCD)
// Offset Bytes Description[23]
// 0 4 End of central directory signature = 0x06054b50
// 4 2 Number of this disk
// 6 2 Disk where central directory starts
// 8 2 Number of central directory records on this disk
// 10 2 Total number of central directory records
// 12 4 Size of central directory (bytes)
// 16 4 Offset of start of central directory, relative to start of archive
// 20 2 Comment length (n)
// 22 n Comment
// 定位到EOCD中Offset of start of central directory,即central directory中央目录的超始位置
fIn.seek(fileChannel.size() - commentLength - 6);
// 6 = 2(Comment length) + 4 (Offset of start of central directory, relative to start of archive)
final ByteBuffer temp = ByteBuffer.allocate(4);
temp.order(ByteOrder.LITTLE_ENDIAN);
// 写入修改APK Signing Block之后的central directory中央目录的超始位置
temp.putInt((int) (centralDirStartOffset + length + 8 - (centralDirStartOffset - apkSigningBlockOffset)));
// 8 = size of block in bytes (excluding this field) (uint64)
temp.flip();
fIn.write(temp.array());
...
好了,写入渠道信息的代码大致上都在这里了,结合上面的代码和注释我们来做一下分析。上文我们提到,通过往APK Signing Block
写入渠道信息完成多渠道打包,这里简要地说明一下流程。我们是这样从安装包中找到APK Signing Block
的:
从zip
结构中的EOCD
出发,根据EOCD
结构定位到Offset of start of central directory(中央目录偏移量)
,通过中央目录偏移量找到中央目录的位置。因为APK Signing Block
是在中央目录之前,所以我们可以从中央目录偏移量往前找到APK Signing Block
的size
,再通过Offset of start of central directory(中央目录偏移量)
- size
来确定APK Signing Block
的起始偏移量。这时候我们知道了APK Signing Block
的位置,就可以拿到ID-value
对去加入渠道信息,再将修改后的APK Signing Block
和Central Directory
同EOCD
一起写入文件中。
这时候修改工作还没有完成,这里因为改动了APK Signing Block
,所以在APK Signing Block
后面的Central Directory
起始偏移量也跟着改变了。这个起始偏移量是记录在EOCD
中的,根据EOCD结构修改Central Directory
的起始偏移量后写入工作就算完成了。
细心的朋友会发现,不是说V2
签名会保护EOCD
这一区块吗,修改了里面的超始偏移量还能通过校验吗?其实Android
系统在使用V2
校验安装包时,会把EOCD
的Central Directory
的起始偏移量换成APK Signing Block
的偏移量再进行校验,所以修改EOCD
中Central Directory
的起始偏移量不会影响到校验。
四、读取渠道信息
在了解了Walle
是如何写入渠道信息之后,去理解读取渠道信息就很简单了。Walle
先拿到安装包文件,再根据zip
文件结构找到APK Signing Block
,从中读取出之前写入的渠道信息。具体的代码懒懒的笔者就不帖了。
五、总结
有一部分的Coder
总是能做出创新性的东西,基于他们对于技术的理解做出更加方便、灵活的工具。在通过对Walle
的分析中,我们可以学到,在清楚理解了zip
结构、Android
安装包检验原理,运行gradle plugin
,就可以做出一款便于打包的工具。在这里分享美团多渠道打包工具Walle
的原理实现,希望各位看了有所收获。