前言本文作者:冯瑞;廖斌斌;刘丰恺
应用安装包的体积会显著影响应用的下载速度和安装速度,按照 Google 的经验数据,包体积每增加 1M 会造成 0.17%的新增折损。抖音的一些实验也证明了包体积会显著影响下载激活的转化率。
Android 的安装包是 APK 格式的,在抖音的安装包中 DEX 的体积占比达到了 40%以上,所以针对 DEX 的体积优化是一种行之有效的包体积优化手段。
DEX 本质上是由 java/kotlin 代码编译而成的字节码,因此,针对字节码进行业务无感的通用优化成为我们的一个探索方向。
优化结果终端基础技术团队和抖音基础技术团队在过去的一年里,利用 ReDex 在抖音包体积优化方面取得了一些明显的收益,这些优化也被同步到了其他各大 App 上。
在抖音、头条和其他应用上,我们的优化对 APK 体积的缩减普遍达到了 4%以上,对 DEX 体积的缩减则可以达到 8% ~ 10%
优化思路在 android 应用的构建过程中,Java/Kotlin 代码会先被编译成 Class 字节码,在这个阶段 gradle 提供了 Transformer 可以进行字节码的自定义处理,很多插件都是在这个阶段处理字节码的。然后,Class 文件经过 dexBuilder/mergeDex 等任务的处理会生成 DEX 文件,并最终被打进安装包中。整个过程如下所示:
所以,针对字节码的优化是有 2 个时机可以进行的:
- 在 transformer 阶段对 Class 字节码进行优化
- 在 DEX 阶段对 DEX 文件进行优化
显然,对 DEX 进行优化是更理想的一种方式,因为在 DEX 文件中,除了字节码指令外,还存在跨 DEX 引用、字符串池这样的结构,针对这些 DEX 格式的优化是无法在 transformer 阶段进行的。
在确定了针对 DEX 文件进行优化的思路后,我们选择了 facebook 的开源框架 ReDex 作为优化工具,并对其进行了定制开发。
选择 ReDex 的原因是它提供了丰富的基础能力,ReDex 的基础能力包括:
- 读写及解析 DEX 的能力,同时可以在一定程度上读取并解析 xml 和 so 文件
- 解析简单的 Proguard keep 规则并匹配类/方法/成员变量的能力
- 对字节码进行数据流分析的能力,提供了常用的数据流分析算法
- 对字节码进行合法性校验的能力,包括寄存器检查、类型检查等
- 一系列的字节码优化项,每项优化称为一个 pass,多个 pass 组成 pipeline 对 DEX 进行优化
我们基于这些能力进行了定制和扩展,并期望最终建立完善的优化体系。
优化项在抖音落地的优化项,包括 facebook 开源的优化和我们自研的优化,从其出发点来看,可以大致分为下面几种:
- 通用字节码优化:通常意义下的编译优化,如常量传播、内联等,一般也可在 Transformer 阶段实现
- DEX 格式优化:DEX 中除了字节码指令外,还包括字符串池、类/方法引用、debug 信息等等,针对这些方面的优化归类为 DEX 格式优化
- 针对编程语言的优化:Java/Kotlin 的一些语法糖会生成大量字节码,可以对这些字节码进行针对性的分析和优化
- 提升压缩率的优化:将 DEX 打包成 APK 实质上是个压缩的过程,对 DEX 内容进行针对性的优化可以提升压缩率,从而产生体积更小的 APK
这几种优化没有明确的标准和界线,有时一个 Pass 会涉及到多种,下面详细介绍一下各项优化。
通用字节码优化ConstantPropagationPass该 Pass 实际上包含了常量折叠和常量传播。
常量折叠是在编译期简化常量的过程,比如
1 y = 7 - 14 / 2
2 --->
3 y = 0
常量传播是在编译期替代指令中已知常量的过程,比如
1 int x = 14;
2 int y = 7 - x / 2;
3 return y * (28 / x 2);
4 --->
5 int x = 14;
6 int y = 7 - 14 / 2;
7 return (7 - 14 / 2) * (28 / 14 2);
上面的例子经过 常量折叠 常量传播优化后就会简化为
1 int x = 14;
2 int y = 0;
3 return 0;
再经过死代码删除就可以最终变为return 0。
具体的优化过程是:
- 对方法进行数据流分析,主要针对 const/move 等指令,得出一个寄存器在某个位置可能的取值
- 根据分析的结果,进行指令替换或指令删除,包括:
- 如果值肯定是非空的,可以将对应的判空去掉,比如 kotlin 生成的 null check 调用
- 如果值肯定为空,可以将指令替换为抛空异常
- 如果值肯定让某 if 分支走不到,可以删除对应的分支
- 如果值是固定的,可以用 const 指令替换对应的赋值或计算指令
一个方法经过 ConstantPropagationPass 优化后,可能会产生一些死代码,比如例子中的int y = 0,这也为后续的死代码删除创造了条件。
AnnoKillPass该 Pass 是用来移除无用注解的。注解主要分为三种类型:
- SOURCE:java 源码编译为 class 字节码就不可见,此类注解一般不用过于关注
- CLASS:字节码通过 dx 工具转成 DEX 就不可见,代码运行时不需要获取信息,所以一般来说也不需要关注,实测发现部分注解仍然存在于 DEX 中,这部分注解可以进行优化
- RUNTIME:DEX 中仍然可见,代码运行中可以通过 getAnnotations 等接口获取注解信息,但是随着业务的迭代,可能获取注解信息的代码已经去掉,注解却没有下掉,这部分注解会被 ReDex 安全的移除
除此之外,实际上为了支持某些系统特性,编译器会自动生成系统注解,虽然注解本身是 RUNTIME 类型,但是可见性是VISIBILITY_SYSTEM
- AnnotationDefault : 默认注解,不能删除
- EnclosingClass : 当前内部类申明时所在的类
- EnclosingMethod : 当前内部类申明时所在的方法
- InnerClass : 当前内部类名称
- MemberClasses : 当前类的所有内部类列表
- MethodParameters : 方法参数
- Signature : 泛型相关
- Throws : 异常相关
举例说明
编译器生成 1MainApplication$1这个匿名内部类,带有 EnclosingMethod 和 InnerClass 注解