Android—V2签名下多渠道快速打包方案

2,515 阅读10分钟

笔者是MIUI系统应用组的开发,之前发布APP时只有应用商店这一个渠道,因此只需给应用商店提供一个APK即可。不过最近应用开发了一个外发版本,该版本有广告、push等多个下载渠道,为了统计各渠道的日活、转化率等信息,需要进行多渠道打包,目前腾讯的VasDolly和美团的Walle这两个框架都实现了V2签名下的多渠道快速打包,但是项目并不希望引入第三方库,因此选择独立开发。

一、多渠道打包现状

1. Android自带多渠道打包

在Manifest的application标签下添加meta-data标签,表示这是一个渠道号信息的占位符。

<meta-data
    android:name="channel"
    android:value="${APP_CHANNEL}"/>

随后在build.gradle中进行如下配置,buildTypes中的release模块指明使用signingConfigs中的release打包配置,在productFlavors中定义了shop和push两个渠道。

android {
    signingConfigs {
        release {
            storeFile file('/Users/....../default.jks')
            storePassword '123456'
            keyAlias 'default'
            keyPassword '123456'
            v1SigningEnabled true
            v2SigningEnabled true
        }
    }

    buildTypes {
        debug {
            ......
        }
        release {
            minifyEnabled true
            shrinkResources true
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
            signingConfig signingConfigs.release
        }
    }

    productFlavors{
        shop {
            manifestPlaceholders = [APP_CHANNEL:"shop"]
        }
        push {
            manifestPlaceholders = [APP_CHANNEL:"push"]
        }
    }
}

配置完后,在终端使用./gradlew assembleRelease命令即可在app/build文件夹下生成所有渠道的apk包。如果只需要打某个渠道的包,如push渠道,可以使用./gradlew assemblePushRelease命令。

该方式下,各渠道包的打包耗时都是一样的,对于大型项目来说,一个渠道包需要四五分钟的打包时间,在渠道较多的情况下,该打包方式效率低下。

2. 解压&重签名

使用该打包方式时需要一个初始APK,将其解压后,删除之前的签名信息,添加渠道信息后再压缩签名即可。相比于第一种方式,该打包方式在效率上有所提升,但是压缩/签名也是比较耗时的。

3. 在APK中插入信息

该方式直接在APK文件中插入渠道信息,即使有几百个渠道,在有初始APK的情况下,也只需要几秒钟的时间。该打包方式的关键点在于,如何在插入额外信息后,APK还能通过Android签名的校验。

先来了解一下Android的签名机制:为了保证APK发布后不被第三方篡改,在打包时,签名机制会对文件提取摘要并存入APK中。在安装时,系统会提取文件的摘要,并将其与之前存入APK中的摘要信息对比,只有这两个摘要完全一致才安装成功。

Android目前提供了V1、V2、V3三种签名技术,这里简单介绍下,详细见参考[3]

① V1签名:V1签名是针对Jar的签名技术,在Android7之前使用。签名时会对所有的class文件与资源文件提取摘要,随后新建MATE-INF文件夹并将摘要存入其中,而META-INF文件夹本身不参与签名校验。 上面提到,快速多渠道打包的关键在于添加额外信息后APK还能通过签名校验,由于V1签名下META-INF文件夹中的内容不参与签名校验,因此只需在META-INF下增加一个文件描述渠道号即可。

② V2签名:Android7及以上版本使用V2签名技术,相比于V1签名技术,V2签名在效率和安全性上有了较大提升。使用V1签名的APK在安装时需要对比所有class文件和资源文件的摘要,效率较低;而使用V2签名打包时,会在生成初始APK后对它的每1M提取摘要,再将摘要块插入到APK中,也就是下图的APK Signing Block。因此在安装时,需要对比的摘要数量比V1少了很多,安装更快。

V2签名机制.png

③ V3签名:相比于V2签名没有本质提升,提供了密钥轮换的功能,并对签名块的大小进行了限制。

二、Zip文件格式

APK本身是ZIP格式,V2签名的原理就是在ZIP中插入签名块来存储摘要信息。ZIP文件的格式如下,其包含文件信息、中央目录区及目录信息3个部分。

ZIP文件格式.png

在解析ZIP文件时,先找到最末端的目录信息,得到中央目录相对起始位置的偏移,随后通过中央目录得到各个文件的位置,再解析各个文件即可。

V2签名机制在文件信息与中央目录这两部分中间添加了签名块描述摘要信息,将签名块添加在此处后,只需修改目录信息中的中央目录偏移即可,ZIP文件还是符合解析规范。

签名前后ZIP文件格式.png

签名块通过一个个键值对存储信息,它的大小为4096的倍数,其数据结构如下所示。签名块的键值对中,只有第一个键值对保存了真正的摘要信息,而该键值对的大小并不固定,为了使键值对的大小符合4096的倍数,一般还会有value全为0的键值对。

签名块数据结构.png

三、插入签名信息

经过ZIP文件格式和签名块结构的分析,可以发现签名块本身是不参与签名校验的,因此在签名块中插入渠道信息即可通过V2签名机制的校验。

一般来说签名块中有value全为0的键值对,用于将签名块的大小凑整为4096的倍数,我们可以选择将该键值对缩小,留出空间存放渠道信息,如下所示。如果签名块中没有value为空的键值对或者剩余空间不够存储渠道信息,那么需要将签名块的大小再扩大4096字节,不过我目前还没遇到这种情况。

插入渠道信息示意.jpg

下面详细介绍如何在V2/V3签名的APK中插入渠道信息。

第一步:判断当前APK是否使用了V2/V3签名,可以通过ZIP末端的目录信息定位到中央目录,而签名块位于中央目录前,如果中央目录前16字节为"APK Sig Block 42",则该APK使用了V2/V3签名,相关代码如下。

    {
        ......
        zipFile = new ZipFile(apkPath)
        String zipComment = zipFile.getComment()
        int commentLength = 0
        if (zipComment != null && zipComment.length() > 0) {
            commentLength = zipComment.getBytes().length
        }
        File file = new File(apkPath)
        long fileLength = file.length()
        // 获取zip中央目录结束标记,以小端模式读取
        byte[] centralEndSignBytes = readReserveData(
                file, fileLength - 22 - commentLength, 4)
        int centralEndSign = ByteBuffer.wrap(centralEndSignBytes).getInt()
        if (centralEndSign != 0x06054b50) {
            println("zip中央目录结束标记错误!!!!!!!!!!!!!!!!!")
            return
        }
        long eoCdrLength = commentLength + 22
        long eoCdrOffset = file.length() - eoCdrLength
        // 中央目录区的偏移量保存在 EoCDR 开始位置 16 字节处, 一共 4 字节
        long pointer = eoCdrOffset + 16
        // 获取中央目录偏移,以小端模式读取
        byte[] pointerBuffer = readReserveData(file, pointer, 4)
        int centralDirectoryOffset = ByteBuffer.wrap(pointerBuffer).getInt()
        // 读取字符串,不用逆置
        byte[] buffer = readDataByOffset(file, centralDirectoryOffset - 16, 16)
        String checkV2Signature = new String(buffer, StandardCharsets.US_ASCII)
        if (!checkV2Signature.equals(SIGNATURE_MAGIC_NUMBER)) {
            println("当前未使用V2签名!!!!!!!!!!!!!!!!!!!!!!!")
            return
        }

这里有个值得注意的地方,readReserveData()方法读取了某个地址后的一段数据,随后将其逆置了,这是因为数字、ID相关的内容读取到内存后是使用小端法存储的。而readDataByOffset()读取出内容后并未逆置,因此读出字符串不用逆置。这两个方法如下所示。

byte[] readDataByOffset(File file, long offset, int length) throws Exception {
    InputStream is = new FileInputStream(file)
    is.skip(offset)
    byte[] buffer = new byte[length]
    is.read(buffer, 0, length)
    is.close()
    return buffer
}

byte[] readReserveData(File file, long offset, int length) throws Exception {
    byte[] buffer = readDataByOffset(file, offset, length)
    reserveByteArray(buffer)
    return buffer
}

第二步:遍历签名块中的键值对,找到最后一个键值对,并判断该键值对的value是否全部为空。只有该键值对的value全为空,并且有足够的空间,才能真正地插入渠道信息,遍历键值对和判断value是否为空的方法如下。

/**
 * 检查签名块中的键值对信息, 寻找可以插入渠道信息的地方
 * 这里选择获取最后一个键值对的地址, 该键值对一般全是0
 */
def checkKeyValues(File file, long signBlockStart, long signBlockEnd) throws Exception {
    long curKvOffset = signBlockStart + 8
    long lastKvOffset
    while (true) {
        lastKvOffset = curKvOffset
        byte[] kvSizeBytes = readReserveData(file, curKvOffset, 8)
        long kvSize = ByteBuffer.wrap(kvSizeBytes).getLong()
        byte[] idBuffer = readReserveData(file, curKvOffset + 8, 4)
        int id = ByteBuffer.wrap(idBuffer).getInt()
        // CHANNEL_KV_ID为渠道号信息的key,如果它存在表示之前已经插入了渠道信息
        if (id == CHANNEL_KV_ID) {
            int channelSize = (int) (kvSize - 4)
            byte[] channelBytes = readDataByOffset(file, curKvOffset + 12, channelSize)
            String channelString = new String(channelBytes, StandardCharsets.US_ASCII)
            println("channelString: " + channelString)
            return 0
        }
        curKvOffset = curKvOffset + 8 + kvSize
        if (curKvOffset >= signBlockEnd) {
            break
        }
    }
    return lastKvOffset
}

/**
 * 检查某个KV的值是否为空, 如果为空才能插入自己的信息
 */
boolean checkIfSingleKvEmpty(File file, long offset) throws Exception {
    boolean result = true
    byte[] kvSizeBytes = readReserveData(file, offset, 8)
    long kvSize = ByteBuffer.wrap(kvSizeBytes).getLong()
    byte[] bytes = readDataByOffset(file, offset + 12, (int) (kvSize - 4))
    for (byte b : bytes) {
        if (b != 0) {
            result = false
            break
        }
    }
    return result
}

第三步:修改空键值对的大小,并插入一个新的键值对存储渠道信息,其相关代码如下,其中insertOrOverrideBytes()方法用于覆盖原数据写入,具体代码见第五章代码。

/**
 * 在签名块中插入渠道信息
 * @param lastKvOffset 签名块中最后一个KV的地址偏移, 需要在该KV中插入签名信息
 * @param signBlockEnd 签名块末尾地址
 */
def insertChannelInfo(String channel, File file, String filePath,
                                      long lastKvOffset, long signBlockEnd) throws Exception {
    byte[] channelBytes = channel.getBytes()
    byte[] channelInfo = buildKeyValue(CHANNEL_KV_ID, channelBytes)
    byte[] lastKvSize = readReserveData(file, lastKvOffset, 8)
    long size = ByteBuffer.wrap(lastKvSize).getLong()
    long newSize = size - channelInfo.length
    byte[] newLastKvSizeBytes = toLittleEndianBytes(newSize, 8)
    insertOrOverrideBytes(filePath, lastKvOffset, newLastKvSizeBytes, true)
    insertOrOverrideBytes(filePath, signBlockEnd - channelInfo.length, channelInfo, true)
}

/**
 * 构建要插入签名块的键值对字节数组
 */
static byte[] buildKeyValue(int key, byte[] value) {
    byte[] keyBytes = toLittleEndianBytes(key, 4)
    long kvSize = 4 + value.length
    byte[] kvSizeBytes = toLittleEndianBytes(kvSize, 8)

    byte[] result = new byte[8 + 4 + value.length]
    System.arraycopy(kvSizeBytes, 0, result, 0, 8)
    System.arraycopy(keyBytes, 0, result, 8, 4)
    System.arraycopy(value, 0, result, 12, value.length)
    return result
}

/**
 * 将数字转化为小端模式的字节
 * @param size 数字是4字节还是8字节
 */
static byte[] toLittleEndianBytes(long num, int size) {
    byte[] result = new byte[size]
    long t = num
    for (int i = size - 1; i >= 0; i--) {
        result[i] = (byte) (t % 256)
        t /= 256
    }
    // 由于是小端模式, 需要将结果逆置
    reserveByteArray(result)
    return result
}

四、项目集成

下面介绍如何将多渠道打包集成到项目中,我们可以通过flavor.properties文件描述所需的所有渠道号,打包的具体步骤如下:
① 读取flavor.properties,得到所有渠道号
② 复制渠道号对应数量的初始APK并重命名,初始APK就是./gradlew assembleRelease的输出
③ 根据第三章的内容,在各个APK中插入对应的渠道信息

Android项目通过gradle构建,可以将多渠道打包封装为一个gradle task,命名为assembleFlavor。由于多渠道打包需要用assembleRelease命令的输出作为初始APK,因此assembleFlavor任务依赖assembleRelease任务。随后将具体多渠道打包的逻辑封装到flavor.gradle文件中,并对外提供一个打包的方法供assembleFlavor调用即可。 build.gradle(:app)文件如下所示。

apply plugin: 'com.android.application'
apply from : "flavor.gradle" // 当前gradle文件依赖flavor.gradle文件

......

task assembleFlavor {
    // assembleFlavor任务依赖assembleRelease任务
    dependsOn(":app:assembleRelease") 
    doLast {
        // assembleFlavor任务实际调用flavor.gradle中的assembleFlavorApk方法
        assembleFlavorApk() 
    }
}

随后在命令行运行./gradlew assembleFlavor即可在build文件夹下生成配置文件中对应的渠道包。

五、Demo源码

源码见github,其中flavor.gradle使用了一些Groovy的特性,不过Java与Groovy是完全兼容的,使用Java编写也完全可以,欢迎给我的项目star~

六、参考

  1. Android 动态写入信息到 APK
  2. 深入理解Android之Gradle
  3. Android | 他山之石,可以攻玉!一篇文章看懂 v1/v2/v3 签名机制