找回密码
 会员注册
查看: 16|回复: 0

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

[复制链接]

3

主题

0

回帖

10

积分

新手上路

积分
10
发表于 2024-10-1 10:48:32 | 显示全部楼层 |阅读模式
动手点关注干货不迷路本文作者:冯瑞;廖斌斌;刘丰恺前言应用安装包的体积会显著影响应用的下载速度和安装速度,按照 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 实际上包含了常量折叠和常量传播。常量折叠是在编译期简化常量的过程,比如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;再经过死代码删除就可以最终变为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_SYSTEMAnnotationDefault : 默认注解,不能删除EnclosingClass : 当前内部类申明时所在的类EnclosingMethod : 当前内部类申明时所在的方法InnerClass : 当前内部类名称MemberClasses : 当前类的所有内部类列表MethodParameters : 方法参数Signature : 泛型相关Throws : 异常相关举例说明编译器生成 1MainApplication$1这个匿名内部类,带有 EnclosingMethod 和 InnerClass 注解系统提供以下接口获取类相关的信息,就是通过分析相关的系统注解来实现的Class.getEnclosingMethodClass.getSimpleNameClass.isAnonymousClass....如果代码中不存在使用这些接口获取类信息的逻辑,就可以安全的移除这部分注解,从而达到缩减包大小的目的。RenameClassesPass该 Pass 通过缩减类名的字符串长度来减小包体积比如把类名从La/b/c/d/e;改为LX/a;,可以类名字符串的长度,从而达到包大小缩减的目的。实际上 Proguard 本身已经提供类似的功能: -repackageclasses 'X',效果如下:但是-repackageclasses 'X'的处理会影响 ReDex 的 InterDexPass 的算法逻辑(InterDexPass 可以参考下文),导致收益缩减收益测试Proguard -repackageclasses 'X' 收益: 600K+Redex InterDexPass 收益: 400K+同时应用 Proguard -repackageclasses 'X' 和 Redex InterDexPass 收益: 40K+本质原因在于 Proguard 重命名后,影响了 InterDexPass 函数引用权重分配,导致 InterDex 收益被回收解决方案InterDexPass 深入分析原理,优化权重算法先执行 InterDexPass,后执行类似 Proguard 的-repackageclasses 'X'权重算法优化相对来说比较复杂,同时存在众多不可确定性,比如潜在的跟其他优化的冲突,所以我们采取了第二种解决方案。这里需要解决的一个关键点在于如何确定一个类名是否可以被安全的重命名,我们采取了一个比较取巧的方式,ReDex 会分析 Proguard 传递上来 mapping.txt 文件,只要我们保持跟 Proguard 类重命名优化一样的处理策略,就不会引发反射/native 调用/序列化等一系列问题。但是执行起来还是碰到各种千奇百怪的问题,比如 Signature 系统注解失效问题。Signature 注解的内容是非标准的类名格式,所以类重命名后简单回写字符串或者更新 Type 类型会导致 Signature 注解失效,最后通过深入解析 Signature 格式规避了这个问题。StringBuilderOutlinerPass该 Pass 是针对 StringBuilder 的 CallSites 进行分析缩略的优化,与死代码删除搭配使用可以有不错的优化效果。为何要优化 StringBuilder 呢?在 Java 的代码开发过程中,字符串操作几乎是我们最经常做的一件事情,无论是实际处理字符串拼接还是各种不同数据类型之间的拼接操作。而这些拼接操作都会被 Java 的 de-sugar 优化为 StringBuilder 操作。比如:var log = "A" + 1 + "B" + 1.0f + other_var; 会被优化为:1StringBuilderbuilder=newStringBuilder();2builder.append("A");builder.append(1);3builder.append("B");builder.append(1.0f);4builder.append(other_var);5builder.toString();因此我们对 StringBuilder 的所有 Callsites 进行分析,在最好情况下多个方法调用可以被优化为一个调用,这个方法是一个 outline (外联)方法,具体的参数拼接和 toString 被隐藏在函数内部:1invoke-static{v1,v2,v3}Outline;.bind[Ljava/lang/Object)Ljava/lang/String;优化步骤可以被简单的分为如下几个步骤:生成一个泛型的外联方法、以及数个特定参数的方法:我们可以认为生成的方法大概是这样的1@Keep2publicstaticStringbind(Object...args){3StringBuilderbuilder=newStringBuilder();4for(inti=0;i
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 会员注册

本版积分规则

QQ|手机版|心飞设计-版权所有:微度网络信息技术服务中心 ( 鲁ICP备17032091号-12 )|网站地图

GMT+8, 2025-1-13 07:35 , Processed in 0.490863 second(s), 25 queries .

Powered by Discuz! X3.5

© 2001-2025 Discuz! Team.

快速回复 返回顶部 返回列表