APK体积优化有感

2022-10-09 11:00:58 浏览数 (1)

theme: fancy

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第5天,点击查看活动详情

本文是阅读字节APK优化文章后的思考和总结

Class阶段优化

思路感觉和编译原理差不多,有些优化前端编译器本身就可以支持。比如内联,常量字段消除等技术,逃逸分析。。。。感觉更像是一种和编译器相辅相成的作用。 ps:就是借鉴了编译器的做法

冗余代码消除

赋值

JVM在类加载阶段会确保全局,static变量赋默认值,如果定义的时候已经进行赋默认值那么这个赋值是冗余的,所以要消除这种赋值

确认目标:

Filed是当前类的变量,Field在init和clinit中赋值,并且之前没有付过非默认值,且此次赋值是默认值,那么这个赋值就是可以优化的

实现:

1.遍历init,clinit的字节码找到putField和putStatic指令

2.过滤不是当前类的变量,指令中可以看到File的全路径名,通过对比当前class。筛选出只对本类Filed赋值的指令

3.将Classname和filedname作为一个key,如果付非默认值则保存到集合中

4.当碰到赋默认值的指令时检测是否在集合中,如果不在集合中进行标记该指令是可以删除的冗余指令

5.遍历完整个字节码之后进行统一删除

删除无用代码

一.使用proguard 的 -assumenosideeffects配置消除无副作用的函数调用

当方法不会修改堆上某个对象或者栈上方法参数的值时,也就是方法调用无副作用时,使用assumenosideeffects配置 会帮助我们在optimize阶段 消除配置好的内容项

缺点:

只会删除配置方法的调用指令,指令虽然无副作用但是其本身需要的内容还是会创建,只是删除了invokeVirual/invokeStatic...方法调用指令本身。

二.手动分析起始指令和终止指令删除

终止指令:就是方法调用指令

1.找到要删除的目标方法调用指令, 2.再根据方法的返回值类型确定是否要包含其后的 pop 或 pop2 指令 3.如果方法有返回值后面是pop指令代表返回值无用 可用删除该方法(带上pop指令);否则该返回值后面需要用到就 不可以删除 4.无返回值的后面没有pop指令,可以放心删除

起始指令:反向分析 当该方法的操作数栈为0时也就标记该方法是起始状态

原理:通过指令对操作数栈入栈出栈的特点,和code结构体中操作数栈size比对。

1.根据终止指令可用得到该方法是有无返回值的。方法调用前后操作数栈应该是一样的所以当调用方法的栈被清除为0时代表回到了调用该方法之前的操作数栈中 2.记录每次入栈的类型,之后对操作数栈操作数需要对比是否是有效的操作-1 3.虚方法需要多一个this参数,正常方法执行流程参数从左到右依次入栈再从右到左依次出栈,最后讲返回值入栈。 4.反着来的话就是当前的栈顶是返回值,弹出该返回值,虚方法先入栈this,static方法入栈从右到左参数,根据指令对操作数栈操作。(操作数栈查看Class结构的code中会存储。返回值出栈,参数从右到左入栈) 5.变为0代表该方法退回到执行前的操作数栈,也就是该无用方法的完整指令 6.消除该段指令即可 案例: logi 需要两个string参数,返回一个int变量 消除逻辑如下 1.根据返回值是否用到指令会在其后面是否返回pop指令。返回pop指令代表无用可用消除该方法(找到终止指令确定该方法返回值无用) 2.记录该方法操作数栈的数量 3.有返回值就弹出,无返回值不做处理。接着倒叙执行指令 比如执行前0--->1--->2---->1--->0 倒叙执行就是0--->1---->2----1------0 当到达0时就是最开始的地方,每次入栈都要和之前的出栈类型进行比对,确保该出栈得到的结果是有效的(反例:入栈int,出栈string。强转 如果不记录该类型之后比对的时候就会有疑惑,入栈string,出栈的是int?? ) 4.直到操作数栈变为0,代表该方法回到最开始还没有执行指令的地方,删除这个地方到终止指令地方的指令 消除成功

优点:

无用方法的调用在这种情况下不仅仅会删除方法调用指令,连其涉及到的指令都会一并删除

缺点:

1.涉及到的指令会一并删除:这也意味着指令如果在调用无用方法时无用,但之后却需要用到这个指令,典型的比如logi传入了一个对象,之后有对这个对象进行操作。对象消除后之后再对对象操作时会npe。

2.只能进行单个方法内分析,因此需要封装

三,Proguard方案使用assumenoexternalsideeffects 配置(实例方法返回值无用消除实例创建)

和第一个一样,区别是该指令不仅会消除方法调用指令,也会修改调用者的实例(该实例方法的返回值无用消除),但是不会修改其他对象以防止出现2中的npe

对方法调用的实例进行修改(方法返回值无用时则会删除)而不会修改其他对象。

减少dexmethod,Fidle数量

当超出65536 时,会新创建一个dex包来存放。具有引用关系的class优先存放在同一个dex文件中

方法内联

access方法内联

access方法是JVM为了保证让内部类可以访问外部类的私有成员所生成的。JVM会在外部类生成静态access方法接受外部类实例来访问对应的私有成员

思路:

  • JVM生成,所以其AccessFlags为 ACC_STATIC, ACC_SYNTHETIC(该Flag表示是JVM自己生成的方法)
  • 名称开头固定为access
  • 删除access方法,将access方法访问的Field,Method的AccessFlags变为Public
  • 找到调用access的指令替换为直接访问Field,Method(内部类创建时会持有外部类引用)

实现步骤:

1.遍历所有method,收集access开头并且AccessFlags为 ACC_STATIC, ACC_SYNTHETIC的方法记录

2.分析其code结构体将code中访问的Field或Method(需要保存对应access方法访问的目标)的权限改为public并删除access方法

3.找到invokeStatic作用于access的指令,删除方法调用;根据之前保存的目标进行替换为真正访问的目标

get,set内联

和access一样的思路。

有两种方式:可以构建map保存classname-get/setfiled和对应code结构体;也可以不保存直接替换。相比来说第二种更好,但是扩展性更低如果set方法内还进行了其他操作不仅是简单的set那么久会有风险 第一种 扩展性更好,但是相对于复杂 第二种简单,但其作用范围仅适用于单纯的set,get。根据指令可用直接分析出code不需要保存code结构体

操作步骤:

  • 找到setget方法,找到aload,getfield典型的get方法。标记其访问的Field将其权限修改成public,删除get方法【风险:如果在混淆中被保留代表可能其他地方会访问,删除后会有风险】。找到调用get指令的地方将其替换·为之前记录的直接访问标记的字段即可
  • 找到set方法找到作用的字段,设置权限。删除set方法,找到调用set指令的地方修改为set指令字节码
Proguard

缺点:

对内联层级过高以及像 builder 方法这种情况支持的不好

无法配置哪些方法内联

语言层面:Java无法配置内联方法,相比于kotlin来说inline修饰Method就可以。

kotlin提出inline可能最主要的问题是基于Lambda无法实现像Java那样运行时替换的方法,只能创建匿名类实现,嵌套过多的Lambda又会导致性能降低创建过多无用实例对象,可能是被迫提出的inline。

优点:混淆,shrink无用代码,短方法内联唯一方法内联。。。

成果

抖音上两个短方法内联减少定义方法数 7 万 ,DEX 文件减少一个,包体积收益达到了 1.7M。

常量字段消除

编译原理里面也有这项优化技术,javac会自动消除final常量调用处替换,但是kotlin中有例外

对于Kotlin,未声明为const的变量不会进行消除(即使他被定义为final)

思路:

  • 分析static,final的常量,过滤用来表示序列化对象版本的 serialVersionUID 字段; 还有反射使用到的字段(一般来说不太会有反射访问 final 类型变量的情况,但这里还是会尝试分析代码中对字段的反射调用,如果有对应的访问则保留。)
  • 找到getsatic指令,分析其访问的字段是否在1中出现,接着消除这条指令替换为对应的常量入栈即可

风险:

替换为直接传播后如果不在同一个dex文件会有dex体积变大的风险。dex文件多个class共享常量池,如果不是同一个dex文件则这个string会创建多次扩大体积

收益:

常量字段消除优化总体带来 400KB 左右的包体收益。

R.class内联

R文件在Application和Module的不同处理
application: 常量消除

R文件内部的Field都是使用static,final修饰的。观察字节码会发现访问R文件的getstatic指令会变成ldc直接引入id常量

module:未使用常量消除

由于R文件中的id不能重复,而原生的aapt是根据类别和顺序生成的资源id,是固定的。所以不能在moudle中写死不然和application编译的时候会引起冲突(同一id对应多个资源),所以module中访问R文件都是用的指令访问不能常量消除

因此在module打包的时候会生成一份临时的R文件,记录module中访问R文件的名称,在application打包的时候取出所有module的R文件和自己的R.java给到aapt自己去处理资源int值。这样module是按照R引用访问,application按照常量访问不会出问题(需要注意的是module生成的R文件不是static修饰的因此会绕过javac的常量消除而被保留下来之后和app的R文件一起给到aapt)

问题

一,Proguard的处理:默认的Proguard混淆会keep R文件。将所有 R 以及 R 内部类的以 public static 修饰的域保留,使其不被优化。因此在我们最终的 APK 中,R.class 仍然存在,这造成了我们包体积的膨胀。

Android dex分包的处理:实际上,造成我们包体积膨胀的原因不止 R 的域的定义和赋值,在 Android 中,一个 DEX 可放置的 field 的数量上限固定是 65536,超过这个限制则我们需要将一个 DEX 拆分为两个。多个 DEX 会导致 DEX 中的复用数据变少,从而进一步提升了包体积的膨胀。因此我们对于 R 的优化,在 DEX 层面上也会有很大的收益。

处理

R文件Field消除

0 人点赞