-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:()V
3: ldc #7 // String Logger
5: new #8 // class java/lang/StringBuilder
8: dup
9: invokespecial #9 // Method java/lang/StringBuilder.“”😦)V
12: 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:()J
20: 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;)I
29: 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 时,就找到了起始指令的位置。注意在入栈时候要记录参数的类型,并在出栈时候做类型匹配校验。如上面的示例:
-
pop 指令效果是单 slot 参数(像 int,float)出栈 ,那我们就在栈存入一个 slot 类型的参数
-
invokestatic 要看方法的参数和返回值,正常效果是对应方法的参数从右至左依次出栈,方返回值 int 入栈。我们就根据方法返回值出栈一个 int 类型的参数,发现栈顶目前是 slot,类型匹配。然后按照方法参数从左至右依次入栈两个 String 类型的参数。
-
invokevirtual 指令正常方法调用参数依次从右至左依次出栈,然后 this 对象出栈,最后方法返回值 String 入栈。我们弹出栈顶一个参数,发现其和 String 匹配,然后依次入栈 this 对应的类型 StringBuilder,这里调用的是 toString 方法没有参数就不用再入栈。
-
中间其他的指令类似,直到 ldc 指令,本身是向栈中放入一个 int,float 或 String 常量,我们这里弹出一个参数,发现其是 String 匹配,并且此时栈的大小变为 0,也就找到了起始指令的位置。
方案缺陷
不过上述方案存在两个缺陷:
- 因为分析只在单个方法内分析,针对 Log 方法封装的情况,必须需要配置封装方法作为目标方法,才能删除完全删除,比如下面的方法需要配置 AccountLog.d 才能删除其调用处的 StringBuilder 创建。
object AccountLog {
@JvmStatic
fun d(tag: String, msg: String) = Log.d(tag, msg)
}
- 可能会误删除一些有用的指令,因为无法认为 Log.i 的两个参数的构建指令都是没有用的,我们只能确定 StringBuilder 的创建是没用的,但是一些其他的方法调用可能会改变一些对象的状态,因此存在一定风险。
Proguard 方案
在我们上述方案在线上运行一年之后,尝试针对上述弊端进行优化,然后发现 proguard 还提供了 assumenoexternalsideeffects 指令,它可以让我们指定没有任何外部副作用的方法。
指定了以后,它只会修改调用这个方法的实例本身,但不会修改其他的对象。通过如下的配置可以删除无用的 StringBuilder 创建。
-assumenoexternalsideeffects class java.lang.StringBuilder {
public java.lang.StringBuilder();
public java.lang.StringBuilder(int);
public java.lang.StringBuilder(java.lang.String);
public java.lang.StringBuilder append(java.lang.Object);
public java.lang.Str