抖音 Android 包体积优化探索:基于 ReDex 的 DEX 优化落地实践

前言

应用安装包的体积会显著影响应用的下载速度和安装速度,按照Google的经验数据,包体积每增加1M会造成0.17%的新增折损。抖音的一些实验也证明了包体积会显著影响下载激活的转化率。

Android的安装包是APK格式的,在抖音的安装包中DEX的体积占比达到了40%以上,所以针对DEX的体积优化是一种行之有效的包体积优化手段。

DEX本质上是由Java/Kotlin代码编译而成的字节码,因此,针对字节码进行业务无感的通用优化成为我们的一个探索方向。

优化结果

终端基础技术团队和抖音基础技术团队在过去的一年里,利用ReDex在抖音包体积优化方面取得了一些明显的收益,这些优化也被同步到了其他各大App上。

优化思路

在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文件

解析简单的proguardkeep规则并匹配类/方法/成员变量的能力

对字节码进行数据流分析的能力,提供了常用的数据流分析算法

对字节码进行合法性校验的能力,包括寄存器检查、类型检查等

一系列的字节码优化项,每项优化称为一个pass,多个pass组成pipeline对DEX进行优化

我们基于这些能力进行了定制和扩展,并期望最终建立完善的优化体系。

优化项

在抖音落地的优化项,包括facebook开源的优化和我们自研的优化,从其出发点来看,可以大致分为下面几种:

通用字节码优化:通常意义下的编译优化,如常量传播、内联等,一般也可在Transformer阶段实现

DEX格式优化:DEX中除了字节码指令外,还包括字符串池、类/方法引用、debug信息等等,针对这些方面的优化归类为DEX格式优化

针对编程语言的优化:Java/Kotlin的一些语法糖会生成大量字节码,可以对这些字节码进行针对性的分析和优化

提升压缩率的优化:将DEX打包成APK实质上是个压缩的过程,对DEX内容进行针对性的优化可以提升压缩率,从而产生体积更小的APK

这几种优化没有明确的标准和界线,有时一个Pass会涉及到多种,下面详细介绍一下各项优化。

通用字节码优化ConstantPropagationPass

该Pass实际上包含了常量折叠和常量传播。

常量折叠是在编译期简化常量的过程,比如

1y=7-14/22---3y=0

常量传播是在编译期替代指令中已知常量的过程,比如

1intx=14;2inty=7-x/2;3returny*(28/x+2);4---5intx=14;6inty=7-14/2;7return(7-14/2)*(28/14+2);

上面的例子经过常量折叠+常量传播优化后就会简化为

1intx=14;2inty=0;3return0;

再经过死代码删除就可以最终变为return0。

具体的优化过程是:

对方法进行数据流分析,主要针对const/move等指令,得出一个寄存器在某个位置可能的取值

根据分析的结果,进行指令替换或指令删除,包括:

如果值肯定是非空的,可以将对应的判空去掉,比如kotlin生成的nullcheck调用

如果值肯定为空,可以将指令替换为抛空异常

如果值肯定让某if分支走不到,可以删除对应的分支

如果值是固定的,可以用const指令替换对应的赋值或计算指令

一个方法经过ConstantPropagationPass优化后,可能会产生一些死代码,比如例子中的inty=0,这也为后续的死代码删除创造了条件。

AnnoKillPass

该Pass是用来移除无用注解的。注解主要分为三种类型:

SOURCE:java源码编译为class字节码就不可见,此类注解一般不用过于关注

RUNTIME:DEX中仍然可见,代码运行中可以通过getAnnotations等接口获取注解信息,但是随着业务的迭代,可能获取注解信息的代码已经去掉,注解却没有下掉,这部分注解会被ReDex安全的移除

除此之外,实际上为了支持某些系统特性,编译器会自动生成系统注解,虽然注解本身是RUNTIME类型,但是可见性是VISIBILITY_SYSTEM

AnnotationDefault:默认注解,不能删除

EnclosingClass:当前内部类申明时所在的类

EnclosingMethod:当前内部类申明时所在的方法

InnerClass:当前内部类名称

MemberClasses:当前类的所有内部类列表

MethodParameters:方法参数

Signature:泛型相关

Throws:异常相关

举例说明

编译器生成1MainApplication$1这个匿名内部类,带有EnclosingMethod和InnerClass注解

系统提供以下接口获取类相关的信息,就是通过分析相关的系统注解来实现的

.

如果代码中不存在使用这些接口获取类信息的逻辑,就可以安全的移除这部分注解,从而达到缩减包大小的目的。

RenameClassesPass

该Pass通过缩减类名的字符串长度来减小包体积

比如把类名从La/b/c/d/e;改为LX/a;,可以类名字符串的长度,从而达到包大小缩减的目的。实际上Proguard本身已经提供类似的功能:-repackageclasses'X',效果如下:

但是-repackageclasses'X'的处理会影响ReDex的InterDexPass的算法逻辑(InterDexPass可以参考下文),导致收益缩减

收益测试

Proguard-repackageclasses'X'收益:600K+

RedexInterDexPass收益:400K+

同时应用Proguard-repackageclasses'X'和RedexInterDexPass收益:40K+

本质原因在于Proguard重命名后,影响了InterDexPass函数引用权重分配,导致InterDex收益被回收

解决方案

InterDexPass深入分析原理,优化权重算法

先执行InterDexPass,后执行类似Proguard的-repackageclasses'X'

权重算法优化相对来说比较复杂,同时存在众多不可确定性,比如潜在的跟其他优化的冲突,所以我们采取了第二种解决方案。

这里需要解决的一个关键点在于如何确定一个类名是否可以被安全的重命名,我们采取了一个比较取巧的方式,ReDex会分析Proguard传递上来文件,只要我们保持跟Proguard类重命名优化一样的处理策略,就不会引发反射/native调用/序列化等一系列问题。

但是执行起来还是碰到各种千奇百怪的问题,比如Signature系统注解失效问题。Signature注解的内容是非标准的类名格式,所以类重命名后简单回写字符串或者更新Type类型会导致Signature注解失效,最后通过深入解析Signature格式规避了这个问题。

StringBuilderOutlinerPass

该Pass是针对StringBuilder的CallSites进行分析缩略的优化,与死代码删除搭配使用可以有不错的优化效果。

为何要优化StringBuilder呢?在Java的代码开发过程中,字符串操作几乎是我们最经常做的一件事情,无论是实际处理字符串拼接还是各种不同数据类型之间的拼接操作。而这些拼接操作都会被Java的de-sugar优化为StringBuilder操作。比如:varlog="A"+1+"B"+1.0f+other_var;会被优化为:

1StringBuilderbuilder=newStringBuilder();2("A");(1);3("B");(1.0f);4(other_var);5();

因此我们对StringBuilder的所有Callsites进行分析,在最好情况下多个方法调用可以被优化为一个调用,这个方法是一个outline(外联)方法,具体的参数拼接和toString被隐藏在函数内部:

1invoke-static{v1,v2,v3}Outline;.bind:([Ljava/lang/Object)Ljava/lang/String;

优化步骤可以被简单的分为如下几个步骤:

生成一个泛型的外联方法、以及数个特定参数的方法:我们可以认为生成的方法大概是这样的

1@Keep2publicstaticStringbind(Objectargs){3StringBuilderbuilder=newStringBuilder();4for(inti=0;;i++){5(args[i]);6}7();8}

收集StringBuilder的CallSites:通过抽象解释和不动点分析,分析所有的StringBuilder操作,对app、new-instance、和init方法分类。判断每次app的参数是不是immutable操作,如果增加的insn少于减少的insn即会减少代码,就对这里进行处理。

生成外联方法调用:由于我们使用了泛型方法来接受参数,因此我们要对基础类型生成ValueOf的转换操作、并且删除app方法前为了防止被错误优化我们还需要插入move指令来copy原有参数(这些move指令会被后续优化正确删除)、如果参数个数还在我们生成的特定outline方法范围内我们就可以使用特定方法来生成外联函数,其余的将使用泛化的外联来接受。

DEX格式优化InterDexPass

该Pass是针对跨DEX引用的优化。

跨DEX引用是指当一个DEX需要“使用”到另一个DEX中的类/方法/变量时,需要在本DEX中保存一份对应的类/方法/变量的id,如果2个DEX用到了相同的字符串,那么这个字符串在2个DEX都需要进行定义。所以,改变类/方法/变量和字符串在DEX中的分布,可以减小引用的数量,从而减小DEX的体积。从原理中也可以看出,该优化对单DEX的应用是无效的。

从上图可以看到,进行类重排后,DEX0的类引用和方法引用数量都减少了,DEX的体积也会因此减小。

具体的优化过程是:

收集每个类涉及的所有引用,按照引用数量和类型计算出类的权重

根据权重计算出每个类的优先级

根据优先级选取一个类放入DEX中,然后调整剩余类的优先级,重复此步骤直到所有类都被处理

ReBindRefsPass

该Pass是针对方法引用的优化,其原理同InterDexPass。

在字节码中,invoke-virtual/interface指令需要一个方法引用,在很多情况下,这个引用指向的是子类或者实现类的引用,把这个引用替换成父类和接口的方法引用不会影响运行时逻辑,同时会减少DEX中方法引用的数量。在生成DEX的时候,方法引用的65536限制通常是最先遇到的瓶颈,该优化也可以缓解这种情况。

如上图所示,优化前caller方法的invoke指令使用的是子类引用,其伪指令如下所示,需要用到2个引用

1new-instancev0,Sub12invoke-virtualv0,()3new-instancev1,Sub24invoke-virtualv1,()

优化后,invoke指令都指向其父类应用,2个引用可以合并为1个,减少了DEX中的引用数量

1new-instancev0,Sub12invoke-virtualv0,()3new-instancev1,Sub24invoke-virtualv1,()
针对编程语言的优化KotlinDataClassPass

该Pass是对Kotlindataclass的优化,基本思路是对dataclass的生成代码进行精简。

解构声明优化

Kotlin中存在解构声明这种语法,可以更方便的创建多个变量,基本用法如下

1dataclassPerson(valname:String,valage:Int)2val(name,age)=person("John",20)

kotlinc会为Person类生成get方法和componentN方法,如下是伪代码表示

1Person{2Stringname;3Intage;45getName():String{returnname;}6getAge():Int{returnage;}7component1():String{returnname;}8component2():Int{returnage;}9}10//解构声明编译为11valname=()13valage=()

可以看到,get和component的逻辑是一样的,所以在编译期,可以进行全局的匹配,用get替换掉component,然后再删除component。

toString等生成方法优化

kotlincompiler为dataclass生成的toString具有相似的代码结构,因此可以生成一个辅助方法,然后在所有dataclass的toString方法中调用这个辅助方法,即外联,从而减少指令数量。

equals和hashCode也可以进行类似优化,但是风险相对较高,因此单独为这些优化配置了开关,业务方可以视情况开启。

提升压缩率的优化RegAllocPass

DEX及其他文件经过压缩打成APK,如果能通过改变DEX的内容来提升压缩率,那么也会减小最终的包体积。RegAllocPass就是通过重新分配寄存器来提升压缩率的。

dx生成DEX时使用的是线性寄存器分配算法,其基本步骤是进行存活变量分析,然后计算出每个变量的活跃区间,再根据活跃区间依次为变量分配寄存器,超出活跃区间的寄存器可以进行再分配,其优点是运行速度快,但结果往往不是最优的。

比如下面的代码,dx分配了6个寄存器,v0~v5

1publicstaticdoublecalculateLuminance(@ColorIntintcolor){2finaldouble[]result=getTempDouble3Array();3colorToXYZ(color,result);4returnresult[1]/100;5}

相对的,ReDex使用了图着色算法进行寄存器分配,基本步骤是进行存活变量分析,并构建冲突图,冲突图的每个节点是一个变量,如果2个变量可以同时存活,就在两个节点之间建立边,最后为冲突图着色,每个颜色代表一个寄存器,着色完成即寄存器分配完成。着色法相对更慢,结果一般更优。对上面同样的代码,着色法使用了4个寄存器,v0~v3。

DEX中的方法使用的寄存器越少,其内容重复率就越高,压缩率也会更大,从而减小了包体积。

抖音落地

抖音是字节跳动规模最大、运行环境复杂度最高的应用之一。在ReDex落地初期,由于对复杂度估计不足,在独立灰度和全量灰度期间引起了一些问题,在解决问题的过程中,我们也逐步形成了一套迭代流程以保证优化的稳定性。下面介绍一下我们遇到过的典型问题及当前的迭代流程。

遇到的问题兼容性问题

一般来说,只要按照字节码规范进行优化,就不会有兼容性问题,因为dalvik/art也是按照规范去校验和运行字节码的,即使进行了错误的优化,引起的问题也应该是共性问题。但很多事都有例外,ReDex就在某品牌手机的部分的机型上遇到了问题。

从log和一些hook来看,某品牌手机对5.x的art做了大量的魔改,可以推断其魔改存在一些问题,导致对正确的字节码的校验和运行也可能出现问题。一个可能的原因是:在ReDex进行优化时,会对一些方法体的指令顺序进行重排,这种重排是不影响方法的逻辑的,但是可能会改变一部分指令,魔改后的art在校验这样的方法时可能会报verifyerror,引起crash。

最终通过黑名单配置跳过了这些方法的优化规避了问题,在后续的优化过程中,没有再遇到类似的问题。

复杂场景优化问题

抖音业务复杂,代码写法多样,给静态分析和优化增加了一些难度,也更容易遇到问题。下面是2个典型问题:

空方法优化问题代码中可能存在一些空方法,排除掉反射和natvie调用等场景后,剩下的空方法应该是可以删除的。但是在做优化时,却遇到了crash,如以下代码

1objectXXXSDKHelper{2init{3initXXXSDK()4}5funfakeInit(){6}7}89//初始化任务10publicclassXXInitTaskimplementsRunnable{11@Override12publicvoidrun(){13();14}15}

在初始化代码中调用fakeInit,它是一个空方法,调用它的目的是触发XXSDKHelper类加载从而执行init语句块,如果删除了这个空方法,就会导致初始化未执行,在后续的流程中抛空指针。

复杂反射问题

对于()等简单的反射用法,静态分析是可以分析出来的,但是对一些经过字符串拼接或者嵌套之后的反射,静态分析很难分析到。因此,对可能会被反射的代码进行优化需要非常小心,通常来说,匿名内部类是不会通过反射调用的,基于此前提,我们进行了匿名内部类的重命名优化,但是在灰度后,发现某些第三方SDK会通过复杂的运行时逻辑对匿名内部类进行了反射调用,最终导致了ClassNotFoundError。

复杂场景的优化问题有些是业务代码不规范造成的,但更多的是优化前提(空方法可以删除/匿名内部类不会被反射)不成立所导致,所以在进行优化时首先需要对假设进行谨慎的验证。

迭代流程

为了减少稳定性问题,我们总结了ReDexPass的迭代流程。

在对一项Pass有了初步构思后,组内会进行可行性讨论,如果理论上可行就进入开发和验证阶段,之后同步进行至少2轮的独立灰度验证和业务方Pass评审,最后进行全量灰度验证。其中任意一个环节发现问题,都会重新进行整个流程。

通过这个流程,我们大大减少了稳定性问题遗留到灰度阶段的可能,在不断完善迭代流程的同时我们也在探索通过加强单元测试、自动化测试等方式来提升质量。

后续规划

ReDex仍然在持续迭代中,未来我们会在以下几个方向继续进行深入探索:

更多包体积优化的探索和迭代,同时探索字节码优化在性能提升方面的可能性

提升字节码质量

更加严格的合法性校验;ReDex之前已经检测出若干自定义插件和proguard的问题,将问题拦截在了编译期,后续会继续提升该能力

建立更加完善的质量验证体系;ReDex作为编译期的全局字节码优化方案,如果保证优化后的字节码质量一直是个痛点,我们会继续在单元测试、自动化测试等方向探索质量提升的手段

增加编译期监控,更加快速便捷的解决编译期字节码问题,提升接入体验

其他应用方向探索;如方法插桩、某些条件下的死代码扫描等。

加入我们

就是现在!客户端/前端/服务端/端智能算法/测试开发面向全球范围招聘!一起来用技术改变世界,感兴趣请联系@,邮件主题简历-姓名-求职意向-期望城市-电话。

抖音Android基础技术团队是一个深度追求极致的团队,我们专注于性能、架构、包大小、稳定性、基础库、编译构建等方向的深耕,保障超大规模团队的研发效率和数亿用户的使用体验。目前北京、上海、杭州、深圳都有大量人才需要,欢迎有志之士与我们共同建设亿级用户APP!

可以进入字节跳动招聘官网查询「抖音基础技术Android」相关职位,或者联系邮件:@,直接发送简历内推或者咨询相关信息!

发布于 2025-01-01
59
目录

    推荐阅读