应用程序安装包的大小会显着影响应用程序的下载速度和安装速度。根据经验数据,封装尺寸每增加1M,就会造成0.17%的新损耗。抖音的一些实验也证明,包体积可以显着影响下载激活的转化率。
安装包为APK格式。在抖音的安装包中,DEX占据了40%以上的体积。因此,DEX的体积优化是一种有效的包体积优化方法。
DEX本质上是由Java/代码编译而成的字节码。因此,业务不敏感的字节码通用优化成为我们的探索方向之一。
优化结果
在过去的一年里,终端基础技术团队和抖音基础技术团队利用ReDex在抖音包大小优化方面取得了一些明显的效益,并且这些优化也同步到了其他各大App。
在抖音、今日头条等应用上,我们的优化普遍使APK的大小减少了4%以上,DEX的大小可以减少8%~10%。
优化思路
在应用程序构建过程中,Java/代码首先会被编译成Class字节码。在这个阶段,提供了字节码的自定义处理。许多插件在这个阶段处理字节码。然后,Class文件会经过/等任务处理生成DEX文件,最后包含在安装包中。整个过程是这样的:
因此,字节码优化有两个机会:
DEX阶段优化Class字节码,DEX阶段优化DEX文件
显然,优化DEX是一种更理想的方式,因为在DEX文件中,除了字节码指令之外,还存在跨DEX引用、字符串池等结构。这些 DEX 格式的优化无法在阶段完成。执行。
确定了优化DEX文件的思路后,我们选择了开源框架ReDex作为优化工具,并进行了定制。
选择ReDex的原因是它提供了丰富的基础能力。 ReDex 的基本功能包括:
具备DEX的读写和解析能力,一定程度上具备对xml等文件的读写和解析能力。能够解析简单的保留规则并匹配类/方法/成员变量。对字节码进行数据流分析的能力提供了常用的数据流分析算法具有验证字节码有效性的能力,包括寄存器检查、类型检查等一系列字节码优化项。每次优化称为一个pass,多个pass由多个pass组成来优化DEX。
我们基于这些能力进行了定制和扩展,期望最终建立一个完整的优化体系。
优化项目
抖音实施的优化项目包括开源优化和我们自研优化。从它们的出发点来看,大致可以分为以下几类:
这些类型的优化没有明确的标准和界限。有时,一个 Pass 会涉及多种类型的优化。下面对各项优化进行详细介绍。
通用字节码优化屁股
这个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;
删除死代码后,终于可以变成0了。
具体优化流程为:
对方法进行数据流分析,主要针对const/move等指令,获取某个寄存器在某个位置可能的值。根据分析结果,进行指令替换或指令删除,包括:
一个方法经过ass优化后,可能会产生一些死代码,比如例子中的int y = 0,这也为后续删除死代码创造了条件。
该Pass用于删除无用的注释。注释主要分为三种:
另外,实际上,为了支持某些系统特性,编译器会自动生成系统注解。尽管注释本身是类型,但它们的可见性是
举个例子
编译器生成匿名内部类$1,带有 和 注解
系统提供了以下接口来获取类相关信息,这是通过分析相关系统注解来实现的。
如果代码中没有使用这些接口获取类信息的逻辑,您可以安全地删除这些注解以减少包大小。
此通道通过减少类名的字符串长度来减少包大小。
例如,将类名从La/b/c/d/e更改为;至 LX/a;可以减少类名字符串的长度,从而减小包的大小。其实它已经提供了类似的功能: - 'X',效果如下:
但是 - 对“X”的处理会影响ReDex的算法逻辑(请参考下文),导致收益减少
本质原因是重命名后影响了函数引用的权重分布,导致收益被收回。
权重算法优化相对复杂,存在很多不确定性,比如与其他优化可能存在冲突,因此我们采用了第二种方案。
这里需要解决的一个关键点是如何判断一个类名是否可以安全地重命名。我们采用了更棘手的方法。只要我们保持与类重命名优化相同的处理,Redex就会分析传递的.txt文件。策略,不会造成反射/调用/序列化等一系列问题。
然而执行过程中我们还是遇到了各种奇怪的问题,比如系统注释失效等。注解的内容是非标准的类名格式,因此在类重命名后简单地写回字符串或者更新Type类型都会导致注解失效。最后通过对格式的深入分析,避免了这个问题。
密码
该Pass旨在优化分析缩写,与死代码删除配合使用可以起到很好的优化效果。
为什么要优化?在Java代码开发过程中,字符串操作几乎是我们最常做的事情,无论是实际处理字符串串联,还是各种不同数据类型之间的串联操作。这些拼接操作都会被Java的脱糖优化为操作。例如:var log = "A" + 1 + "B" + 1.0f + ;将被优化为:
1 StringBuilder builder = new StringBuilder();
2 builder.append("A"); builder.append(1);
3 builder.append("B"); builder.append(1.0f);
4 builder.append(other_var);
5 builder.toString();
因此,我们分析一切。在最好的情况下,多个方法调用可以优化为一个调用。该方法是一个(外部)方法,具体参数拼接隐藏在函数内部:
1 invoke-static {v1, v2, v3} Outline;.bind:([Ljava/lang/Object)Ljava/lang/String;
优化步骤可以简单分为以下几个步骤:
生成一个通用的外部方法和一个带有几个具体参数的方法:我们可以认为生成的方法大致是这样的
1 @Keep
2 public static String bind(Object... args) {
3 StringBuilder builder = new StringBuilder();
4 for (int i = 0; i < args.length ; i++) {
5 builder.append(args[i]);
6 }
7 return builder.toString();
8 }
收集:通过抽象解释和定点分析、分类、new-、init方法来分析所有操作。判断每个参数是否是一个操作。如果增加的insn小于减少的insn,则代码将减少,并且将被处理。生成外部方法调用:由于我们使用泛型方法接受参数,所以需要对基本类型进行转换操作,并且在删除方法之前,为了防止错误的优化,我们还需要插入一条移动指令来复制原始参数(这些移动指令将被后续优化正确删除)。如果参数数量仍在我们生成的具体方法的范围内,我们可以使用特定的方法来生成外部链接函数,其余的将使用广义外部链接来接受。 DEX格式优化
该通证针对跨 DEX 参考进行了优化。
跨DEX引用是指当一个DEX需要“使用”另一个DEX中的类/方法/变量时,需要在这个DEX中保存该类/方法/变量对应的ID。如果两个 DEX 使用相同的字符串,则需要在两个 DEX 中定义该字符串。因此,改变DEX中类/方法/变量和字符串的分布可以减少引用的数量,从而减小DEX的大小。从原理上也可以看出,这种优化对于单一的DEX应用来说是无效的。
从上图可以看到,类重排之后,DEX0中的类引用和方法引用数量都减少了,DEX的大小也会相应减少。
具体优化流程为:
收集每个类涉及的所有引用,根据引用的数量和类型计算该类的权重,根据权重计算每个类的优先级,根据优先级选择一个类放入DEX,然后调整剩余类别的优先级,重复此步骤,直到处理完所有类别
这个Pass是对方法引用的优化,其原理是一样的。
在字节码中,-/ 指令需要方法引用。在许多情况下,此引用指向子类或实现类的引用。用父类和接口的方法引用替换该引用不会影响运行时逻辑。 ,同时也会减少DEX中方法引用的数量。在生成DEX时,方法引用的65536个限制通常是遇到的第一个瓶颈,而这个优化也可以缓解这种情况。
如上图所示,预优化方法的指令使用子类引用。伪指令如下,需要2次引用。
1 new-instance v0, Sub1
2 invoke-virtual v0, Sub1.a()
3 new-instance v1, Sub2
4 invoke-virtual v1, Sub2.a()
优化后,所有指令都指向其父类应用程序,两个引用可以合并为一个,减少DEX中的引用数量。
1 new-instance v0, Sub1
2 invoke-virtual v0, Base.a()
3 new-instance v1, Sub2
4 invoke-virtual v1, Base.a()
编程语言的优化
这个Pass是对数据类的优化。基本思想是简化数据类的生成代码。
中有解构声明语法,这使得创建多个变量变得更容易。基本用法如下
1 data class Person(val name: String,val age: Int)
2 val (name,age) = person("John",20)
将为该类生成 get 方法和方法。下面是伪代码表示:
1 Person {
2 String name;
3 Int age;
4
5 getName(): String { return name; }
6 getAge(): Int { return age; }
7 component1(): String { return name; }
8 component2(): Int { return age; }
9 }
10 // 解构声明编译为
11 val name = person.component12 1()
13 val age = person.component2()
可以看到,get和的逻辑是一样的,所以编译时可以进行全局匹配,替换成get,然后删除。
为数据类生成的代码具有类似的结构,因此可以生成一个辅助方法,然后在所有数据类方法中调用,即外部连接,从而减少指令数量。
和 也可以进行类似的优化,但风险比较高,所以这些优化都单独配置开关,业务方可以根据情况开启。
优化提高压缩率
DEX等文件被压缩成APK。如果可以通过改变DEX的内容来提高压缩率,那么最终的包大小也会减小。通过重新分配寄存器来提高压缩率。
dx在生成DEX时使用线性寄存器分配算法。基本步骤是进行生存变量分析,然后计算每个变量的活动范围,然后根据活动范围依次为变量分配寄存器。超出活动范围的寄存器可以重新分配。 ,其优点是运行速度快,但结果往往不是最优的。
例如下面的代码中,dx分配了6个寄存器,v0~v5
1 public static double calculateLuminance(@ColorInt int color) {
2 final double[] result = getTempDouble3Array();
3 colorToXYZ(color,result);
4 return result[1] / 100;
5 }
相比之下,ReDex 使用图形着色算法进行寄存器分配。基本步骤是执行生存变量分析并构建冲突图。冲突图的每个节点都是一个变量。如果两个变量能够同时存活,则在两个节点之间建立边,最后对冲突图进行着色。每种颜色代表一个寄存器。当着色完成后,寄存器分配就完成了。着色方法相对较慢,并且通常会产生更好的结果。对于上面相同的代码,着色方法使用 4 个寄存器,v0 ~ v3。
DEX 中的方法使用的寄存器越少,其内容重复率和压缩率就越高,从而减小数据包大小。
抖音上线
抖音是字节跳动规模最大、运行环境最复杂的应用之一。 ReDex早期,由于对复杂度的估计不够,在独立灰度和全灰度期间造成了一些问题。在解决问题的过程中,我们逐渐形成迭代过程,保证优化的稳定性。下面介绍一下我们遇到的典型问题以及目前的迭代过程。
遇到的问题 兼容性问题
一般来说,只要按照字节码规范进行优化,就不会出现兼容性问题,因为/art也是按照规范来验证和运行字节码的。即使执行了错误的优化,引起的问题也应该是 。但很多事情都有例外。 ReDex在某品牌手机的部分5.x型号上遇到问题。
从日志和一些hooks来看,某品牌手机对5.x ART做了很多神奇的改变。可以推断,魔改中存在一些问题,可能会导致正确字节码的校验和操作出现问题。一个可能的原因是ReDex在优化时,某些方法体中的指令顺序会重新排列。这种重新安排不会影响方法的逻辑,但可能会改变一些指令。修改后的艺术在学校里。尝试该方法时,可能会报错,导致崩溃。
最终通过黑名单配置跳过了这些方法的优化,避免了问题的发生。在后续的优化过程中,没有再遇到类似的问题。
复杂场景优化问题
抖音的业务复杂,代码编写方式多种多样,这使得静态分析和优化变得更加困难,也更容易遇到问题。以下是 2 个典型问题:
空方法优化问题 代码中可能存在一些空方法。消除反射和调用场景后,剩余的空方法应该被删除。但是在做优化的时候遇到了crash,比如下面的代码
1 object XXXSDKHelper {
2 init {
3 initXXXSDK()
4 }
5 fun fakeInit() {
6 }
7 }
8
9 // 初始化任务
10 public class XXInitTask implements Runnable {
11 @Override
12 public void run() {
13 XXXSDKHelper.INSTANCE.fakeInit();
14 }
15 }
在初始化代码中调用,是一个空方法。调用它的目的是触发类加载并执行init语句块。如果删除这个空方法,则不会执行初始化,并且在后续过程中会抛出空指针。
复杂的反射问题
对于Class.(...)这样简单的反射用法,静态分析是可以分析的,但是对于一些字符串拼接或嵌套后的反射,静态分析就很难分析了。因此,在优化可能反映的代码时需要非常小心。一般来说,匿名内部类不会通过反射来调用。基于这个前提,我们对匿名内部类进行了重命名和优化,但是灰度化之后,发现一些第三方SDK会通过复杂的运行时逻辑对匿名内部类进行反射调用,最终导致了问题的出现。
复杂场景下的一些优化问题是由于业务代码不规范导致的,但更多的时候是因为优化前提(空方法可以删除/匿名内部类不会体现)不成立而导致的,所以在优化的时候,首先需要对假设保持谨慎。确认。
迭代过程
为了减少稳定性问题,我们总结了ReDex Pass的迭代过程。
在对 Pass 有了初步想法后,团队将进行可行性讨论。如果理论上可行,将进入开发验证阶段。此后,将同时进行至少2轮独立灰度验证和业务侧通行证审核,最后进行全面的通行证审核。灰度验证。如果发现任何一个环节出现问题,整个流程都会重做。
通过这个过程,我们大大降低了稳定性问题留给灰度阶段的可能性。在不断完善迭代流程的同时,我们也在探索通过加强单元测试和自动化测试来提高质量的方法。
后续规划
ReDex仍在不断迭代中,未来我们将继续在以下方向进行深入探索:
对包体积优化进行更多的探索和迭代,同时探索字节码优化在性能提升方面的可能性,提高字节码质量,增加编译时监控,更快速便捷地解决编译时字节码问题,提升访问体验。其他 应用方向探索;例如方法插桩、特定条件下的死代码扫描等。加入我们
字节跳动终端技术团队( )是全球大前端基础技术研发团队(在北京、上海、杭州、深圳、广州、新加坡和美国山景城设有研发团队),负责整个字节跳动前端基础设施建设提升公司全产品线的性能、稳定性和工程效率;支持的产品包括但不限于抖音、今日头条、西瓜视频、飞书、瓜瓜龙等,移动端、Web端等,每个终端都经过深入研究。
现在就这样了!客户端/前端/服务器/端智能算法/测试开发面向全球招募!让我们一起用科技改变世界。如果您有兴趣,请联系我们,邮件主题为简历-姓名-求职意向-期望城市-电话号码。
抖音基础技术团队是一支深度追求极致的团队。我们重点关注性能、架构、包大小、稳定性、基础库、编译构建等,保证超大型团队的研发效率和亿万用户的使用体验。 。目前,北京、上海、杭州、深圳等地人才需求量较大。欢迎有志之士与我们一起打造拥有数亿用户的APP!
您可以前往字节跳动官方招聘网站查看“抖音基础技术”相关职位,或联系邮箱: ,直接发送简历寻求推荐或查询相关信息!