美团多渠道打包工具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的签名验证,我们先从官方一张图入手

一般情况下,我们用到的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插件。

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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,125评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,293评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,054评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,077评论 1 291
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,096评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,062评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,988评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,817评论 0 273
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,266评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,486评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,646评论 1 347
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,375评论 5 342
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,974评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,621评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,796评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,642评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,538评论 2 352

推荐阅读更多精彩内容