无需通过升级APK来实现BUG修复,有人选择插件化来解决,但是对于已经开发好的APP,移植成本非常高,既要学习插件化工具,又要对老代码进行改造。
热修复更加轻量、灵活,直接把补丁上传到云端,下拉补丁后立即生效。热修复主要有两种方案,底层替换和类加载,一般配合使用。
- 底层替换方案:限制颇多,但时效性最好,加载轻快,立即见效。
- 类加载方案:时效性差,需要重新冷启动才能见效,但修复范围广,限制少。
1.底层替换原理
直接替换ART虚拟机中的ArtMethod结构可以达到即时生效。
直接替换的难点在于获取ArtMethod结构的大小,由于ArtMethod可能被厂商修改,不能直接使用AOSP原始的ArtMethod,因此要想办法兼容。
memcpy(oldmeth, newmeth, sizeof(ArtMethod));
通过ART虚拟机源码,发现类的method空间是线性的,一个接一个紧密new出来的排列在数组中的。
android9.0/art/runtime/class_linker.cc:
LengthPrefixedArray* ClassLinker::AllocArtMethodArray(Thread* self,
LinearAlloc* allocator,
size_t length) {
if (length == 0) {
return nullptr;
}
const size_t method_alignment = ArtMethod::Alignment(image_pointer_size_);
const size_t method_size = ArtMethod::Size(image_pointer_size_);
const size_t storage_size =
LengthPrefixedArray::ComputeSize(length, method_size, method_alignment);
void* array_storage = allocator->Alloc(self, storage_size);
auto* ret = new (array_storage) LengthPrefixedArray(length);
CHECK(ret != nullptr);
for (size_t i = 0; i < length; ++i) {
new(reinterpret_cast(&ret->At(i, method_size, method_alignment))) ArtMethod;
}
return ret;
}
根据这个特性可以看出,两个相邻ArtMethod的差值就是ArtMethod的大小,我们可以自己构造一个类来巧妙获取。
public class NativeMethodModel {
public static void f1(){}
public static void f2(){}
}
可以在JNI层获取它们的地址差值:
size_t firMid = (size_t) env->GetStaticMethodID(nativeMethodModelClazz, "f1", "()V");
size_t secMid = (size_t) env->GetStaticMethodID(nativeMethodModelClazz, "f2", "()V");
size_t methSize = secMid - firMid;
这个methSize就可以作为sizeof(ArtMethod)的值了。
memcpy(oldmeth, newmeth, methSize);
访问权限问题:
方法调用时的权限检查
新替换的方法的所属类,和原先方法的所属类,是不同的类,被替换的方法有权限访问这个类的其他private方法吗?
通过oat code观察,调用同一个类的私有方法,没有任何权限检查,可以推测是编译时的优化,确认了两个方法同属一个类,所以机器码不做任何权限检查。同包名下的权限问题
补丁中的类在访问同包名下的类时,会报异常,是由于补丁类是从补丁包的Classloader加载的,与原来的base包不是同一个Classloader。可以使用反射修改ClassLoader规避:
Field classLoaderField = Class.class.getDeclaredField("classLoader");
classLoaderField.setAccessible(true);
classLoaderField.set(newClass, oldClass.getClassLoader());
- 反射调用非静态方法产生的问题
当一个非静态方法被热替换后,在反射调用这个方法时,会抛异常:
Caused: java.lang.IllegalArgumentException:
Excepted receiver of type com.patch.demo.BaseBug
, but got com.patch.demo.BaseBug
com.patch.demo.BaseBug是两个不同的类,前者是被热替换方法所属的类,由于我们替换了ArtMethod的declaring_class_,因此就是新的补丁类。后者是被调用的实例对象所属类,是原有的BaseBug。
静态方法是类级别直接调用的,不需要接收对象实例作为参数。
这种反射调用非静态方法产生的问题可以通过冷启动对付。
新增方法、字段的影响:
除了反射问题,补丁类里面存在方法、字段的新增或者减少,都是不适用的。
方法、字段数量的变化,会导致dex中的方法索引、字段索引发生变化,所以无法正常替换。
2.你所不知的Java
2.1 内部类编译
内部类在编译期会被编译为跟外部类一样的顶级类。
非静态内部类持有外部类的引用,静态内部类不持有外部类的引用。
外部类为了访问内部类私有的域/方法,编译期间会自动为内部类生成access&**相关方法。
如何避免生成access&**方法:
- 一个外部类如果有内部类,把所有method/field的private访问权限改成protected或默认访问权限或public。
- 同时把所有内部类的所有method/field的private访问权限改成protected或默认访问权限或public。
2.2 匿名内部类编译
匿名内部类也属于内部类,满足内部类编译的情况。
匿名内部类的名称格式一般是外部类&numble,编译期根据该匿名内部类在外部类中出现的先后关系,一次累加命名。
如果在已有匿名内部类之前,增加、减少一个匿名内部类,会导致错乱,原有的xxx&1跟修改后的xxx&1根本不是一个类。
新增/减少匿名内部类,对热修复是无解的,编译后的.class无法区分xxx&1/xxx&2类。当然,如果匿名内部类是插入到外部类的末尾,那么是可以的。
2.3 域编译
2.3.1 静态field初始化,静态代码块
不支持
静态field初始化和静态代码块被编译器翻译在
2.3.2 非静态field初始化,非静态代码块
非静态field和非静态代码的编译器翻译在
综上,静态field和静态代码块,只能冷启动生效。非静态field和非静态代码块的变更被翻译到
2.4 final static域编译
final static修饰的基本类型/String常量类型,是没有被翻译到
final static String s1 = new String("heihei");
final static String s1 = "haha";
static int i1 = 1;
final static int i2 = 2;
类加载初始化initClass在执行clinit方法之前,会先执行initSFields(art没有独立成方法),该方法主要就是给static域赋默认值。
010Editor查看dex文件结构,在dex的类定义区,每个类下面有一段encoded_array_item。上例初始值分别为s
1=NULL,s2=“haha”,i1=0,i2=2。
- final static修饰的原始类型和String类型域(非引用类型),并不会被翻译到clinit中,而是在类初始化执行initSFields中初始化赋值。
- final static修饰的引用类型,初始化仍然在clinit中。
一些android性能优化文章,如果field是常量,推荐使用final static作为修饰符。通过源码看这句话不太对,得到优化的仅仅是原始类型和String常量,引用类型实际上不会得到任何优化。
final static int i2 = 2;
const/4 vA, #+B //前一个字节时opcode,后一个字节前4位是寄存器v1,后4位就是立即数的值"0x02"
static int i1 = 1;
sget vAA, field@BBBB //前一个字节是opcode,后一个字节时寄存器v0,后两个字节是i1这个field在dex文件结构中field区的索引值
smali中,final static域直接通过const/4指令,const/4指令执行很简单。
sget指令,首先调用dvmDexGetResolvedField判断是否解析过,没被解析过,就调用dvmResolveStaticField尝试解析域,如果这个静态域所在类没有被解析还会调用dvmResolveClass解析类。拿到sfield静态域,然后调用dvmGetStaticFieldInt(sfield)得到静态域的值。
final static引用类型没有得到优化,因为不管是不是final,最后都是通过sget-object指令去获取该值。
综上:
- 修改final static基本类型或者String常量类型,由于编译期间引用到基本类型的地方被立即数替换,引用String类型的地方被常量池索引id替换,所以热修复下,所有引用该域的地方都会被替换,是可行的。
- 修改final static引用类型域,是不允许的,因为这个field的初始化会被翻译到clinit方法中。
2.5 方法编译
如果使用了混淆,可能导致方法的内联和裁剪,最后也可能导致method的新增/减少。
2.5.1 方法内联
- 方法没有被其它任何地方引用到,毫无疑问,该方法会被内联掉
- 方法足够简单,比如一个方法的实现就只有一行,该方法会被内联掉,那么任何调用该方法的地方都会被该方法的实现替换掉
- 方法只被一个地方引用到,这个地方会被方法的实现替换掉。
比如test()调用print(),print只有一个地方调用因此会被内联。查看mapping.txt文件,没有print方法的映射,说明被内联掉了。
2.5.2 方法裁剪
public class BaseBug {
public static void test(Context context) {
Log.d("BaseBug", "test");
}
}
查看生成的mapping.txt文件:
void test$faab20d() -> a
context参数没被使用,所以参数被裁剪。混淆任务首先生成test$faab20d(),然后再混淆。如果将要patch该test方法,同时恰好用到了context参数,那么test的参数不会被裁剪,对热修复来说就是新增了test(context)方法,只能走冷启动。
将参数引用住,可达到不让编译器优化的目的:
public class BaseBug {
public static void test(Context context) {
if (Boolean.FALSE.booleanValue()) {
context.getApplicationContext();
}
Log.d("BaseBug", "test");
}
}
注意这里不能用基本类型false,必须用包装类Boolean,因为基本类型也可能被优化掉。
2.5.3 热修复处理
混淆配置文件加上-dontoptimize就不会做方法的裁剪和内联。
2.6 switch case语句编译
新旧资源id替换,有时竟然存在switch case语句中的id不会被替换掉的情况。
case项是连续的几个比较相近的值1,3,5,会被翻译为packed-switch指令。中间差值用:pswitch_0补齐,:pswitch_0标签处直接return-void。
case项是不够连续的1,3,10的话,会被翻译成sparse-switch指令。
一个资源id肯定是const final static变量,switch语句被翻译成packed-switch指令,不做处理的话资源id就无法替换。解决方案简单暴力,修改smali反编译流程,碰到packed-switch指令强转为sparse-switch指令,:pswitch_N转化为:sswitch_N指令。然后做资源id的暴力替换,然后再编回smali为dex。
2.7 泛型编译
java泛型基本是完全在编译器中实现的,编译器执行类型检查和类型推断,然后生成普通的非泛型字节码,虚拟机完全无感知泛型的存在。这种实现技术称为擦除(erasure)。
java5之前,用Object来实现类似"泛型"的功能。但是使用Object来实现泛型存在一些问题,编译期通过,运行期可能报错。
public class ObjectFoo {
private Object foo;
public void setFoo(Object foo) {
this.foo = foo;
}
public Object getFoo() {
return foo;
}
}
ObjectFoo foo1 = new ObjectFoo();
foo1.setFoo(new Boolean(true));
Boolean b = (Boolean)foo1.getFoo();//正确
String s = (String)foo1.getFoo();//运行时,类型转换失败ClassCastException异常
java5之后使用擦除方案,在编译时进行类型安全检测。Boolean b = genericFoo.getFoo()这里并不需要做强制类型转换,实际上编译器会在字节码中自动加上强制类型转换。
public class GenericFoo {
private T foo;
public void setFoo(T foo) {
this.foo = foo;
}
public T getFoo() {
return foo;
}
}
GenericFoo genericFoo = new GenericFoo();
genericFoo.setFoo(new Boolean(true));
Boolean b = genericFoo.getFoo();//正确
String s = (String)genericFoo.getFoo();//编译不通过,incovertiable types
反编译字节码:
.method public getFoo()Ljava/lang/object;
.method public setFoo(Ljava/lang/object;)V
class A {
private T t;
public T get() {
return t;
}
public void set(T t) {
this.t = t;
}
}
class B extends A {
private Number n;
@Override //跟父类返回值不一样
public Number get() {
return n;
}
@Override //跟父类参数类型不一样
public void set(Number n){
this.n = n;
}
}
class C extends A {
private Number n;
@Override //跟父类返回值不一样
public Number get() {
return n;
}
//@Override 重载父类get方法,因为参数类型不一样
public void set(Number n){
this.n = n;
}
}
为什么类B的set和get方法可以用@Override而不报错?
基类A由于类型擦除的影响,set(T t)在字节码中实际是set(Object t),那么B的方法set(Number n)参数不一样,理论上应该是重载而不是重写。但是我们本意是进行重写,实现多态,可是类型擦除后,只能变为了重载,这样就有了冲突。
实际上JVM采用了一个特殊方法,就是bridge方法来重写,然后调用实际的B.set(Ljava/lang/Number;)V。
.method public set(Ljava/lang/Number;)V
.method public bridge synthetic set(Ljava/lang/Object;)V
check-cast p1, Ljava/lang/Number;
invoke-virtual (p0, p1), Lcom/test/B;->set(Ljava/lang/Number;)V
return-void
.end method
类B中的字节码同时存在get()Ljava/lang/Number;和get()Ljava/lang/Object;,方法的重载只能以方法参数而无法以返回类型作为重载的区分标准的,但是虚拟机却是允许这样的,因为虚拟机通过参数类型和返回类型共同来确定一个方法,所以编译器为了实现泛型的多态允许做这个看起来"不合法"的事情。
热修复,如果新增了bridge方法,只能走冷启动修复。
2.8 Lambda表达式编译
函数式接口:是一个接口,该接口具有唯一的一个抽象方法。
跟匿名内部类的区别:
- 关键字this:匿名类的this指向匿名类,而lambda的this指向包围lambda表达式的类。
- 编译方式:lambda编译成类的私有方法,使用java7新加的invokedynamic指令来动态绑定方法。匿名内部类被编译成"外部类&numble"的新类。
Sun/Oracle Hotspot VM:自动生成私有静态"lambda$xx$()"方法,这个方法的实现其实就是lambda表达式里的逻辑,invokedynamic执行metafactory运行时生成一个函数式接口具体类,具体类会调用私有静态"lambda$x$()"方法。
android:java8的新特性,需要jack(java android compiler kit)支持,Jack(.java->.jack->.dex),不再有.class文件。编译期也会为外部类合成一个static辅助方法,该方法内部逻辑实现lambda表达式。.dex执行lambda跟普通方法一样,没有运行时类,编译期生成新类。
热修复:
- 新增一个lambda表达式,会导致外部类新增一个辅助方法,无法热修复,只能走冷启动。
- 只修改原有lambda表达式内部逻辑,由于jack是编译期自动生成辅助类(相当于metafactory动态生成的类),该辅助类是非静态的,如果辅助类访问外部的非静态field/method就必须持有外部类的引用,会导致合成val$this变量,新增field无法热修复。
2.9 访问权限检查
- 类加载,当前类和实现接口/父类是非public,同时加载两者的classLoader不一样的话,会校验失败
- 如果补丁类中存在非public类的访问/非public方法/域的调用,那么也会失败。而且在补丁加载阶段检测不出来,运行阶段会直接crash异常退出。
3.冷启动类加载原理
如果仅仅把补丁类打入补丁包,运行时类加载的时候会异常退出:
- 加载一个dex,如果不存在odex文件,那么首先会执行dexopt,会进行verify/optimize操作。执行类的Verify,类被打上CLASS_ISPREVERIFIED标志;执行类的Optimize(优化指令,例如invoke-变成invoke--quick,quick从类的vtable直接取),类被打上CLASS_ISOPTIMIZED标志。
- 加入A类是补丁类,类B引用到补丁类A,由于类B被打上了CLASS_ISPREVERIFIED标志,referre是类B,resClassCheck是补丁类A,它们属于不同dex,所以抛ThrowIllegalAccessError。
- 解决这个问题,一个单独无关帮助类放到一个单独的dex中,原dex中所有类的构造函数都引用这个类,一般实现方法都是侵入dex打包流程,利用.class字节码修改技术,在所有.class文件的构造函数中引用这个帮助类。这样VerifyClass类校验返回false,原dex中所有类都没有CLASS_ISPREVERIFIED标志,因此解决运行时异常。但是对加载效率影响很大,将在InitClass阶段进行Verify和Optimize。
Art下冷启动:
- Dalvik把dex文件解析加载到native内存,如果是压缩文件中有多个dex,那么除了classes.dex之外的其它dex被直接忽略掉。
- Art下默认支持压缩文件中包含多个dex。首先加载primary dex其实就是classes.dex,然后加载其它dex。所以补丁类放到classes.dex即可,后续出现在其它dex中的"补丁类"不会被重复加载。解决方案:只要把补丁dex命名为classes.dex,原APK中的dex依次命名为classes(2,3,4).dex,然后一起打包为一个压缩文件。然后DexFile.loadDex得到DexFile对象,最后把该DexFile对象整个替换旧的dexElements数组就可以了。
在把dex加载native内存之前,如果dex不存在对应的odex,那么dalvik下会执行dexopt,art下会执行dexoat,最后得到的都是一个优化后的odex。虚拟机执行的也是这个odex而不是dex。
如果dex很大将会非常耗时,会阻塞loadDex线程,一般是主线程。解决办法:可以把loadDex当做一个事务来看,如果中途被打断,那么就删除odex文件,重启的时候如果发现存在odex文件,loadDex完之后,反射注入/替换dexElements数组,实现patch。如果不存在odex文件,那么重启另一个子线程loadDex,重启之后再生效。还需要对odex文件进行md5校验,防止被篡改。
4.多态对冷启动类加载的影响
手Q的QFix为了避免补丁类不在同一个dex问题,把补丁A类添加到原来dex的pResClasses数组中,这样就确保执行B类test方法时,dvmDexGetResolvedClass不为null,就不会执行后面类A和类B的dex一致性校验了。
然而,QFix是在dexopt之后进行绕过的,dexopt会改变原先的很多逻辑,许多odex层面的优化会写死字段和方法的访问偏移,这就会导致比较严重的BUG。
当前类和所有继承父类的public/protected/default方法就是virtual方法,private/static不属于。
Vtable:
- 整个复制父类vtable到子类vtable
- 遍历子类的virtual方法集合,如果方法原型一致,说明是重写父类方法,那么相同索引位置处,子类重写方法覆盖掉vtable中父类的方法
- 方法原型不一致,那么把该方法添加到vtable的末尾
public class Demo {
public static void test_addMethod() {
A obj = new A();
obj.a_t2();
}
}
Optimize阶段,优化invoke-virtual为invoke-virtual-quick,这个指令后面跟的立即数就是该方法在类vtable中索引值。
假如修复后的apk新增了a_t1方法,patch前类A的vtable值是vtable[0]=a_t2,patch后类新增了a_t1,那么变为vtable[0]=a_t1、vtable[1]=a_t2。但是obj.a_t2()这行代码在odex中的指令其实是invoke-virtual-quick A.vtable[0],所以patch前调用的a_t2,patch后调用的a_t1,导致错乱。
5.Dalvik下完整DEX方案的新探索
全量合成可以避免多态的影响,微信的Tinker合成方案,是从dex的方法和指令维度进行全量合成,虽然可以很大地节省空间,但由于对dex内容的比较粒度过细,实现较为复杂,性能消耗比较严重。实际上dex占APK比例是比较低的,资源文件才是大头。
dex比较的最佳粒度,应该是类的维度。一般思路是把原来的dex和patch里的dex重新合并,其实可以在原先的dex去掉补丁中也有的class,这样补丁+去除补丁的原基线,就是新APP的所有类。
移除类,只需要移除定义的入口,对于Class的具体内容不进行删除,这样可以最大可能地减少offset的修改。
一个dex里面一共有pHeader->classDefsSize个类定义,从pHeader->classDefsOff偏移处开始,一个接一个地线性排列着。
所以从pHeader->classDefsOff处遍历,删除补丁中的类,再修改pHeader->classDefsSize即可。
在加载补丁后,如果Application类使用其他新dex里的类,由于不在同一个dex里,如果Application被打上pre-verified标志,就会抛异常。解法很简单,直接清除掉pre-verified标志就行了。
类的标志,位于ClassObject的accessFlag成员。
CLASS_ISPREVERIFIED = (1<<16);
//jni层清除
clazzObj->accessFlags &= ~CLASS_ISPREVERIFIED;
Dalvik虚拟机如果发现某个类没有pre-verrified,就会在初始化这个类时做Verify操作,这将扫描这类的所有代码,在扫描过程中对这个类代码里使用到的类都要进行dvmOptResolveClass操作,它会在Resolve的时候对使用到的类进行初始化,而这个逻辑是发生在Application类初始的时候。此时补丁还没进行加载,所以就会提前加载到原始dex中的类。接下来补丁加载完毕,这些已加载的类如果用到了新dex中的类,并且又是pre-verified时就会报错。
- 让Application用到的所有非系统类都和Application位于同一个dex里,这就可以保证pre-verified标志被打上,避免进入dvmOptResolveClass。补丁加载完后,再清楚pre-verified标志。
- 把Application里除了热修复框架代码外,其他代码都剥离开,单独放到一个其他类里面。这样可以保证单独拿出来的类和Application处于同一个dex的几率比较大,想更保险的话,Application可以采用反射方式访问这个单独类,这样就彻底把Application和其它类隔绝开了。
参考
阿里Sophix《深入探索Android热修复技术原理》