# AAPT2 产物逆向

# 为什么要逆向 AAPT2 产物

根据上一章我们对 Android 工程的构建流程的了解,如果要处理 APP 中的资源,一般会选择从 processRes 任务的产出物中获取,而从 Android Gradle Plugin 3.0.0 开始,processRes 任务的产出物已经不再是原始的资源文件了,而是由特定的格式(AAPT2)所编码的二进制。

Booster 的优化特性中,有很多特性的来实现都依赖于解析这些被 AAPT2 编译过的二进制资源,例如:

  1. booster-task-analyser 以布局文件中的自定义 View 作为静态分析的入口来构建 Call Graph
  2. booster-transform-r-inline 从布局文件中提取 ConstraintLayout 引用的资源 ID
  3. booster-task-compression-pngquantbooster-task-compression-cwebp*.png.flat 文件中获取图片资源名称及其源文件路径;
  4. booster-task-resource-deredundancy*.png.flat 文件中获取图片资源的 Configuration

# 什么是 AAPT2 ?

AAPT2Android 资源打包工具)是一个构建工具,Android StudioAndroid Gradle Plugin 使用它来编译和打包应用的资源。AAPT2 会解析资源、为资源编制索引,并将资源编译为针对 Android 平台进行过优化的二进制格式。

AAPT2 的可执行文件随 Android SDKBuild Tools 一起发布,以 Build Tools 29.0.0 为例,aapt2 可执行文件位于:

$ANDROID_HOME/build-tools/29.0.0/aapt2

Android Gradle Plugin 3.0.0 开始,AAPT2 默认开启,相对于 AAPT,资源打包流程由原来的单一编译过程拆分为「编译」和「链接」两个阶段。

# 编译阶段

Android 所有类型的资源的编译都是通过 AAPT2 来完成,资源的编译使用 compile 子命令,编译成功后,会生成一个扩展名为 .flat 的中间二进制文件,正常情况下,每一个输入的资源文件对应输出一个 .flat 文件,然后在后续的链接阶段使用。

# 编译单个资源

$ aapt2 compile -o build ./app/src/main/res/mipmap-xxxhdpi/ic_launcher.png

# 编译多个资源

$ aapt2 compile -o build \
    ./app/src/main/res/mipmap-xxxhdpi/ic_launcher.png \
    ./app/src/main/res/layout/activity_main.xml \
    ./app/src/main/res/values/strings.xml

# 编译整个目录

$ aapt2 compile -o build/resources.ap_  --dir ./app/src/main/res/

通过 unzip 命令查看 build/resources.ap_ 文件内容:

$ unzip -lv build/resources.ap_

结果如下:

Archive:  build/resources.ap_
 Length   Method    Size  Cmpr    Date    Time   CRC-32   Name
--------  ------  ------- ---- ---------- ----- --------  ----
    2964  Stored     2964   0% 01-01-1980 08:00 222c02e4  drawable-v24_ic_launcher_foreground.xml.flat
   10400  Stored    10400   0% 01-01-1980 08:00 c51d04d8  drawable_ic_launcher_background.xml.flat
    1004  Stored     1004   0% 01-01-1980 08:00 b7942223  layout_activity_main.xml.flat
     468  Stored      468   0% 01-01-1980 08:00 719ae087  mipmap-anydpi-v26_ic_launcher.xml.flat
     480  Stored      480   0% 01-01-1980 08:00 51d410e6  mipmap-anydpi-v26_ic_launcher_round.xml.flat
    3076  Stored     3076   0% 01-01-1980 08:00 b7c61139  mipmap-hdpi_ic_launcher.png.flat
    5032  Stored     5032   0% 01-01-1980 08:00 4e0c4c11  mipmap-hdpi_ic_launcher_round.png.flat
    2172  Stored     2172   0% 01-01-1980 08:00 8403a200  mipmap-mdpi_ic_launcher.png.flat
    2908  Stored     2908   0% 01-01-1980 08:00 fa7067cb  mipmap-mdpi_ic_launcher_round.png.flat
    4604  Stored     4604   0% 01-01-1980 08:00 7d50d1c1  mipmap-xhdpi_ic_launcher.png.flat
    7020  Stored     7020   0% 01-01-1980 08:00 b81236dd  mipmap-xhdpi_ic_launcher_round.png.flat
    6504  Stored     6504   0% 01-01-1980 08:00 087eaa8c  mipmap-xxhdpi_ic_launcher.png.flat
   10544  Stored    10544   0% 01-01-1980 08:00 a3248946  mipmap-xxhdpi_ic_launcher_round.png.flat
    9056  Stored     9056   0% 01-01-1980 08:00 da999b7f  mipmap-xxxhdpi_ic_launcher.png.flat
   15260  Stored    15260   0% 01-01-1980 08:00 3c9e8eea  mipmap-xxxhdpi_ic_launcher_round.png.flat
     296  Stored      296   0% 01-01-1980 08:00 eeebffe4  values-v13_styles.arsc.flat
     296  Stored      296   0% 01-01-1980 08:00 08cc005e  values-v21_styles.arsc.flat
     332  Stored      332   0% 01-01-1980 08:00 3050ad73  values_colors.arsc.flat
     248  Stored      248   0% 01-01-1980 08:00 b5f978d1  values_strings.arsc.flat
     284  Stored      284   0% 01-01-1980 08:00 b1096c78  values_styles.arsc.flat
--------          -------  ---                            -------
   82948            82948   0%                            20 files

WARNING

注意:对于资源文件,输入文件的路径必须符合以下结构:path/resource-type[-configuration]/file,否则,会报如下错误:

error: invalid file path '...'

# 链接阶段

在链接阶段,AAPT2 会合并在编译阶段生成的所有中间文件(.flat 文件),并将它们打包成 ZIP 包(最终 APK 的原型,由于不包括 DEX 文件且未签名,所以无法正常安装)。

链接资源使用 link 子命令,如下所示:

$ aapt2 link -o build/resources.ap_ \
    -I $ANDROID_HOME/platforms/android-29/android.jar \
    --manifest build/intermediates/manifests/full/debug/AndroidManifest.xml \
    build/layout_activity_main.xml.flat \
    build/values_styles.arsc.flat \
    build/values_colors.arsc.flat \
    build/values_strings.arsc.flat \
    build/mipmap-xxxhdpi_ic_launcher.png.flat \
    build/mipmap-xxxhdpi_ic_launcher_round.png.flat

# AAPT2 容器格式

AAPT2 的编译阶段,会生成扩展名为 .flat 的中间二进制文件,这种以 .flat 作为扩展名的文件格式,被称之为 AAPT2 容器,AAPT2 容器文件由文件头和资源项两大部分组成,容器中的各个字段以小端(Little-Endian)字节序表示:

# AAPT2 文件头

字段 字节数 描述
magic 4 AAPT2 容器文件标识:AAPT0x54504141
version 4 AAPT2 容器版本
entry_count 4 容器中包含的条目数(一个 flat 文件中可以包含多个资源项)

# AAPT2 资源项

字段 字节数 描述
entry_type 4 资源类型(目前仅支持两种类型:RES_TABLE(0x00000000)RES_FILE (0x00000001)
entry_length 8 资源数据长度
data entry_length 资源数据

# Resource Table

entry_type0x00000000 时,data 表示 protobuf 序列化的 ResourceTable 结构

# Resource File

entry_type0x00000001 时,data 表示资源文件,格式如下:

字段 字节数 描述
header_size 4 header 的长度
data_size 8 data 的长度
header header_size 表示 protobuf 序列化的 CompiledFile 结构
header_padding x 0-3 个填充字节,用于 data 32 位对齐
data data_size 资源文件内容(PNG, 二进制 XML 或者 protobuf 序列化的 XmlNode 结构)
data_padding y 0-3 个填充字节,用于 data 32 位对齐

AAPT2 格式规范:https://github.com/aosp-mirror/platform_frameworks_base/blob/master/tools/aapt2/formats.md

# flat 格式的兼容性问题

虽然 Android Gradle Plugin 3.0.0 已经默认启用 AAPT2,但是 AAPT2 的产出物(flat 文件)的格式直到 Android Gradle Plugin 3.2.0 才稳定下来,那 3.2.0 以前的版本产出的 flat 文件格式到底是什么样子呢?

# Resource File

通过逆向分析 flat 文件,我们还原了 Android Gradle Plugin 3.2.0 以前的版本产出的 flat 文件格式,如下表所示:

字段 字节数 描述
entry_type 4 资源类型(通常为:RES_FILE (0x00000001)
entry_length 8 资源数据长度
header header_size 表示 protobuf 序列化的 CompiledFile
header_padding x 0-3 个填充字节,用于 data 32 位对齐
data entry_length 资源数据
data_padding y 0-3 个填充字节,用于 data 32 位对齐

# Resource Table

Resource Table 的格式比较简单,其实就是 ResourceTableprotobuf 序列化结果。

关于二进制文件的逆向工具,类 Linux 系统都自带 xxd 命令,可以直接输出二进制文件的十六进制格式:

$ xxd ./build/intermediates/res/merged/debug/mipmap-hdpi_ic_launcher.png.flat

或者使用 VIM 打开二进制文件

$ vim ./build/intermediates/res/merged/debug/mipmap-hdpi_ic_launcher.png.flat

然后在 VIM 中输入:

:%!xxd

# flatAAPT 产物的关系

Android Gradle Plugin 3.0 以前的版本中,AAPT 的产物主要有 3 类:

  1. 已编译的二进制 XML,例如:布局 XML 文件;
  2. 字符串池(String Pool),内嵌于 Resource Table 中,一般不会独立存在;
  3. 资源表(Resource Table),例如:ARSC 文件;

AAPT2 的大部分数据结构都采用 protobuf 重新进行编码,但还有一小部分数据结构仍然复用了AAPT 的格式,例如:String Pool ,我们从 AAPT2proto 定义便可以看出来:

message StringPool {
  bytes data = 1;
}

message ResourceTable {
  // The string pool containing source paths referenced throughout the resource table. This does
  // not end up in the final binary ARSC file.
  StringPool source_pool = 1;

  // Resource definitions corresponding to an Android package.
  repeated Package package = 2;
}

# AAPT2 容器的意义

AAPT2 为什么要将中间产物编码成 flat 格式呢?主要原因在于 AAPT2 将资源打包过程拆分成了两个阶段:「编译阶段」和「链接阶段」,为了在链接阶段得到资源更详细的信息,例如:资源名称、配置信息(Configuration) 等,因此,直接将资源的元信息连同资源本身一同编码进 AAPT2 容器文件中,这样,资源链接的过程可以完全与编译过程解耦了,而且,对于增量构建来说,这样大大提升了资源打包的性能。

#Gradle 插件中访问 aapt2

# AGP 3.5.0 以下版本

fun findAapt2(project: Project) {
    project.applicationVariants.forEach { variant ->
        val variantImpl = variant as ApplicationVariantImpl;
        val buildTool = variantImpl.variantData.scope.globalScope.androidBuilder.buildToolInfo;
        val aapt2 = buildTool.getPath(BuildToolInfo.PathId.AAPT2);
        // do something with aapt2
    }
}

# AGP 3.5.0 以上版本

fun findAapt2(project: Project) {
    project.applicationVariants.forEach { variant ->
        val variantImpl = variant as ApplicationVariantImpl;
        val buildTool = variantImpl.variantData.scope.globalScope.androidBuilder.buildToolInfoProvider.get();
        val aapt2 = buildTool.getPath(BuildToolInfo.PathId.AAPT2);
        // do something with aapt2
    }
}

# 在代码中执行 aapt2 命令

fun runAapt2(project: Project, aapt2: String, args: List<String>) {
    val rc = project.exec { spec ->
        spec.isIgnoreExitValue = true
        spec.commandLine = listOf(aapt2) + args
    }

    when (rc.exitValue) {
        0 -> println("Aapt2 execute successful")
        else -> println("Aapt2 execute failed: ${rc.exitValue}")
    }
}

# Booster Aapt2

为了便于在 Gradle 插件中解析 flat 文件,Booster 提供了 booster-aapt2 模块,提供了 BinaryParser 以及 Aapt2Parser 来解析已编译的资源,由于 Android Gradle Plugin 版本间存在差异导致 AAPT2 中间产物格式不一致,而 booster-aapt2 屏蔽了这些细微的差异,以简化已编译资源文件的解析过程。

# 使用方法

build.gradle 中引入 booster-aapt2 依赖,如下所示:

buildscript {
    ext {
        kotlin_version = '1.3.31'
        booster_version = '3.0.0'
    }
    repositories {
        mavenLocal()
        mavenCentral()
        google()
        jcenter()
        maven { url 'https://oss.sonatype.org/content/repositories/public/' }
        maven { url 'https://oss.sonatype.org/content/repositories/snapshots/' }
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:3.5.0'
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"

        /* 👇👇👇👇 引用这个模块 👇👇👇👇 */
        classpath "com.didiglobal.booster:booster-aapt2:$booster_version"
    }
}

然后,通过 BinaryParserAapt2Parser 来解析已编译的资源文件:

fun parseCompiledResource(res: File) {
    val container = BinaryParser(res).use { parser ->
        parser.parseAapt2Container()
    }

    container.entries.map {
        it as Aapt2Container.ResFile
    }.forEach { res ->
        // do something with parsed resource
    }
}