首页 热点资讯 义务教育 高等教育 出国留学 考研考公
您的当前位置:首页正文

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

2024-12-18 来源:华拓网

写在前面

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

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


正文

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

签名方案V2 (Full APK signature)

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

好了,我们目标很清晰,要添加包含渠道信息的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是采用哪种签名方案(如渠道包在后端生成,后端是无法知道前端用什么签名方案的),我们就需要组合方案来生成渠道包了。

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


参考与DEMO

参考:

DEMO:

Demo项目结构说明.png
显示全文