前言
在系列的前文中,详细地描述了Instant Run方案的设计思路和在实际场景愈渐复杂情况下的方案细节演进,知道了如何通过注入代码,配合补丁让app无需hook系统API也能够表现出可控的动态性。关于补丁,我们也了解了其应该包含的内容,按照前文的描述,由于补丁类是一个新类,因此我们需要将被修复类的非public成员方法/字段的直接调用转译成反射调用(这里的反射调用涉及到的api仅会有protected修饰的,不用担心反射限制问题),这一步通过手动编写也没有太大的难度,但实际的情况下,Instant Run方案构建补丁遇到的问题远不止前文提到的内容。在这一篇文章中,我们会来研究如何自动化地生成一个符合前文标准的补丁文件。这一章的内容会些微涉及到一些Java字节码的知识,先前不了解的话也不用担心,自动化部分需要关注的只是一小部分字节码结构,而且很简单。
转译原有逻辑
还是从前文举的例子来看
public class HotfixDemo {
private static int sNumberA = 1;
protected int mNumberB = 2;
public String mString = "3";
public String method() {
String msg = sNumberA + ":" + mNumberB + mString;
print(msg);
return msg;
}
private void print(String msg) {
System.out.println(msg);
}
}
在这个例子中,需要将非public的字段/方法访问进行转译,若想通过某种方式判断方法或字段的访问,那通过字节码来确定无疑是最好的方法。不过,这简单的一步还不需要直接操作字节码来完成,Javassist提供的ExprEditor功能就能很好做到这一点,Javassist 在CtBehavior和CtClass中提供了instrument()方法进行字节码处理,并可传递一个ExprEditor供调用者实现回调,在回调中可以处理一些常见的指令,如字段访问、方法调用、new对象等等。这些回调中的参数Expr又贴心的提供了replace()方法,这个方法可以使用新的指令直接替换原本的指令。
ctClass.instrument(object : ExprEditor() {
override fun edit(f: FieldAccess?) {
if (f == null) {
return
}
val className = f.className
val fieldName = f.fieldName
if (f.isReader) {
f.replace("\$_ = ReflectHelper.getFieldValue(\"${className}\", \"${fieldName}\", _host);")
} else {
f.replace("ReflectHelper.setFieldValue(\"${className}\", \"${fieldName}\", _host, $1);")
}
}
override fun edit(m: MethodCall?) {
if (m == null) {
return
}
val calledClassName = m.className
val calledMethodName = m.methodName
// 没有处理returnType,如果只使用方法返回值也可以用$_接收结果
if (calledClassName == "com.test.HotfixDemo") {
m.replace("ReflectHelper.invokeMethod(\"$calledClassName\",\"${calledMethodName}\",_host,\$args);")
}
}
})
可以看到,处理字段访问需要要根据读写进行区分,以读取字段为例,将FiedAccess替换为ReflectHelper.getFieldValue()的反射调用,FieldValue()的参数为反射时必要的类限定名和字段名,以及反射作用的实例对象_host,在这个语境下_host是被修复类的运行时实例,当然,如果在static方法中访问字段的话,_host为null。因为是读取字段,所以这里的结果需要被保存到$_中。$_和代码段后面的$1、$args都是Javassist提供的宏,用于在替换时使用不同指令的参数。相关内容可以见这篇文章:Java字节码工具使用心得,感兴趣的可以了解一下,这里不再赘述了。
上面的例子最后生成的代码将会如下所示,这个类将会被反射创建实例,并在被修复方法的调用点传递this作为_host(如果上下文方法为static,_host = null),在该类中就可以直接调用方法或者访问字段了。
// 加上下划线表示是自动生成的
public class HotfixDemo_Patch implements MethodPatchInfo {
public String method(HotfixDemo _host) {
String var1 = (String) ReflectHelper.getFieldValue("com.test.HotfixDemo", "sNumberA", _host);
String var2 = (String) ReflectHelper.getFieldValue("com.test.HotfixDemo", "sNumberB", _host);
String var3 = _host.mString;
String msg = var1 + ":" + var2 + var3;
return msg;
}
}
需要处理的情况
this调用的区分
通过字节码工具处理了所有的字段访问、方法调用,好像已经处理完了所有case,甚至都可以准备上线验证了,这时忽然发现这样一个方法
public class HotfixDemo {
private static int sNumberA = 1;
protected int mNumberB = 2;
public String mString = "3";
public void copy(HotfixDemo demo) {
mNumberB = demo.mNumberB;
mString = demo.mString;
}
}
为了正确调用到被修复类的成员,所有在补丁类中,以this为操作数的指令我们都需要进行处理,将这些指令的操作数替换为被修复类的_host。而上述这个例子,用最开始的方法有一个严重的问题——Javassist的ExprEditor无法区分调用对象,我们不知道mNumberB的调用者是当前的实例,还是传入的demo参数。该怎么办呢?或许有个看起来很蠢的办法,可以在调用前加入这样的逻辑:
f.replace("if ($0 == this) {" +
"\$_ = ReflectHelper.getFieldValue(\"${className}\", \"${fieldName}\", _host);" +
"} else {" +
"\$_ = ReflectHelper.getFieldValue(\"${className}\", \"${fieldName}\", $0);" +
"}")
这样的逻辑编译之后会根据方法的操作数不同而生成不同的判断语句,如果是基于当前对象(this)的方法调用,if内的判断将会是this == this,也就是true,否则会为params == this,通常会为false。根据这一点,我们就可以判断是否需要替换操作数为_host了,
// this的调用
if (this == this) {
var1 = ReflectHelper.getFieldValue("com.test.HotfixDemo", "mNumberB", _host);
} else {
var1 = ReflectHelper.getFieldValue("com.test.HotfixDemo", "mNumberB", params /*这个字段是被修复方法的参数*/);
}
// param的调用
if (param == this) {
var1 = ReflectHelper.getFieldValue("com.test.HotfixDemo", "mNumberB", _host);
} else {
var1 = ReflectHelper.getFieldValue("com.test.HotfixDemo", "mNumberB", param /*这个字段是被修复方法的参数*/);
}
虽然经过proguard或者R8的优化处理,常量判断表达式的额外分支会被优化掉,但这样的处理多少有些让人迷惑。有没有更加优雅的方式?当然是有的,不过这里我们就需要一些字节码相关的知识了。
先通过javap指令反编译一下class文件,下面是method()方法的字节指令
Code:
stack=2, locals=4, args_size=2
0: new #5 // class java/lang/StringBuilder
3: dup
4: invokespecial #6 // Method java/lang/StringBuilder."<init>":()V
7: getstatic #7 // Field sNumberA:I
10: invokevirtual #8 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
13: ldc #9 // String :
15: invokevirtual #10 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
18: aload_0
19: getfield #2 // Field mNumberB:I
22: invokevirtual #8 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
25: aload_0
26: getfield #4 // Field mString:Ljava/lang/String;
29: invokevirtual #10 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
32: invokevirtual #11 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
35: astore_1
36: aload_0
37: aload_1
38: invokespecial #12 // Method print:(Ljava/lang/String;)V
41: aload_1
42: areturn
在19行,26行是对成员field的压栈指令,操作数是栈顶的数据,而在他们之前的指令aload_0就是我们需要关心的。这条指令会将局部变量对应slot位置的操作数压栈,我们可以通过class文件中LocalVariableTable的内容来了解详细内容。
LocalVariableTable:
Start Length Slot Name Signature
0 48 0 this Lcom/example/testproject/HotfixDemo;
38 10 1 msg Ljava/lang/String;
0 48 2 __host Lcom/example/testproject/HotfixDemo;
虚拟机栈中会为入参、局部变量进行编号(slot),有名称的局部变量或者参数会被记录在LocalVariableTable中,反编译时如果能找到这个表,对应的参数和局部变量反编译后就会有名称,否则就会是var1, var2…这个表中,以Start为开头的Length范围内,为当前局部变量或者参数在方法内的作用域(不同的作用域中,slot可能会被复用)。需要注意的是,实例方法中,slot0一定代表着当前对象this,在类方法中,代表着第一个参数(无参时代表第一个声明的局部变量)。因此,找到aload_0就等同于找到了this调用,我们将aload_0替换成对_host的调用就可以完美解决上面的问题。这一步需要知道_host这个参数对应的slot,为了简单起见,在做预处理时就可以为_host分配一个新的slot,他的数值就是locals。
super调用
处理完this的调用方式并不意味着万事大吉了,Java是面向对象语言,继承关系是非常重要的一部分,子类会经常需要调用父类的逻辑,但是补丁类是一个和被修复类完全不相同的新类,没有被修复类的继承关系,而且,这个方案下,补丁类也不能够拥有非Object类的继承关系,因为实例在构造时必须调用父类的构造函数,如果父类构造时需要参数,我们就无法直接通过默认构造方法创建补丁类的实例了。
不能直接调用的话,那就间接调用,在Instant Run中使用的就是这种间接调用,通过代码注入的方式,让可以直接调用父类逻辑的被修复类提供一个对外的接口,再将补丁类中所有的super指令替换为对这个接口的访问即可。举个例子:
public class AbstractHotfixDemo {
public boolean intercept() {
return false;
}
}
public class HotfixDemo extends AbstractHotfixDemo {
@Override
public boolean intercept() {
return super.intercept();
}
}
在HotfixDemo#intercept中,调用了父类逻辑AbstractHotfixDemo#intercept,为了让非AbstraceHotfixDemo子类的类通过某种方式直接调用到AbstractHotfixDemo#intercept,我们可以加入一个这样的方法
public static Object invokeSuper(String signature) {
switch (signature) {
case "intercept()Z":
return intercept();
default:
return null;
}
}
我们将所有的super.调用都替换成对_host实例invokeSuper()的调用,这样就可以做到不走子类逻辑而直接调用父类方法。当然,为了覆盖所有可能被修复的方法,invokeSuper()的case中需要列出所有可能被super调用的方法。构造函数中super()和this()的调用也可以这样实现。
新增成员、类
从个人经验来看,大部分的热修复仅仅会修复少量逻辑,这样看起来对新增成员或者类的需求并不是很大,那为什么会考虑新增呢?主要还是因为匿名内部类的存在。使用一个匿名内部类时,编译器实际会去创建一个外部类并为其生成一个名称——一般为外部类类名$数字的形式。因此实际上,如果新使用一个匿名内部类,就相当于新增了一个以外部类类名$数字为名称的外部类。基于这点,让我们来看一下该方案如何做到支持新增成员或类的情况。
- 新增一个类。新增类的引用会直接体现在补丁的改动当中,我们需要做的只是让他们都通过同一个ClassLoader加载即可,无需对方法的指令进行转译
- 新增一个方法。同理,新增方法的直接引用也会体现在补丁的改动当中,但是要为新增的方法放在补丁类中,并为其中的指令做转译
- 新增一个field。新增field和新增方法是完全不同的情况了。按照当前的实现,补丁类的实例是static的,如果新增一个static field,在补丁类实例中可以保存状态,但是member field就需要额外的处理了
能够看到,我们能够处理的新增,仅仅是直接调用的情况。如果是反射调用,或是新增一个被修复类中没有重载过的重载方法,由于很难在补丁内处理他们的调用入口,所以诸如这样的情况Instant Run是无法支持的,这也是该方案的限制之一。
进阶彩蛋
如果不使用Javassist中提供的replace api,通过直接操作字节码用复杂一些的方式来实现指令访问的替换,能够对JVM的指令有更深的了解,这里提供一下实现的思路。
首先,Java字节码指令中,访问字段的指令有
getfieldputfiledgetstaticputstatic
访问方法的指令稍多,有如下这些:
invokevirtual调用虚方法(public、protected)invokespecial调用私有、<init>、super方法invokeinterface调用接口方法invokestatic调用静态方法invokedynamic调用lambda表达式和默认方法
穷举了这些指令之后,我们就可以通过转译这些指令来完成整个方法的转译了。转译字段访问比较简单,而方法调用涉及到参数比较复杂,因此主要分析一下方法的调用,这里以比较常见的invokespecial指令为例演示一下转译流程。我们先反编译一个简单的方法,再反编译查看他的字节码,看一下指令是如何使用操作数的
public int demoMethod() {
return add(1, 2);
}
public int demoMethod();
Code:
0: aload_0
1: iconst_1
2: iconst_2
3: invokespecial #2 // Method add:(II)I
6: ireturn
编号0,将this压栈,编号1,2,分别将常量1和2压栈,编号3,就是我们需要处理的invokespecial指令,它的操作数是#2,这是一个对class文件常量池中某一项的引用,通过这个引用,虚拟机知道这条invokespecial指令需要调用的方法,其方法签名是add:(II)I,有两个参数,因此虚拟机会弹栈两次,取出数据作为参数,然后再弹栈,取出数据作为函数的调用实例。我们要将直接的方法调用替换反射调用,需要看一下调用的区别
add(1, 2);
int result = (int) ReflectHelper.invokeMethod("com.test.HotfixDemo", "add", _host, 1, 2);
虽然看上去后者要复杂的多,但是实际上只需要新增类型强转的指令,以及将原本方法调用的参数传递给反射调用。我们可以基于栈的性质和方法调用指令的特点来进行处理,增加locals的值,声明新的局部变量,并将所有方法调用用到的参数都弹栈赋值给局部变量,等到反射调用的时候再倒序传入局部变量,就像下面这样。
public int demoMethod();
Code:
0: aload_0
1: iconst_1
2: iconst_2
3: istore_1
4: istore_2
//省略一段处理其他参数的指令
66: iload_2
67: iload_1
//省略剩下的指令
优化点
处理掉上面提出的问题,我们就可以搭建出一套可用性较高的热修复框架了。但是仅有这些,离线上应用还是有一定的距离的。作为一个拯救线上bug的框架,将对应用各项参数的影响降低到最低,是必须做到的事情,比如下面这些情况:
- 正常来说发布包都需要进行混淆,也就是通过Proguard/R8工具进行处理。工具处理比较重要且需要我们关注的有两部分——obfuscate和optimize(shrink)。obfuscate会将未被keep的所有类、字段、方法、参数的成员变量修改为不易理解的字符。而optimize会优化代码,删除无用的类、字段、方法、参数。热修复的补丁需要根据这部分规则进行一些处理。
- 现有的框架是通过开发人为增加注解的方式进行改动方法的检测的,我们能否可以通过文件比较的方式自动检测改动范围,让补丁制作流程更加简洁。
- 前文提到的新增字段,在处理optimize(shrink)时其实非常有必要,我们很难保证修复补丁不会调用到一个应当被shrink的成员。
- 对构造函数和super方法的调用,会因为大量桥接方法的注入,造成包体积增大,我们可以通过优化将代码内注入的桥接方法省略。
- 补丁中区分方法,使用的是方法签名,方法签名在参数较多时,表示的字符串会很长,字符串也是使用utf-8的格式保存在常量池的,注入的代码方法签名过多也会导致包体积增大。
- 补丁类对被修复字段/方法的访问是通过反射调用的,反射调用比直接调用速度要慢一个数量级左右,如果被修复的方法被频繁调用,不可避免的会影响到程序的运行性能。
上述的内容都是我们这个方案需要考虑,或者可以进一步优化的点,这部分优化内容将会在下一篇文章中分享给大家。