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

笔者现在在负责一个新的Android项目,前期功能不太复杂,安装包的体积小,渠道要求也较少,所以打渠道包使用Android Studio自带的打包方法。原生方法打渠道包大约八分钟左右就搞定了,顺便可以悠闲地享受一下这种打包方式的乐趣。但是,随着重的功能的加入和渠道的增加,原生方法打渠道包就显示有点慢了,所以集成了美团的多渠道打包工具Walle,顺便看了一下里面的实现原理。

一、概述

这一次的原理分析仅仅针对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源码解析_第1张图片

一般情况下,我们用到的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源码解析_第2张图片

通过分析plugingradle依赖,我们知道这个插件的功能实现由pluginpayload_writerpayload_reader三个模块构成。我们先看实现了org.gradle.api.PluginGradlePlugin类。抛开异常检查和配置相关的代码,我们从主功能代码开始看。

    @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)中创建了一个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 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 idValues, final boolean lowMemory)

    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 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的原理实现,希望各位看了有所收获。

你可能感兴趣的:(美团多渠道打包工具Walle源码解析)