美团多渠道打包工具Walle源码解析

栏目: Android · 发布时间: 5年前

内容简介:这一次的原理分析仅仅针对在上一家公司的时候,笔者所在的该文章先从安装包

这一次的原理分析仅仅针对 Android Signature V2 Scheme

在上一家公司的时候,笔者所在的 Android 团队经历了 Android Signature V1Android Signature V2 的变更,其中因为未及时从 V1 升级到 V2 而导致上线受阻,当时也紧急更换了新的多渠道打包 工具 来解决问题。在我自己使用多渠道打包工具时,不免对 V2 签名验证的方式有了一丝好奇,想去看看 V2 签名验证和多渠道打包的实现原理。

该文章先从安装包 V2 签名验证入手,再从打包过程中分析 Walle 是怎么绕过签名验证在安装包上加入渠道信息,最后看 Walle 怎么从应用中读取渠道信息。在这里我就不讲 Walle 的使用了,建议读者在看原理前先了解一下使用方式。

二、APK Signature Scheme v2

APK Signature Scheme v2 的签名验证,我们先从官方一张图入手

美团多渠道打包工具Walle源码解析

一般情况下,我们用到的 zip 格式由三个部分组成:文件数据区+中央目录结构+中央目录结束标志,分别对应上图的 Contents Of ZIP entriesCentral 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 对中的 ID0x7109871aID-value 进行校验,对对中的其它 ID-value 是不做检验处理的,那么我们可以向 ID-value 对中添加我们自己的 ID-value ,即渠道信息,这样使安装包可以在增加了渠道信息的情况下通过 Android 的安装包检验。

三、写入渠道信息

通过上面的分析我们得知,写入渠道信息需要修改安装包,这时候肯定会想到使用 gradle 插件对编译后的安装包文件进行修改。如下图所示,我们也可以看到, Walle 的源码目录中的plugin插件。

美团多渠道打包工具Walle源码解析

通过分析 plugingradle 依赖,我们知道这个插件的功能实现由 pluginpayload_writerpayload_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) 中创建了一个 ChannelMakergradle 任务对象,并把这个任务对象放在 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.groovypackaging() 方法中,做了检验操作和一堆条件判断,最后都会调用以 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 Blocksize ,再通过 Offset of start of central directory(中央目录偏移量) - size 来确定 APK Signing Block 的起始偏移量。这时候我们知道了 APK Signing Block 的位置,就可以拿到 ID-value 对去加入渠道信息,再将修改后的 APK Signing BlockCentral DirectoryEOCD 一起写入文件中。

这时候修改工作还没有完成,这里因为改动了 APK Signing Block ,所以在 APK Signing Block 后面的 Central Directory 起始偏移量也跟着改变了。这个起始偏移量是记录在 EOCD 中的,根据EOCD结构修改 Central Directory 的起始偏移量后写入工作就算完成了。

细心的朋友会发现,不是说 V2 签名会保护 EOCD 这一区块吗,修改了里面的超始偏移量还能通过校验吗?其实 Android 系统在使用 V2 校验安装包时,会把 EOCDCentral Directory 的起始偏移量换成 APK Signing Block 的偏移量再进行校验,所以修改 EOCDCentral Directory 的起始偏移量不会影响到校验。

四、读取渠道信息

在了解了 Walle 是如何写入渠道信息之后,去理解读取渠道信息就很简单了。 Walle 先拿到安装包文件,再根据 zip 文件结构找到 APK Signing Block ,从中读取出之前写入的渠道信息。具体的代码懒懒的笔者就不帖了。

五、总结

有一部分的 Coder 总是能做出创新性的东西,基于他们对于技术的理解做出更加方便、灵活的工具。在通过对 Walle 的分析中,我们可以学到,在清楚理解了 zip 结构、 Android 安装包检验原理,运行 gradle plugin ,就可以做出一款便于打包的工具。在这里分享美团多渠道打包工具 Walle 的原理实现,希望各位看了有所收获。


以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

架构真经

架构真经

马丁L. 阿伯特(Martin L. Abbott)、迈克尔T.费舍尔(Michael T. Fisher) / 机械工业出版社 / 2017-4 / 79

前言 感谢你对本书第2版感兴趣!作为一本入门、进修和轻量级的参考手册,本书旨在帮助工程师、架构师和管理者研发及维护可扩展的互联网产品。本书给出了一系列规则,每个规则围绕着不同的主题展开讨论。大部分的规则聚焦在技术上,少数规则涉及一些关键的思维或流程问题,每个规则对构建可扩展的产品都是至关重要的。这些规则在深度和焦点上都有所不同。有些规则是高级的,例如定义一个可以应用于几乎任何可扩展性问题的模......一起来看看 《架构真经》 这本书的介绍吧!

URL 编码/解码
URL 编码/解码

URL 编码/解码

HEX CMYK 转换工具
HEX CMYK 转换工具

HEX CMYK 互转工具

HSV CMYK 转换工具
HSV CMYK 转换工具

HSV CMYK互换工具