Android多渠道打包及加固方案

前言

Android多渠道打包已经是老生常谈的问题了,各个大厂也先后开源了自己的打包方案,为我们开发者带来不少便捷。今天我就来谈谈美团的Walle,我在项目中也正是用到了它,也算做个总结和备忘吧。本篇中会提及Walle的基本使用方法以及如何在项目中配置加固使用,当然,最后也稍微会从源码的角度去分析一下这个方案的原理。那么,现在开始吧。

如何使用

参考Walle项目Github首页,操作如下:
在工程目录引入

buildscript {
    dependencies {
        classpath 'com.meituan.android.walle:plugin:1.1.6'
    }
}

在app目录下引入

apply plugin: 'walle'

dependencies {
    // 用于读取渠道号
    compile 'com.meituan.android.walle:library:1.1.6'
}

配置信息呢,可以参考官方说明,我这就简单记录下(copy)了

walle {
    // 指定渠道包的输出路径
    apkOutputFolder = new File("${project.buildDir}/outputs/channels");
    // 定制渠道包的APK的文件名称
    apkFileNameFormat = '${appName}-${packageName}-${channel}-${buildType}-v${versionName}-${versionCode}-${buildTime}.apk';
    // 渠道配置文件 一个渠道占一行
    channelFile = new File("${project.getProjectDir()}/channel")
}

不要忘记获取渠道信息

val channel = WalleChannelReader.getChannel(context)
UMConfigure.init(this, UMENG_APP_KEY, channel, UMConfigure.DEVICE_TYPE_PHONE, "")

接下来只需要在gradle任务执行channelRelease或是执行

gradlew clean assembleReleaseChannels

渠道包就能生成在你指定的目录下面了。

但是这样操作完之后就没问题了吗?显然不是。通常我们发布自己的应用之前,还需要进行应用加固(360或是乐固,本文用的是乐固),加固后会清除apk的签名和渠道信息,需要重新签名然后写入渠道信息。因此,打渠道变成了如下流程:

要满足上面的操作,项目中walle的配置显然就不太合适了,幸亏walle团队也有提供命令行工具walle-cli供我们自行打包,为了方便,我自己写了个简单的脚本,自动上传到乐固加固然后进行签名写入渠道信息,具体可以参考一下autoReinforce的项目说明。

源码分析

有几个问题想问下大家。

  1. 渠道信息写在哪里了呢?为什么写在这个位置呢?
  2. 渠道信息是如何进行读写操作的呢?

第一个问题很简单啦,文档上也说了写在了Apk中的APK Signature Block区块,如下图,之所以写在这个位置是因为v2不会对该区域进行校验。

在payload_reader可以找到写入渠道的逻辑,在讲述逻辑之前,我们首先要了解一下EOCD(End of Centtal Directory)的结构:

offset Bytes Description
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

由于渠道信息是写在APK Signature Block,因此只要找到Center Directory的位置,那么往前就能找到Apk Signing Block的位置。在Walle中,通过循环找到魔数0x06054b50(假设Comment为空,通过增加Comment的长度,确定EOCD block的位置),从而确定comment的长度,再将长度与Comment length对比,只要能确认Comment的长度,就能确认APK Signature Block的位置了。APK Signature Block结构如下表所示:

offset Bytes Description
@+0 8 block的长度(当前长度不计算在内)
@+8 n ID-value值
@-24 8 block的长度
@-16 16 魔数”APK Sig Block 42”

walle渠道信息就是写在ID-value中,在上一步中已经拿到Center Directory的offset,再向前24bytes,取8bytes,就能拿到APK Signature Block的长度了,注意这个长度是不包括前面8个bytes的,在walle中向前多偏移了8个bytes,取首尾block长度对比进行校验,代码片段如下:

        // Find the APK Signing Block. The block immediately precedes the Central Directory.
        if (centralDirOffset < APK_SIG_BLOCK_MIN_SIZE) {
            throw new SignatureNotFoundException(
                    "APK too small for APK Signing Block. ZIP Central Directory offset: "
                            + centralDirOffset);
        }
        // 后面16bytes就是魔数啦 加上前面8bytes的black长度信息,24bytes
        // * 16 bytes: magic
        fileChannel.position(centralDirOffset - 24);
        final ByteBuffer footer = ByteBuffer.allocate(24);
        fileChannel.read(footer);
        footer.order(ByteOrder.LITTLE_ENDIAN);
        // 这里不是很清楚为什么要将魔数拆开来对比?
        if ((footer.getLong(8) != APK_SIG_BLOCK_MAGIC_LO)
                || (footer.getLong(16) != APK_SIG_BLOCK_MAGIC_HI)) {
            throw new SignatureNotFoundException(
                    "No APK Signing Block before ZIP Central Directory");
        }
        // 尾部记录的block长度
        final long apkSigBlockSizeInFooter = footer.getLong(0);
        if ((apkSigBlockSizeInFooter < footer.capacity())
                || (apkSigBlockSizeInFooter > Integer.MAX_VALUE - 8)) {
            throw new SignatureNotFoundException(
                    "APK Signing Block size out of range: " + apkSigBlockSizeInFooter);
        }
        // 将总长度与头部记录的8bytes长度相加
        final int totalSize = (int) (apkSigBlockSizeInFooter + 8);
        final long apkSigBlockOffset = centralDirOffset - totalSize;
        if (apkSigBlockOffset < 0) {
            throw new SignatureNotFoundException(
                    "APK Signing Block offset out of range: " + apkSigBlockOffset);
        }
        fileChannel.position(apkSigBlockOffset);
        final ByteBuffer apkSigBlock = ByteBuffer.allocate(totalSize);
        fileChannel.read(apkSigBlock);
        apkSigBlock.order(ByteOrder.LITTLE_ENDIAN);
        // 头部和尾部的长度杜比校验
        final long apkSigBlockSizeInHeader = apkSigBlock.getLong(0);
        if (apkSigBlockSizeInHeader != apkSigBlockSizeInFooter) {
            throw new SignatureNotFoundException(
                    "APK Signing Block sizes in header and footer do not match: "
                            + apkSigBlockSizeInHeader + " vs " + apkSigBlockSizeInFooter);
        }
        // 拿到APK Signing Block了

再来看看ID-value区域结构

Bytes Description
8 序列长度n(不包括其本身)
4 序列id
n-4 内容

了解了ID-value区域结构那么再贴一下获取custom ID-value的代码

        // APK Sig Block 中的ID-value区域
        final ByteBuffer pairs = sliceFromTo(apkSigningBlock, 8, apkSigningBlock.capacity() - 24);

        final Map<Integer, ByteBuffer> idValues = new LinkedHashMap<Integer, ByteBuffer>(); // keep order

        int entryCount = 0;
        while (pairs.hasRemaining()) {
            entryCount++;
            if (pairs.remaining() < 8) {
                throw new SignatureNotFoundException(
                        "Insufficient data to read size of APK Signing Block entry #" + entryCount);
            }
            // 获取总长度 8bytes
            final long lenLong = pairs.getLong();
            if ((lenLong < 4) || (lenLong > Integer.MAX_VALUE)) {
                throw new SignatureNotFoundException(
                        "APK Signing Block entry #" + entryCount
                                + " size out of range: " + lenLong);
            }
            final int len = (int) lenLong;
            // id开始的位置
            final int nextEntryPos = pairs.position() + len;
            if (len > pairs.remaining()) {
                throw new SignatureNotFoundException(
                        "APK Signing Block entry #" + entryCount + " size out of range: " + len
                                + ", available: " + pairs.remaining());
            }
            // 获取id 4bytes
            final int id = pairs.getInt();
            idValues.put(id, getByteBuffer(pairs, len - 4));

            pairs.position(nextEntryPos);
        }

至此就分析完了如何在APK中去读取插入的渠道信息,顺带了解了一下APK包的结构。最后过一下如何写入渠道信息的吧,流程如下:

  • 通过commentLength\centralDirStartOffset\apkSigningBlockAndOffset找到IdValues的位置
  • 在IdValues block中找到V2签名的位置,判断是否已经签名
  • 判断是否使用V3签名,如果有将长度补成4096的倍数(V3签名会校验)
  • 写入渠道

结语

从多渠道打包,引申出了Apk的签名V2签名逻辑(V1类似,但是是放在EOCD的Comment中),Apk(Zip)包的结构等问题。这里只是简单的做下自我总结,如有疑问欢迎留言,当然你也可以选择去看看官方的文档和大神们的博客。

参考

评论