Android 多渠道打包的方案总结

美团的 Walle 方案:https://github.com/Meituan-Dianping/walle
腾讯的 VasDolly 方案:https://github.com/Tencent/VasDolly
packer-ng-plugin 方案:https://github.com/mcxiaoke/packer-ng-plugin

先从 Github 开源维护的情况看,packer-ng-plugin 项目已经停止维护,Walle 最新的维护是在2年前,VasDolly 最新的维护在5个月前。从开源维护的角度来说,腾讯的 VasDolly 方案,更胜一筹。

可商用的多渠道自动化打包方案

VasDolly

先说使用后的体验:

因为 VasDolly官方最新的版本为 v3.0.4,所以直接集成最新版本的情况,没去试历史旧版本的情况。
项目环境为:

dependencies {
classpath 'com.android.tools.build:gradle:7.0.3'
classpath 'com.tencent.vasdolly:plugin:3.0.4'
}
distributionUrl=https://services.gradle.org/distributions/gradle-7.0.2-all.zip

即可成功编译,并且按教程配置,可以打出对应的渠道包。官方的 Demo 给的就是这个 Gradle 编译环境。打20个渠道包,时间可以控制在1分钟左右。

但是,例如项目环境为:

dependencies {
classpath "com.android.tools.build:gradle:4.1.3"
classpath 'com.tencent.vasdolly:plugin:3.0.4'
}
distributionUrl=https://services.gradle.org/distributions/gradle-6.6-all.zip

编译项目会报这个错:

Unable to load class 'com.android.build.api.extension.AndroidComponentsExtension'.
This is an unexpected error. Please file a bug containing the idea.log file.

VasDolly 实现原理:

https://github.com/Tencent/VasDolly/wiki/VasDolly%E5%AE%9E%E7%8E%B0%E5%8E%9F%E7%90%86

通过Gradle生成多渠道包

若是直接编译生成多渠道包,首先要配置渠道文件、渠道包的输出目录和渠道包的命名规则:

channel{
    //指定渠道文件
    channelFile = file("/Users/leon/Downloads/testChannel.txt")
     //多渠道包的输出目录,默认为new File(project.buildDir,"channel")
    outputDir = new File(project.buildDir,"xxx")
    //多渠道包的命名规则,默认为:${appName}-${versionName}-${versionCode}-${flavorName}-${buildType}-${buildTime}
    apkNameFormat ='${appName}-${versionName}-${versionCode}-${flavorName}-${buildType}'
    //快速模式:生成渠道包时不进行校验(速度可以提升10倍以上,默认为false)
    fastMode = false
    //buildTime的时间格式,默认格式:yyyyMMdd-HHmmss
    buildTimeDateFormat = 'yyyyMMdd-HH:mm:ss'
    //低内存模式(仅针对V2签名,默认为false):只把签名块、中央目录和EOCD读取到内存,不把最大头的内容块读取到内存,在手机上合成APK时,可以使用该模式
    lowMemory = false
}

其中,多渠道包的命名规则中,可使用以下字段:

  • appName : 当前project的name
  • versionName : 当前Variant的versionName
  • versionCode : 当前Variant的versionCode
  • buildType : 当前Variant的buildType,即debug or release
  • flavorName : 当前的渠道名称
  • appId : 当前Variant的applicationId
  • buildTime : 当前编译构建日期时间,时间格式可以自定义,默认格式:yyyyMMdd-HHmmss

然后,通过 gradle channelDebuggradle channelRelease 命令分别生成 DebugRelease 的多渠道包。

为了方便临时生成渠道包进行测试,从v2.0.0开始支持添加渠道参数:gradle channelDebug(channelRelease) -Pchannels=yingyongbao,gamecenter,这里通过属性 channels 指定的渠道列表拥有更高的优先级,且和原始的文件方式是互斥的。

根据已有基础包重新生成多渠道包

若是根据已有基础包重新生成多渠道包,首先要配置渠道文件、基础包的路径和渠道包的输出目录:

rebuildChannel {
  //指定渠道文件
  channelFile = file("/Users/leon/Downloads/testReChannel.txt")
  // 已有APK文件地址(必填),如new File(project.rootDir, "/baseApk/app_base.apk"),文件名中的base将被替换为渠道名
  baseApk = 已有APK文件地址(必填)
  //默认为new File(project.buildDir, "rebuildChannel")
  outputDir = 渠道包输出目录
  //快速模式:生成渠道包时不进行校验(速度可以提升10倍以上,默认为false)
  fastMode = false
  //低内存模式(仅针对V2签名,默认为false):只把签名块、中央目录和EOCD读取到内存,不把最大头的内容块读取到内存,在手机上合成APK时,可以使用该模式
  lowMemory = false
}

通过命令行生成渠道包、读取渠道信息:

https://github.com/Tencent/VasDolly/blob/master/command/README.md

读取渠道信息

通过 helper 类库中的 ChannelReaderUtil 类读取渠道信息。

String channel = ChannelReaderUtil.getChannel(getApplicationContext());

如果没有渠道信息,那么这里返回 null,开发者需要自己判断。


Walle

先说使用后的体验:

Walle 官方库已经2年多没更新,v1.1.7 为最新的版本。打20个渠道包,时间也可以控制在1分钟左右。
项目环境为:

dependencies {
classpath 'com.android.tools.build:gradle:4.1.3'
classpath 'com.meituan.android.walle:plugin:1.1.7'
}
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-6.6-all.zip

即可成功编译,并且按教程配置,可以打出对应的渠道包。
因为 Walle 最新的维护是在2年前,很多依赖的库和 Gradle 版本都太低,所以自己在 Github 仓库去尝试升级维护这个库,地址如下:https://github.com/jeffreyxuworld/MeituanWalle

Walle 实现原理

可看下美团的官方链接:https://tech.meituan.com/2017/01/13/android-apk-v2-signature-scheme.html

捋了一遍原理后,总结如下:
大前提都是针对 APK 签名方案去做的相关事情。
Android 支持以下三种应用签名方案

  • v1 方案:基于 JAR 签名。https://source.android.com/security/apksigning#v1
  • v2 方案APK 签名方案 v2(在 Android 7.0 中引入)。https://source.android.com/security/apksigning/v2
  • v3 方案APK 签名方案 v3(在 Android 9 中引入)。https://source.android.com/security/apksigning/v3

更细节的要点如下

  • v1 签名不保护 APK 的某些部分,例如 ZIP 元数据。APK 验证程序需要处理大量不可信(尚未经过验证)的数据结构,然后会舍弃不受签名保护的数据。这会导致相当大的受攻击面。此外,APK 验证程序必须解压所有已压缩的条目,而这需要花费更多时间和内存。为了解决这些问题,Android 7.0 中引入了 APK 签名方案 v2。Walle 是基于 APK 签名方案 v2 去做的相关处理,而美团上一代分渠道打包方案是根据 APK 签名方案 v1 去做的,所以现在已经不适用。
  • 该方案会对 APK 的内容进行哈希处理和签名,然后将生成的“APK 签名分块”插入到 APK 中。如需详细了解如何在应用中使用 v2+ 方案,可以阅读以下链接:https://developer.android.com/about/versions/nougat/android-7.0#apk_signature_v2
  • 在验证期间,v2+ 方案会将 APK 文件视为 blob,并对整个文件进行签名检查。对 APK 进行的任何修改(包括对 ZIP 元数据进行的修改)都会使 APK 签名作废。这种形式的 APK 验证不仅速度要快得多,而且能够发现更多种未经授权的修改。
  • APK 签名方案 v2 是一种全文件签名方案,该方案能够发现对 APK 的受保护部分进行的所有更改,从而有助于加快验证速度并增强完整性保证。使用 APK 签名方案 v2 进行签名时,会在 APK 文件中插入一个 APK 签名分块,该分块位于“ZIP 中央目录”部分之前并紧邻该部分。在“APK 签名分块”内,v2 签名和签名者身份信息会存储在 APK 签名方案 v2 分块中。

    新的签名方案会在 ZIP 文件格式的 Central Directory 区块所在文件位置的前面添加一个 APK Signing Block 区块,下面按照 ZIP 文件的格式来分析新应用签名方案签名后的 APK 包。
    整个APK(ZIP文件格式)会被分为以下四个区块: 1. Contents of ZIP entries(from offset 0 until the start of APK Signing Block) 2. APK Signing Block 3. ZIP Central Directory 4. ZIP End of Central Directory

    新应用签名方案的签名信息会被保存在区块2(APK Signing Block)中, 而区块1(Contents of ZIP entries)、区块3(ZIP Central Directory)、区块4(ZIP End of Central Directory)是受保护的,在签名后任何对区块1、3、4的修改都逃不过新的应用签名方案的检查。
    之前的渠道包生成方案是通过在 META-INF 目录下添加空文件,用空文件的名称来作为渠道的唯一标识,之前在 META-INF 下添加文件是不需要重新签名应用的,这样会节省不少打包的时间,从而提高打渠道包的速度。但在新的应用签名方案下 META-INF 已经被列入了保护区了,向 META-INF 添加空文件的方案会对区块1、3、4都会有影响。
  • 可以看出因为 APK 包的区块1、3、4都是受保护的,任何修改在签名后对它们的修改,都会在安装过程中被签名校验检测失败,而区块2(APK Signing Block)是不受签名校验规则保护的,那是否可以在这个不受签名保护的区块2(APK Signing Block)上做文章呢?我们先来看看对区块2格式的描述:
  • 区块2中 APK Signing Block 是由这几部分组成:2个用来标示这个区块长度的8字节 + 这个区块的魔数(APK Sig Block 42)+ 这个区块所承载的数据(ID-value)。我们重点来看一下这个ID-value,它由一个8字节的长度标示+4字节的 ID+它的负载组成。V2的签名信息是以 ID(0x7109871a)的 ID-value 来保存在这个区块中,不知大家有没有注意这是一组 ID-value,也就是说它是可以有若干个这样的 ID-value 来组成。
  • Android 应用在安装时新的应用签名方案是怎么进行校验的呢?通过翻阅 Android 相关部分的源码,发现下面代码段是用来处理上面所说的 ID-value 的:
      public static ByteBuffer findApkSignatureSchemeV2Block(
              ByteBuffer apkSigningBlock,
              Result result) throws SignatureNotFoundException {
          checkByteOrderLittleEndian(apkSigningBlock);
          // FORMAT:
          // OFFSET       DATA TYPE  DESCRIPTION
          // * @+0  bytes uint64:    size in bytes (excluding this field)
          // * @+8  bytes pairs
          // * @-24 bytes uint64:    size in bytes (same as the one above)
          // * @-16 bytes uint128:   magic
          ByteBuffer pairs = sliceFromTo(apkSigningBlock, 8, apkSigningBlock.capacity() - 24);
    
          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);
              }
              long lenLong = pairs.getLong();
              if ((lenLong < 4) || (lenLong > Integer.MAX_VALUE)) {
                  throw new SignatureNotFoundException(
                          "APK Signing Block entry #" + entryCount
                                  + " size out of range: " + lenLong);
              }
              int len = (int) lenLong;
              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());
              }
              int id = pairs.getInt();
              if (id == APK_SIGNATURE_SCHEME_V2_BLOCK_ID) {
                  return getByteBuffer(pairs, len - 4);
              }
              result.addWarning(Issue.APK_SIG_BLOCK_UNKNOWN_ENTRY_ID, id);
              pairs.position(nextEntryPos);
          }
    
          throw new SignatureNotFoundException(
                  "No APK Signature Scheme v2 block in APK Signing Block");
      }
    
    上述代码中关键的一个位置是 if (id == APK_SIGNATURE_SCHEME_V2_BLOCK_ID) {return getByteBuffer(pairs, len - 4);},通过源代码可以看出 Android 是通过查找 IDAPK_SIGNATURE_SCHEME_V2_BLOCK_ID = 0x7109871aID-value,来获取 APK Signature Scheme v2 Block,对这个区块中其他的 ID-value 选择了忽略。
    当看到这里时,我们可不可以设想一下,提供一个自定义的ID-value并写入该区域,从而为快速生成渠道包服务呢?
    怎么向 ID-value 中添加信息呢?通过阅读 ZIP 的文件格式和 APK Signing Block 格式的描述,笔者通过编写下面的代码片段进行验证,发现通过在已经被新的应用签名方案签名后的 APK 中添加自定义的 ID-value,是不需要再次经过签名就能安装的,下面是部分代码片段。
    public void writeApkSigningBlock(DataOutput dataOutput) {
          long length = 24;
          for (int index = 0; index < payloads.size(); ++index) {
              ApkSigningPayload payload = payloads.get(index);
              byte[] bytes = payload.getByteBuffer();
              length += 12 + bytes.length;
          }
    
          ByteBuffer byteBuffer = ByteBuffer.allocate(Long.BYTES);
          byteBuffer.order(ByteOrder.LITTLE_ENDIAN);
          byteBuffer.putLong(length);
          dataOutput.write(byteBuffer.array());
    
          for (int index = 0; index < payloads.size(); ++index) {
              ApkSigningPayload payload = payloads.get(index);
              byte[] bytes = payload.getByteBuffer();
    
              byteBuffer = ByteBuffer.allocate(Integer.BYTES);
              byteBuffer.order(ByteOrder.LITTLE_ENDIAN);
              byteBuffer.putInt(payload.getId());
              dataOutput.write(byteBuffer.array());
    
              dataOutput.write(bytes);
          }
          ...
      }
    
  • 对新的应用签名方案生成的 APK 包中的 ID-value 进行扩展,提供自定义 ID-value(渠道信息),并保存在 APK 中。而 APK 在安装过程中进行的签名校验,是忽略我们添加的这个 ID-value 的,这样就能正常安装了。
  • App 运行阶段,可以通过 ZIPEOCD(End of central directory)Central directory 等结构中的信息(会涉及 ZIP 格式的相关知识,这里不做展开描述)找到我们自己添加的 ID-value,从而实现获取渠道信息的功能。
  • 新一代渠道包生成工具完全是基于 ZIP 文件格式和 APK Signing Block 存储格式而构建,基于文件的二进制流进行处理,有着良好的处理速度和兼容性,能够满足不同的语言编写的要求,目前美团的方案采用的是 Java+Groovy 开发, 该工具主要有四部分组成:
    1. 用于写入 ID-value 信息的 Java 类库
    2. Gradle 构建插件用来和Android的打包流程进行结合
    3. 用于读取 ID-value 信息的 Java 类库
    4. 用于供 com.android.application 使用的读取渠道信息的 AAR
    这样,每打一个渠道包只需复制一个 APK,然后在 APK 中添加一个 ID-value 即可,这种打包方式速度非常快,对一个30M大小的 APK 包只需要100多毫秒(包含文件复制时间)就能生成一个渠道包,而在运行时获取渠道信息只需要大约几毫秒的时间。

最后总结:

  • 如果项目之前是通过 AS 手动打包的形式,在主 App 工程的 build.gradleAndroidManifest.xml 里做了一些渠道包相关信息的配置。现在用了 VasDollyWalle 的方案,那么也要对自己工程里相关的代码进行更改。
    例如:
  1. AndroidManifest.xml 里,友盟 SDK 需要获取应用的渠道名称

  1. 在主 App 工程的 build.gradle 中,如果写了如下代码:
  flavorDimensions "versionCode", "serverUrl"
           applicationVariants.all { variant ->
                variant.outputs.all { output ->
                    def fileName
                    if (variant.buildType.name == "release") {
                        fileName = "XXAPP-${variant.productFlavors[0].name}-${variant.versionName}-Android.apk"
                    } else {
                        fileName = "XXAPP-Android.apk"
                    }
                    outputFileName = fileName
                }
            }
productFlavors {
        yingyongbao {
            dimension "versionCode"
            manifestPlaceholders = [UMENG_CHANNEL_VALUE: "yingyongbao"]
        }
        huawei {
            dimension "versionCode"
            manifestPlaceholders = [UMENG_CHANNEL_VALUE: "huawei"]
        }
        xiaomi {
            dimension "versionCode"
            manifestPlaceholders = [UMENG_CHANNEL_VALUE: "xiaomi"]
        }
        oppo {
            dimension "versionCode"
            manifestPlaceholders = [UMENG_CHANNEL_VALUE: "oppo"]
        }
        vivo {
            dimension "versionCode"
            manifestPlaceholders = [UMENG_CHANNEL_VALUE: "vivo"]
        }
        weibo {
            dimension "versionCode"
            manifestPlaceholders = [UMENG_CHANNEL_VALUE: "weibo"]
        }
        bzhan {
            dimension "versionCode"
            manifestPlaceholders = [UMENG_CHANNEL_VALUE: "bzhan"]
        }
        toutiao {
            dimension "versionCode"
            manifestPlaceholders = [UMENG_CHANNEL_VALUE: "toutiao"]
        }
        guangdiantong {
            dimension "versionCode"
            manifestPlaceholders = [UMENG_CHANNEL_VALUE: "guangdiantong"]
        }
        baidu {
            dimension "versionCode"
            manifestPlaceholders = [UMENG_CHANNEL_VALUE: "baidu"]
        }
        urlTest {
            dimension "serverUrl"
            buildConfigField("int", "SERVER_TYPE", "1")
        }
        urlOnline {
            dimension "serverUrl"
            buildConfigField("int", "SERVER_TYPE", "2")
        }
    }

这些渠道包相关的配置,都会和 VasDollyWalle 的方案有所冲突,所以要按照 VasDollyWalle 官方教程里的写法来写。

  • 因为 packer-ng-plugin 项目已经停止维护,V2签名方案也不支持,所以没去试这个的情况。
  • Walle 对项目里的 gradle 的版本,没有较高版本的要求,这一点对于一些使用较低版本 gradle 的项目,可以更方便的去集成。也许 VasDolly 的旧版本也可以做到,但是既然有最新版本的 SDK,一般不建议去使用历史的旧版本。

你可能感兴趣的:(Android 多渠道打包的方案总结)