美文网首页Android进阶征途Android开发Android技术知识
Android多渠道包生成最佳实践(二)

Android多渠道包生成最佳实践(二)

作者: 安卓大叔 | 来源:发表于2018-04-15 18:12 被阅读73次

写在前面

继昨天在 Android多渠道包生成最佳实践(一) 文章中介绍了两种多渠道生成包的方案:

  • META-INF目录添加渠道文件
  • Apk文件末尾追加渠道注释

今天来介绍最后一种方案:针对Android7.0 新增的V2签名方案的Apk添加渠道ID-value。在读这篇文章前,你需要对zip格式和V2签名等知识等有一定的了解:

Apk签名方案 V2 (需要科学上网)
ZIP文件格式分析


正文

在实践前,我们来简单地了解下Google引入的新的签名方案。

签名方案V2 (Full APK signature)

Android7.0引入一项新的应用签名方案 Apk签名方案 V2,它是一个对全文件进行签名的方案,能提供更快的应用安装时间、对未授权APK文件的更改提供更多保护。

顾名思义,新的签名方案是对整个Apk文件进行签名校验的,对于 Android多渠道包生成最佳实践(一) 里介绍的两种多渠道生成包方案显然是不适用了,因为它们都对Apk文件进行了修改,所以用新的签名方案的Apk在安装校验是就会不通过。我们来对比下新旧两种签名方案有何区别:

V1和V2签名对比(图来自Google官方文档).png

可以看到,新的签名方案在Zip文件中新增了一个 APK Siging Block,而这个新增的数据块就是保存签名信息的。而Contents of ZIP entriesZIP Central DirectoryEnd of Central Directory是受保护的,在签名后任何对它们的修改都逃不过新的应用签名方案的检查。

以此看来,我们是无法对Contents of ZIP entriesZIP Central DirectoryEnd of Central Directory做任何修改的了,但能不能针对 APK Siging Block做些手脚呢?我们不妨先来看下 APK Siging Block的格式:

偏移 字节数 描述
0 8 签名块长度(本字段的长度不计算在内)
8 n 一组ID=value(安卓的签名保存在此)
8+n 8 签名块长度(和第一个字段值一致)
16+n 16 魔数 APK Sig Block 42”

我们注意到 ID-value,它由一个8字节的长度标示+4字节的ID+它的负载组成。V2的签名信息是以固定的ID值(0x7109871a)的ID-value来保存在这个区块中,也就是说它是可以有若干个这样的ID-value来组成:

Length ID Data
··· ··· ···
签名长度 0x7109871a 安卓签名信息
··· ··· ···

另外,签名校验是不会校验时,会忽略除了安卓签名信息的其他ID-value的,那么我们就可以把渠道号添加到ID-value里,就能实现多渠道生成包了!

需要另外提醒的是, APK Siging Block是使用小端模式来保存字节的,我们读的时候也必须用小端模式来读,否则会出错。

小端模式不了解的童鞋看下百度百科怎么解释:

小端模式,是指数据的高字节保存在内存的高地址中,而数据的低字节保存在内存的低地址中,这种存储模式将地址的高低和数据位权有效地结合起来,高地址部分权值高,低地址部分权值低。

也就是说,如 0x1234,用小端模式保存的话,就是:
byte[0] = 0x34 -- 低字节保存在低地址
byte[1] = 0x12 -- 高字节保存在高地址

下面我们来实践下。

方案三:针对Android7.0 新增的V2签名方案的Apk添加渠道ID-value

说明:文本使用的代码大部分来自于美团的开源项目 walle(这里称赞下美团的技术团队,阅读其代码时真是赏心悦目,值得大家参考借鉴!)。因为walle项目为了兼容性和适配性,做了很多处理,项目本身有很多个子项目。所以我把核心部分的代码抽出来,精简化。而我们这里只需探索如何针对Android7.0 新增的V2签名方案的Apk添加渠道ID-value的核心代码就可以了,知道了原理,就可以根据自己的项目需求来做针对性的修改,或使用walle时避免不必要的坑。

好了,我们目标很清晰,要添加包含渠道信息的ID-value的到Apk文件的 APK Siging Block里。我们先来理理思路:

  1. 寻找 APK Siging Block数据块
  2. 对ID-value进行扩展,写入包含渠道信息的ID-value

似乎难度也不大,我们一步一步来看。

1.寻找 APK Siging Block数据块
根据上面分析我们知道, APK Siging Block是在紧接着Contents of ZIP entries后,在ZIP Central Directory前,我们有什么办法找到它所在文件的具体位置呢?嗯,很简单,我们通过Zip的 End of central directory record (EOCD)可以知道ZIP Central Directory的具体位置,我们再来回顾下EOCD的格式:

Offset Bytes Desctiption
0 4 End of central directory signature = 0x06054b50
4 2 Number of this disk
6 2 Number of the disk with the start of the central directory
8 2 Total number of entries in the central directory on this disk
10 2 Total number of entries in the central directory
12 4 Size of central directory (bytes)
16 2 Offset of start of central directory with respect to the starting disk number
20 2 Comment length(n)
22 n Comment

可以注意到,EOCD中的 Offset of start of central directory with respect to the starting disk number 是记录了ZIP Central Directory的具体位置,也即是离文件头的偏移。而ZIP Central Directory是紧跟APK Siging Block的,所以我们可以通过ZIP Central Directory找到签名块的具体位置。

先找到ZIP Central Directory的位置:

public static long findCentralDirStartOffset(final FileChannel fileChannel, final long commentLength) throws IOException {
    // 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
    // For a zip with no archive comment, the
    // end-of-central-directory record will be 22 bytes long, so
    // we expect to find the EOCD marker 22 bytes from the end.

    final ByteBuffer zipCentralDirectoryStart = ByteBuffer.allocate(4);
    zipCentralDirectoryStart.order(ByteOrder.LITTLE_ENDIAN);
    fileChannel.position(fileChannel.size() - commentLength - 6); // 6 = 2 (Comment length) + 4 (Offset of start of central directory, relative to start of archive)
    fileChannel.read(zipCentralDirectoryStart);
    final long centralDirStartOffset = zipCentralDirectoryStart.getInt(0);
    return centralDirStartOffset;
}

再根据ZIP Central Directory的位置,向上读APK Signing Block

public static Pair<ByteBuffer, Long> findApkSigningBlock(
        final FileChannel fileChannel, final long centralDirOffset) throws IOException, SignatureNotFoundException {

    // Find the APK Signing Block. The block immediately precedes the Central Directory.

    // FORMAT:
    // OFFSET       DATA TYPE  DESCRIPTION
    // * @+0  bytes uint64:    size in bytes (excluding this field)
    // * @+8  bytes payload
    // * @-24 bytes uint64:    size in bytes (same as the one above)
    // * @-16 bytes uint128:   magic

    if (centralDirOffset < APK_SIG_BLOCK_MIN_SIZE) {
        throw new SignatureNotFoundException(
                "APK too small for APK Signing Block. ZIP Central Directory offset: "
                        + centralDirOffset);
    }
    // Read the magic and offset in file from the footer section of the block:
    // * uint64:   size of block
    // * 16 bytes: magic
    fileChannel.position(centralDirOffset - 24);
    final ByteBuffer footer = ByteBuffer.allocate(24);
    fileChannel.read(footer);
    footer.order(ByteOrder.LITTLE_ENDIAN); // 小端模式,高字节保存在高地址
    // 是否存在V2签名魔数:APK Sig Block 42
    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");
    }
    // Read and compare size fields
    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);
    }
    final int totalSize = (int) (apkSigBlockSizeInFooter + 8); // + 8 (签名块第一个Block长度字节数)
    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);
    }
    return Pair.of(apkSigBlock, apkSigBlockOffset);
}

2. 对ID-value进行扩展,写入包含渠道信息的ID-value
写ID-value就很简单了,我们要先拿出原来Apk已存在的ID-value,然后把我们自己的渠道信息保存在新的ID-value里,再把新的旧的ID-value一起写进Apk:

public static void writeApkSigningBlock(final File apkFile, final Map<Integer, ByteBuffer> idValues) throws IOException, SignatureNotFoundException {
    RandomAccessFile fIn = null;
    FileChannel fileChannel = null;
    try {
        fIn = new RandomAccessFile(apkFile, "rw");
        fileChannel = fIn.getChannel();
        // 获取注释长度
        final long commentLength = ApkUtil.getCommentLength(fileChannel);
        // 获取核心目录偏移
        final long centralDirStartOffset = ApkUtil.findCentralDirStartOffset(fileChannel, commentLength);
        final Pair<ByteBuffer, Long> apkSigningBlockAndOffset
                = ApkUtil.findApkSigningBlock(fileChannel, centralDirStartOffset); // 获取签名块
        final ByteBuffer oldApkSigningBlock = apkSigningBlockAndOffset.getFirst();
        final long apkSigningBlockOffset = apkSigningBlockAndOffset.getSecond();

        // 获取apk已有的ID-value
        final Map<Integer, ByteBuffer> originIdValues = ApkUtil.findIdValues(oldApkSigningBlock);
        // 查找Apk的签名信息,ID值固定为:0x7109871a
        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");
        }

        // // 获取所有 ID-value
        final ApkSigningBlock apkSigningBlock = genApkSigningBlock(idValues, originIdValues);

        if (apkSigningBlockOffset != 0 && centralDirStartOffset != 0) {
            // 读取核心目录的内容
            fIn.seek(centralDirStartOffset);
            byte[] centralDirBytes;
            centralDirBytes = new byte[(int) (fileChannel.size() - centralDirStartOffset)];
            fIn.read(centralDirBytes);

            // 更新签名块
            fileChannel.position(apkSigningBlockOffset);
            // 写入新的签名块,返回的长度是不包含签名块头部的 Size of block(8字节)
            final long lengthExcludeHSOB = apkSigningBlock.writeApkSigningBlock(fIn);

            // 更新核心目录
            fIn.write(centralDirBytes);

            // 更新文件的总长度
            fIn.setLength(fIn.getFilePointer());

            // 更新 EOCD 所记录的核心目录的偏移
            // 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

            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);
            long oldSignBlockLength = centralDirStartOffset - apkSigningBlockOffset; // 旧签名块字节数
            long newSignBlockLength = lengthExcludeHSOB + 8; // 新签名块字节数, 8 = size of block in bytes (excluding this field) (uint64)
            long extraLength = newSignBlockLength - oldSignBlockLength;
            temp.putInt((int) (centralDirStartOffset + extraLength));
            temp.flip();
            fIn.write(temp.array());
        }
    } finally {
        if (fileChannel != null) {
            fileChannel.close();
        }
        if (fIn != null) {
            fIn.close();
        }
    }
}

private static ApkSigningBlock genApkSigningBlock(final Map<Integer, ByteBuffer> idValues,
                                       final Map<Integer, ByteBuffer> originIdValues) {
    // 把已有的和新增的 ID-value 添加到 payload 列表
    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;
}

注意上面在写完ID-value后,因为APK Signing Block的长度变化了,相应的Apk文件大小和ZIP Central Directory的偏移也会变化,要同步更新。

至此,三种方案已经讲完了。建议还是下载demo看下细节,因为上面的代码只是截取部分来讲解,可能阅读起来有点头不接尾。但原理我们是清晰的了,只要知道了原理,就很容易实现,但还是希望大家能自己实践下,只有自己实践后,才能有更深刻的理解。


写在最后

介绍了三种多渠道生成包的方案,其中方案一和二是针对旧签名方案的,而方案三是针对新签名方案的。在实际开发中,如果我们无法确保Apk是采用哪种签名方案(如渠道包在后端生成,后端是无法知道前端用什么签名方案的),我们就需要组合方案来生成渠道包了。

正如 Android多渠道包生成最佳实践(一) 中介绍的,方案二(Apk文件末尾追加渠道注释)比方案一(META-INF目录添加渠道文件)性能更优,所以我们可以优先采取 方案二和方案三 的组合来生成渠道包。

好了,三种多渠道生成包的方案到此介绍完了,不知你有没收获呢?或者你有更好的方案,欢迎在评论区留言~


参考与DEMO

参考:

新一代开源Android渠道包生成工具Walle
Apk签名方案 V2 (需要科学上网)
ZIP文件格式分析

DEMO:

MCRelease

Demo项目结构说明.png

相关文章

网友评论

    本文标题:Android多渠道包生成最佳实践(二)

    本文链接:https://www.haomeiwen.com/subject/asidkftx.html