笔者如今在负责一个新的
Android
项目,前期功能不太复杂,安装包的体积小,渠道要求也较少,因此打渠道包使用Android Studio
自带的打包方法。原生方法打渠道包大约八分钟左右就搞定了,顺即可以清闲地享受一下这种打包方式的乐趣。可是,随着重的功能的加入和渠道的增长,原生方法打渠道包就显得有点慢了,因此集成了美团的多渠道打包工具Walle
,顺便看了一下里面的实现原理。android
这一次的原理分析仅仅针对Android Signature V2 Scheme
。api
在上一家公司的时候,笔者所在的Android
团队经历了Android Signature V1
到Android Signature V2
的变动,其中由于未及时从V1
升级到V2
而致使上线受阻,当时也紧急更换了新的多渠道打包工具来解决问题。在我本身使用多渠道打包工具时,难免对V2
签名验证的方式有了一丝好奇,想去看看V2
签名验证和多渠道打包的实现原理。bash
该文章先从安装包V2
签名验证入手,再从打包过程当中分析Walle
是怎么绕过签名验证在安装包上加入渠道信息,最后看Walle
怎么从应用中读取渠道信息。在这里我就不讲Walle
的使用了,建议读者在看原理前先了解一下使用方式。app
APK Signature Scheme v2
的签名验证,咱们先从官方一张图入手dom
通常状况下,咱们用到的zip
格式由三个部分组成:文件数据区+中央目录结构+中央目录结束标志,分别对应上图的Contents Of ZIP entries
、Central Directory``、End of Central Directory
(下文简称为EOCD
)。正如图中After signing
所示,APK Signature Scheme v2
是在ZIP文件格式的 Central Directory
区块所在文件位置的前面添加一个APK Signing Block
区块,用于检验以上三个区块的完整性。ide
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)。gradle
其中Android
是经过ID-value
对中的ID
为0x7109871a
的ID-value
进行校验,对对中的其它ID-value
是不作检验处理的,那么咱们能够向ID-value
对中添加咱们本身的ID-value
,即渠道信息,这样使安装包能够在增长了渠道信息的状况下经过Android
的安装包检验。ui
经过上面的分析咱们得知,写入渠道信息须要修改安装包,这时候确定会想到使用gradle
插件对编译后的安装包文件进行修改。以下图所示,咱们也能够看到,Walle
的源码目录中的plugin插件。this
经过分析plugin
的gradle
依赖,咱们知道这个插件的功能实现由plugin
、payload_writer
、payload_reader
三个模块构成。咱们先看实现了org.gradle.api.Plugin<Project>
的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<Project>
接口的类的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<String, String> extraInfo)
间接调用了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<Integer, ByteBuffer> idValues, final boolean lowMemory)
:
public static void putAll(final File apkFile, final Map<Integer, ByteBuffer> idValues, final boolean lowMemory) throws IOException, SignatureNotFoundException {
handleApkSigningBlock(apkFile, new ApkSigningBlockHandler() {
@Override
public ApkSigningBlock handle(final Map<Integer, ByteBuffer> originIdValues) {
if (idValues != null && !idValues.isEmpty()) {
originIdValues.putAll(idValues);
}
final ApkSigningBlock apkSigningBlock = new ApkSigningBlock();
final Set<Map.Entry<Integer, ByteBuffer>> entrySet = originIdValues.entrySet();
for (Map.Entry<Integer, ByteBuffer> 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<ByteBuffer, Long> apkSigningBlockAndOffset = ApkUtil.findApkSigningBlock(fileChannel, centralDirStartOffset);
final ByteBuffer apkSigningBlock2 = apkSigningBlockAndOffset.getFirst();
final long apkSigningBlockOffset = apkSigningBlockAndOffset.getSecond();
// 找到APK Signature Scheme v2的ID-value
final Map<Integer, ByteBuffer> 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
的原理实现,但愿各位看了有所收获。