卷起来!抖音Android包体积优化探索( 三 )

最终发现上述字节码中,标红的部分可以删除,删除对应的字节码指令,优化完成 。
使用抖音之前开源的字节码处理框架 ByteX,可以比较方便地获取 Field 的 Class,遍历 Class 的所有方法,以及所有方法的字节码 。我们也已经将此方案进行了开源,有兴趣的同学可以前往查看详细代码:

  • https://github.com/bytedance/ByteX/blob/master/field-assign-opt-plugin
删除无副作用代码冗余赋值是利用了虚拟机在类加载时为字段默认赋值的特性,从而删除多余的的赋值指令,而我们代码中本身也有一些对线上包是没有作用的,最常见的就是日志打印,除了占用包体积之外,还会造成性能问题以及安全风险,因此一般都会将其移除掉,接下来我们以 Log.i 调用为例来介绍如何删除代码中的无用函数调用 。比如下面代码中的日志打印语句:
public static void click() {clickSelf();Log.i("Logger", "click time:" + System.currentTimeMillis());}一开始我们尝试了 proguard 的 -assumenosideeffects,这个指令需要我们假定要删除的方法调用没有任何的副作用,并且从程序分析的角度来说这个方法是不会修改堆上某个对象或者栈上方法参数的值 。使用如下配置,proguard 就会在 optimize 阶段帮我们删除 Log 相关的方法调用 。
-assumenosideeffects class android.util.Log {public static boolean isLoggable(java.lang.String, int);public static int v(...);public static int i(...);public static int w(...);public static int d(...);public static int e(...);}但是这种删除并不彻底,它只会删除方法调用指令本身,比如上面的代码中删除 Log.i 方法调用之后,会遗留一个 StringBuilder 对象的创建:
public static void click() {clickSelf();new StringBuilder("click time:")).Append(System.currentTimeMillis();}这个对象的创建我们人为判断的话也是无用的,但是仅从简单的静态程序指令分析的角度并不能判定其是无用的,因此 proguard 并没有将其删除 。
既然 assumenosideeffects 删除不干净,我们就自己来实现更加彻底的优化方案 。
优化思路 public static void click();Code:0: invokestatic#6// Method clickSelf:()V3: ldc#7// String Logger5: new#8// class java/lang/StringBuilder8: dup9: invokespecial #9// Method java/lang/StringBuilder."<init>":()V12: ldc#10// String click time:14: invokevirtual #11// Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;17: invokestatic#12// Method java/lang/System.currentTimeMillis:()J20: invokevirtual #13// Method java/lang/StringBuilder.append:(J)Ljava/lang/StringBuilder;23: invokevirtual #14// Method java/lang/StringBuilder.toString:()Ljava/lang/String;26: invokestatic#2// Method android/util/Log.i:(Ljava/lang/String;Ljava/lang/String;)I29: pop如上可以看到一行Log.i("Logger", "click time:" + System.currentTimeMillis());在编译完成之后会生成多条指令(从 ldc 到 pop),除了目标方法 Log.i 调用 invokestatic 指令外,还有很多参数创建和入栈指令 。
我们要删除相关方法的调用的话,主要是就是找到这行代码所产生的起始指令和终止指令,然后起始到终止位置之间的指令就是我们要删除的全部指令 。
1. 查找终止指令位置
终止指令的查找相对简单,主要就是找到要删除的目标方法调用指令,再根据方法的返回值类型确定是否要包含其后的 pop 或 pop2 指令 。
比如上述代码我们通过遍历就能找到目标方法调用invokestatic #2的位置,因为 Log.i 的返回值类型是 int,终止指令就是下一条的 pop 。
注意 pop 指令的作用是主动让 int 类型的值出栈,也就是不会使用该方法的返回值,只有这种情况下我们才能安全删除目标方法,否则不能删除 。当然如果方法的返回值类型是 void,就不会有 pop 指令 。
2. 查找起始指令位置
起始指令的查找则需要我们对于 java 字码指令设计有基本的认识: java 字节码指令是基于堆栈设计的,每一条字节码指令会对应操作数栈的若干参数的入栈和出栈,并且一个完整独立代码/代码块执行前和执行后操作数栈应该是一样的 。
因此我们找到终止指令后,倒序遍历指令,根据指令的作用进行反向的入栈和出栈操作,当我们的栈中 size 减为 0 时,就找到了起始指令的位置 。注意在入栈时候要记录参数的类型,并在出栈时候做类型匹配校验 。如上面的示例: