从dex和class文件结构看包体积优化

544 阅读9分钟

Class 文件格式简介

class文件是一种8位字节的二进制流文件,当遇到需要占用8个字节以上空间的数据项时会按照高位在前(最高位字节在地址最低位,最低位字节在地址最高位)的方式分割成若干个8字节存储。

u1、u2 代表一个字节、2个字节的无符号数

常量池本身是一个存储cp_info元素的表(数组),而常量项 cp_info 本身也是一个表:

cp_info{

u1 tag; //1个字节, 表明常量项的类型

u1 info[]; //常量项具体内容

}

在Java代码中声明的String字符串最终在class文件中的存储格式是CONSTANT_utf8_info,CONSTANT_utf8_info 是cp_info 表中的一种 tag 表,因此一个字符串最大长度也就是u2 所能代表的最大值 65536 ,但是需要使用2个字节保存null值,因此一个字符串最大长度为 65536 - 2 = 65534.

方法表的结构

属性可以分很多种, 可以用 attribute_info 数据结构伪代码表示

Dex 文件格式简介

Dex 文件中的各主要域的释义

Dex 的文件头记录的是 Magic 数、校验码、签名、总文件大小、各个区段所占的空间大小以及它们的起始地址在文件中的偏移量等一系列信息,如下表所示;

无法复制加载中的内容

使用dexdump来解析一个dex来看看它的内部构造 【数据来自得物app主dex】

dexdump -f classes.dex >>class.txt

文件头的大小是112,由string_ids_off偏移量的值为112也可以看出,string_ids是紧挨着文件头的;这里有个小知识点,由于dex中采取的是低位在前高位在后的顺序进行存储的,0x000070在二进制文件中的表示为0x700000。

从文件头中便可用获取到该dex所占的体积为 8836116 byte;包含64160个字段,65536个方法数,8810个类

其他各区段的详细信息这里就不再相信介绍了,可查阅官网source.android.com/devices/tec…

官网

Class 与 dex 文件格式的对比

  1. dex 文件去除了class文件中的冗余信息(比如重复字符常量)体积更小。从某种角度来说,一个dex相当于包含了多个class文件,那么在一个dex中就可以将多个class的重复字段、方法、header头等进行去除来减少体积;
  2. dex结构更加紧凑,相较于多个class,dex在解析阶段,可以减少I/O操作,提高类的查找速度。
  3. Android 虚拟机的反汇编语言是 Smali,采用的是基于寄存器的指令;而传统的 class 是基于栈的指令。Smali 中的指令语法 与标准 java 虚拟机中定义的指令语法还是很相似的。
  4. 在 android 设备上运行的是 linux 标准的 ELF 文件,如 apk libs 中的 .so 文件,dex 经过 odex 化的产物 都是 ELF 文件。

代码检测与优化

代码优化的思路主要围绕减少 string_id_item[], method_id_item[], class_def_item[]的体积展开,同时隐含对data区段的体积减小。

比如混淆,通过用短字符替换长字符的形式,可以使得上述各区段的体积都有降低;

比如内联,由于java源码经过编译后,会生成大量匿名内部类的实现类、编译桥接类;内联在可以的情况下通过将这些类的实现“复制”到调用方代码中,既可以获得运行时到效率提升(减少了方法调用栈的开销)同时也可以减少class数量(隐含对header、常量池等的减小);当这些内联后的多个class又聚合在一个dex中时,由于copy产生的多份字段开销也由dex的数据不重复性得以消除。

删除代码,将无用的代码及时清理掉。一般情况下删除代码都能带来体积减小,不是必然是因为还是存在一些情况,比如一个类中删除了不少的字段,但是这些删除的字段在其他类中也有使用,而这些类都打进了一个dex中,这种情况下dex体积是没有变化的。

Dex 分包策略不同,代码体积也将有差异。在一个dex中,一个class如果存在对另一个class方法的引用,则需要将对应的class也copy一份进来;同时,每多一个dex,也要多出一段 header 头的开销。相同代码量,dex越多,体积越大。

还可以通过代码检测找到apk其他文件夹下的可优化点,比如找到无用资源减少res和arsc体积。下面简单介绍下这种检测方式,以无用资源检测为例。

无用资源检测原理

无用资源检测的原理很简单,归纳起来就是从apk的代码中找出所有对资源引用的地方,将没有被引用的资源标记为无用资源。要实现起来可以有多种思路,一种是在打包过程中检测、一种是在apk构建成功后检测。

  1. 打包时,在transform阶段遍历class找到无用资源,Bytex是这样实现的。
  2. 打包后,解压apk,遍历dex文件、解析每个dex中的claass_defs中,将数据转换成 smail 字节码,遍历smail 找到无用资源,matrix 是这样实现的。

每个AAR的 module 在自己编译生成AAR时,会由 aapt 生成一个 R.txt 的文件,里面记录的是 res/ 里的资源文件。在主工程编译的过程中,会逐个读取每个aar中的 R.txt,再统一进行所有资源的分配,分配的结果保存在 build/intermediates/runtime_symbol_list/ 文件夹下的 R.txt 中;并且为了支持代码中通过 R.xxx 的形式调用资源,在主工程编译时,最终除了会在主工程包名下,生成一个包含主工程和AAR所有资源的R.java文件之外,还会在每个AAR相应的包名下,生成一个包含AAR资源的R.java文件。

Bytex 的实现

Bytex 的资源检测是在beforeTransform 阶段通过 resolveResource 方法收集资源信息

对于有 id (最终会在 arsc 中记录)的资源, resolveResource 最终会调用到 ResManager 的 prepare 方法,它主要做了几件事情:

  1. 调用 collectResourceId 方法遍历 R.class 并将结果用 resourceMap 记录下来
  2. 调用 processAndroidManifest 方法遍历 AndroidManifest 文件,通过 XmlReader 解析出对 id 的引用,通过 RecordResReachXmlVisitor 通知 ResManager
  3. 遍历 raw 文件下的 xml 资源,通过 XmlReader 解析出对 id 的引用,通过 RecordResReachXmlVisitor 通知 ResManager
  4. 白名单的处理

对于 assets 这种无 id 的资源则是通过 AssetsManager [这个AssetsManager 不是 android 系统里面的那个AssetsManager] 遍历 Assets 目录下的文件,也是用一个map记录

数据准备好了后,在 Transform 阶段再对对所有非 R 类型的 class 遍历,对 class中的字段匹配,如果命中 res 或 assets,则会将引用+1;Transform 结束后将 assets map 和 resourcemap 中未引用到的对象视为无用资源

Matrix 实现

Matrix 中对无用资源的检测是由 UnusedResourcesTask 完成的,对无用 Assets 的检测是由 UnusedAssetsTask 完成的。

我们先看无用资源的,由于 Matrix 的检测是对 apk 解压,基于apk的产物来检测的,apk 编译过程中经过 R8 后会对代码进行混淆,所以需要用到mapping文件来对其反混淆,同时检测无用资源还需要拿到编译期间最终生成的 R.txt 文件。

  1. 准备阶段,UnusedResourcesTask 的 init 方法解析 R.txt 文件,将处理后的数据内容保存到 resourceDefMap 中
  2. Task 执行阶段,通过decodeCode 方法遍历每一个dex文件,找出dex中的每一个class数据段,利用 baksmali 将其还原成 smali 格式,然后再通过 readSmaliLines 方法逐行遍历,识别 smali 语法关键指令内容,如在 const、sget、0x 等对应的 value 中找到对资源 id 的调用,将全部调用到的资源用一个 resourceRefSet 存储起来
  3. resourceDefMap 与 resourceRefSet 进行下过滤就可以得到全部无用资源了

UnusedAssetsTask 相较于 UnusedResourcesTask 流程基本一致,区别是准备阶段 UnusedResourcesTask 是从 R.txt 中取数据,而UnusedResourcesTask 是从 assets 文件夹下遍历文件准备数据源;读取 smali 指令识别的关键字和处理逻辑稍有区别。

两种方案的总结

通过Bytex和Matrix中两种实现方案的对比,我们不难发现,Matrix 的实现是要比Bytex中更准确一点。原因主要有以下几点:

  1. 由于在 Transform 阶段遍历 class 的情况遇上白名单的类是直接跳过的,会使得检测不全
  2. 无法百分百保证 aar中的 R.class 与最终生成的 R.class 完全一致
  3. 检测插件执行完毕,后续其他插件的修改也可能结果有影响

但是 Transform 阶段的一个好处是流程提前,可以对打包进行预检验,更近一步,配合 CI 可以做到代码合入预校验。

不管是 Bytex 还是 Matrix ,检测出的结果也无法做到 100% 准确,因为存在以下情况是无法检测到的:

  1. 直接通过 so 在native层调用资源的情况,由于 .so 是 ELF 格式的文件,这两种方案的静态检测均未覆盖对 so 的检测
  2. 通过反射调用资源的情况,这种情况是由于检测识别的关键字未能匹配导致的

由于无法百分百相信结果的准确性,所以也就无法在打包过程中通过插件来将这些资源进行剔除,这也是目前无用资源无法自动化剔除的痛点。