从零开始的Android热修复之旅(四):复杂问题整理

开篇

经过前三篇文章的讨论,相信有读者们已经能够完全掌握类Instant Run原理的热修复方案了,但是在原理和实现之间还是有着众多细节问题需要我们去解决的。本文将会举出一些笔者在从demo到线上应用这一流程当中遇到的问题,并分享一些解决这些问题的经验。其中会涉及到一些关于编译、工具、语言等相关的小知识。相信读者们踩完这些坑之后,会发现自己实现一套热修复框架是一件非常简单的事情。

编译相关

在处理APP编译时注入工具的选择上,考虑到运行速度和代码量,笔者选用了ASM,原本以为在ASM Bytecode Outline插件的协助下,进行字节码逻辑的注入是一件非常简洁的事情,但实际上在开发过程中遇到的小问题(多是Error的形式)远比我们想象的要多。由于ASM没有改动后的校验,不像编译器一般在compile阶段会检查不合法的语义语法,在编写的过程中,绝大多数的问题都是字节码插桩完成,打包成功,运行时报错(Error),下面会列出一些遇到过的字节码注入问题,在通过标准编译器编译代码时这些问题都会被编译器找到并提示,因此在运行时基本不会遇到。

1
java.lang.IncompatibleClassChangeError: Found interface com.xxx.hotfix.runtime.patch.RuntimeDirector, but class was expected (declaration of 'com.xxx.hotfix.runtime.patch.RuntimeDirector' appears in /data/app/com.xxx.hotfix.demo--s-04slO01UaxvrwU6HArg==/base.apk)

这个异常中的RuntimeDirector实际是一个接口,在调用接口内方法的时候没有使用invoke-interface指令而使用了invoke-virtual指令

1
2
3
4
5
6
7
java.lang.IllegalAccessError: Illegal class access: 'com.xxx.hotfix.demo.TestCommonSyntheticPatch' attempting to access 'com.xxx.hotfix.demo.TestCommon' (declaration of 'com.xxx.hotfix.demo.TestCommonSyntheticPatch' appears in /data/user/0/com.xxx.hotfix.demo/cache/xxxFix/patch.zip)
at com.xxx.hotfix.demo.TestCommonSyntheticPatch.invocationDispatch(Unknown Source:0)
at com.xxx.hotfix.demo.TestCommon.privateTestModifiedField(Unknown Source:13)
at com.xxx.hotfix.demo.TestCommon.testField(TestCommon.java:27)
at com.xxx.hotfix.demo.TestCommon.execTestCase(TestCommon.java:80)
at com.xxx.hotfix.demo.MainActivity$onCreate$3.onClick(MainActivity.kt:26)
at android.view.View.performClick(View.java:7252)

这里是一个新生成的类,在访问原有类的时候会抛出了IllegalAccessError,原因是没有通过访问级别的校验,比如一个类访问了另一个类的private方法,这也是compiler在编译时能够检出的问题。

1
java.lang.InstantiationException: java.lang.Class<com.xxx.hyperion.discuss.main.forum.BaseForumAdapter_SyntheticPatch> cannot be instantiated

这个Error是构造试图一个abstract类的对象时抛出的,比较常见,反射newInstance一个抽象类就可以复现了。

1
java.lang.VerifyError: Verifier rejected class com.xxx.hotfix.demo.TestSyntheticPatch: void com.xxx.hotfix.demo.TestSyntheticPatch.onCreate(android.os.Bundle) failed to verify: void com.xxx.hotfix.demo.TestSyntheticPatch.onCreate(android.os.Bundle): [0x67] register v0 has type Reference: java.lang.Object but expected Precise Reference: java.lang.String (declaration of 'com.xxx.hotfix.demo.TestSyntheticPatch' appears in /data/user/0/com.xxx.hotfix.demo/cache/xxxFix/patch.zip)

这个问题乍一看其实没什么头绪的,因为本身是虚拟机加载类时的Error,没有具体问题的栈堆,message的信息也比较少,所以逐个方法逐行指令定位问题,最后发现是一个Integer实例被aload指令放到栈顶,而使用栈顶数据的方法对应的参数签名是I,也就是int类型。到这里可能有的读者会奇怪,为什么这样是不行的呢?编写代码时,Integer类型的值是可以作为参数传递给int类型使用的。我们可以来看一下下面这段代码和其javap后的指令。

1
2
3
4
public void test() {
Integer integer = 0;
this.method(integer);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
public void test();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=2, args_size=1
0: iconst_0
1: invokestatic #1 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
4: astore_1
5: aload_0
6: aload_1
7: invokevirtual #2 // Method java/lang/Integer.intValue:()I
10: invokevirtual #3 // Method method:(I)V
13: return

很明显,指令数量比起代码来说多了不少,显然编译器在编译时做了些什么。看下第1行,调用了Integer.valueOf()方法为局部变量赋值,第7行,在作为参数传递给method方法时,又隐式调用了Integer#intValue()方法,将包装类型转为了原始类型。可见,能够在编写代码时完全不区分原始类型和包装类型的使用,完全是仰仗了编译器为逻辑做的隐式调用。Java代码的隐式调用还有很多,比如字符串拼接时会通过StringBuilder完成、父类只有默认构造函数时子类构造函数会隐式调用super();其他的编译处理还有桥接等,泛型的实现离不开桥接方法的生成。

Object类的FIELD

在这一节的开头,可以先请大家想一想,一个class中是否可以存在两个同名的field?当然,既然这么问出来,答案肯定是可以,虽然在日常的Java coding中,IDE和compiler都会警告我们这样不合法,但是在kotlin中就非常容易出现上述的情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
private static android.app.Application INSTANCE;
descriptor: Landroid/app/Application;
flags: ACC_PRIVATE, ACC_STATIC
RuntimeInvisibleAnnotations:
0: #50()

private static final com.xxx.hyperion.main.home.AppConfigModel appConfigModel;
descriptor: Lcom/xxx/hyperion/main/home/AppConfigModel;
flags: ACC_PRIVATE, ACC_STATIC, ACC_FINAL

public static final com.xxx.hyperion.app.HyperionApplicationHelper INSTANCE;
descriptor: Lcom/xxx/hyperion/app/HyperionApplicationHelper;
flags: ACC_PUBLIC, ACC_STATIC, ACC_FINAL

这是一个object类所声明的成员,可以看到有两个名称为INSTANCE的field,一个是object类原本的单例对象,一个是开发者手动声明的对象;其实这样可以说明,虚拟机区分member是会用到签名的。既然可能会有两个同名field member的存在,在反射获取field时,就需要注意原生反射API提供的getField()方法在这种情景下并不能满足索引的需求了,获取Field实例,不仅要名称,还要它对应的签名。

Javassist检查异常

1
javassist.bytecode.BadBytecode: conflict: *top* and java.lang.Object

Javassist会对某些操作字节码的API做编译检查,上面这个问题在Javassist可能会抛出的异常中也属于是比较难以定位的了,不像语法检查,没有直接的错误提示,只能够逐行代码debug。最后定位发现,该问题出在,插入代码时并没有使用Javassist提供的insert API,而是通过字节码代码的方式为try-catch语句的try语句块中新增了一个局部变量,然后在try-catch外部使用了这个局部变量。很明显,局部变量是有作用域的,在ExceptionTable中会标记try-catch语句包裹的范围,范围内声明的局部变量作用域就在ExceptionTable的startPc和endPc之间,Javassist在insert逻辑时,也同时处理了作用域问题,可见改动字节码时需要注意的东西还是非常多的。最后的改动就是将ExceptionTable中的startPc改动为声明局部变量的下一行,让这个局部变量的作用域不在ExceptionTable的覆盖区间内。
与之相似的,局部变量的作用域的范围也会体现在LocalVariableTable中,并且javassist是会检查这个作用域的,如果在作用域外引用到了这个变量,就会产生问题。

方法体过大

1
Method exceeds compiler instruction limit: 22581 in java.util.Map com.xxx.hotfix.patch.PatchDispatcherImpl.loadPatchDeliverInfo()

又是一个正常开发中很难遇到的问题,因为制作补丁的过程中,有一部分方法的生成是通过字节码工具完成的,在改动范围非常大的时候,对应生产的方法体也就会大很多,比如这个例子中,指令数就超过了20000条,当然解决方式也比较简单,将逻辑拆分到不同的方法当中,再由一个方法统一调用就可以解决了。举例就是,这个方法可能处理了10000个类,我们可以将其拆分成10个方法,每个方法处理1000个类,最后再由原先的方法调用中间生成的10个方法。

R.class的处理

热修复方案上线后的一段时间里,有同学反馈在制作补丁时偶尔会遇到如下所示的问题
常量改变的示例

改动检测工具检测到了一个整型常量的读取变更,根据指令信息我们找到了对应的源码,发现这个整型常量实际上就是对R文件内声明字段的引用,这些字段在构建时生成,并且为常量,class文件编译时编译器会直接将使用处对字段的引用内联,也就是将其替换成对应的字面常量。可实际上代码中对R的引用并没有被改动过,深究发现生成默认的构建方式中,gradle使用aapt2生成R文件时并不能保证每次构建时字段的值完全一致。因此保证R的一致性成为了自动化检测改动工具的重要功能。需要注意的是,由于app模块和library模块对R的处理方式不同,library模块生成的R并非是常量,因此这里只需要处理app工程中的R文件即可。
最开始的设想,通过保存构建时的R.jar文件,额外处理构建补丁的流程,在原工程的compilerJavac的任务前,将生成的R.jar替换成之前构建时保存的文件即可,这样能够保证构建补丁生成class文件时,被内联的值和线上包的值一定保持相同。但在后续的调研中发现,aapt2虽然不支持aapt中的使用public.xml固定资源,但是新增的--emit-ids--stable-ids也可以做到输出并重新复用R文件内容。