本文章主要根据阿里出的《深入探索Android热修复技术原理》后的个人总结
一、为什么直接补丁类直接导入到补丁包中,运行类加载时会产生异常并退出?
首先,因为dex加载到本地内存时,如果不存在odex文件,那么首先会执行dexopt,其中
if(doVerify){
if(dvmVerifyClass(class)){
((DexClassDef*)pClassDef)->accessFlags |= CLASS_ISPREVERIFIED;
verified = true;
}
}
dvmVerifyClass方法如果APK只存在一个dex文件,直接返回true,这时类会被打上CLASS_ISPREVERIFIED标记。
在运行时,由于补丁类跟原类不在同一个dex文件中,此时原类已经被打上了ISPREVERIFIED标记,由于此时在不同的dex文件了,就会直接抛出dvmThrowIllegalAccessError异常。
解决方案:
创建一个新的无关的帮助类放到一个单独的dex文件中,原dex中所有的构造函数都引用这个类,一般方案是侵入dex打包流程,利用.class字节码修改技术,在所有.class文件的构造函数中引用这个类,插桩由此而来。这样做在dexopt时,类将不会被加上CLASS_ISPREVERIFIED标志。因此运行时就不会有这个异常了。
但是如此的话,本来只需要DEX第一次加载到内存时校验一次即可,但是因为未加CLASS_ISPREVERIFIEDQ标记,那么类的校验和优化都将在类的初始化阶段进行。
虽然每次加载加一个类时间的是很快的,但是每次APP启动的时候会加载很多类,就会造成短暂白屏,这对用户来说无法容忍。
Dalvik虚拟机加载一个类,有三部ResolveClass,LinkClass,InitClass。
Link:父类或实现接口权限控制检查主要发生在Link阶段。
Init:这个方法主要完成父类的初始化、当前类的初始化、静态变量的初始化赋值等操作。
QFix方案:
手Q热修复方案巧妙的躲过了插桩方案,躲过原理如下:
首先类加载时由于调用类已经打上了CLASS_ISPREVERIFIED标记,所以会被检查,检查时又由于不在同一个DEX文件而直接报错,所以这时如果能让两个类的pDvmDex相同,那么就可以绕过这个判断进行demDexSetResolvedClass()将类放入到虚拟机。
如果将补丁类放到原有的dex的pRexClasses数组中?
1、具体实现,首先通过补丁工具反编译dex为smali文件拿到对应文件:
preResolveClz:需要打包的补丁类(类A)的描述符,非必需,为了调试方便加上的这个参数。
refererClz:需要打包的补丁类(类A)所在的dex文件的任何一个类描述符。只要是与该补丁类在同一个DEX文件中即可,所以拿了DEX文件中的第一个类。
classIdx:需要打包的类A在原有dex文件中的类索引ID,如2425
2、通过dlopen拿到libdvm.so库的句柄,通过dlsym拿到so库的dvmResolveClass/dvmFindLoadedClass函数指针。
3、预加载引用类:android/support/annotation/AnimRes,这个AnimRes就是之前说的refererClz即当前dex文件的第一个类,这样dvmFindLoadedClass(“android/support/annotation/AnimRes”)返回值才不为null,然后dvmFindLoadedClass执行结果ClassObject作为第一个参数执行dvmResolveClass(AnimRes,2425,true)即可。
第三个参数fromUnverifiedConstance,如果是true直接绕过检查,直接将类加载到虚拟机中。
4、真正载入类时,由于之前已经把类加载到了虚拟机中,所以下一次在虚拟机中获取resClass时就会直接返回class对象而不再进行判断。
5、JNI实现方案就是获取到原调用类的对象,然后原类的id,直接调用dvmResolveClass就可以了。
*其中AnimRes,2425都是原DEX中的。书本第83页有源代码
这样其实就绕过了加载时的dex判断,但是这个有个天然的缺陷就是,由于是在dexopt后绕过的,dexopt会改变原有的很多逻辑,许多odex层面的优化会固定字段和方法的访问偏移,这就会导致比较严重的BUG。
以上两个方法都是为了绕过是否在同一个Dex的判断,而这个进行校验的判断条件就是:
if(!fromUnverifiedConstance && IS_CLASS_FLAG_SET(referrer,CLASS_ISPREVERIFIED))
插装法是为了给第二个条件变为false,手Q是给第一个条件变为false。
但是手Q仅仅是把第三个参数变成true然后改变第一个条件变成false的话,还是没有把类加载到虚拟机中,下一次也就是真正加载类的时候还是获取不到类,导致还是要失败,所以还需要所需要dex指针及类对应dex中的id,进行加载。
建议看一下书的第83页。
Dalvik虚拟机加载dex文件,只会加载classes.dex文件,之外的dex文件会被直接忽略。所以补丁类需要跟现有dex合并为一个classes.dex文件。
Art虚拟机则会先加载classes.dex文件,然后依次加载其他的dex文件,并且后续出现在其他dex文件中的“补丁类”将不再会被加载。所以Art下的方案:把最新的补丁类dex命名为classes.dex,原dex依次命名为classes(2、3、4…).dex就可以了,然后一起打包为一个压缩文件。再通过DexFile.loadDex得到DexFile对象,最后用该DexFile对象整体替换旧的dexElements数组就可以了。
DexFile.loadDex尝试把一个DEX文件加载到内存中,在加载到native内存之前,如果dex不存在对应的odex,那么Dalvik虚拟机会进行dexopt,Art虚拟机会进行dexoat,最后得到的都是一个优化后的odex。实际上最后虚拟机执行的是这个odex而不是dex。
因为我们执行的是odex文件,需要编译并加载dex到内存,对于dalvik虚拟机来说,因为最后加载的是一个合成完的dex文件,所以影响不大,因为总是要加载的。但是对于Art虚拟机来说,问题就很大,因为新的loadDex是补丁dex和Apk中原dex合并成一个完整补丁压缩包,所以dexoat会非常耗时。所以如果优化后的odex文件没生成或者不完整,那么loadDex便不能在应用启动的时候进行,因为会阻塞loadDex线程,一般是主线程。为了解决这个加载慢的问题,Sophix提供的方案是:把loadDex当做一个事务,如果中途被打断,那么就删除odex文件,重启的时候如果发现存在odex文件,loadDex完之后,反射注入/替换dexElements数组,实现打包。如果不存在odex文件,那么重启另一个子线程loadDex,重启后生效。
-
在Dalvik下用Sophix自行研发的全量dex方案。
-
在Art下本质上虚拟机已经支持多dex的加载,所以做的仅仅是把补丁dex作为主dex(classes.dex)加载而已
QFix 多态问题
(避免插桩的方案,将新类提前加载到旧类pRexClasses中,并且用旧类的相关信息绕过检查)
A b = new B();执行会尝试加载类B,方法调用链dvmResolveClass -> dvmLinkClass -> createVtable,此时为类B创建一个Vtable,其实在虚拟机中加载每个类都会为这个类生成一个Vtable表,vtable表就是当前类的所有virtual方法的一个数组,当前类和所有继承父类的public/protect/default方法就是virtual方法,因为public/protect/default修饰的方法都是可以被继承的。private/static方法不在这个范畴,因为不能被继承。
子类vtable的大小等于子类vtable方法数+父类vtable的大小。
createVtable主要进行了一下操作:
1、判断是否有父类,如果有则将父类的vtable给子类的vtable
2、遍历子类方法,判断是否方法名和参数都一致,如果一致证明是子类已经重写父类方法,那么替换vtable对应索引,子类方法覆盖掉vtable中父类的方法
3、如果方法原型不一致则直接将方法插入到vtable的末尾
4、如果不存在父类,则直接将在子类方法到vtable
但是对于静态方法或者字段来说,b.name输出的是A方法也就是父类方法的name字段的内容,这就是因为 field/static是从当前引用类型而不是实际类型中去查找,如果找不到,再去父类中递归查找。
问题出现原因:
在optimize(dvmopt)阶段,会优化方法执行,会将virtual方法优化为OP_INVOKE_VIRTUAL_QUICK指令去执行而不是INVOKE_VIRTUAL,这个指令后面的立即数就是方法对应的索引。invoke-virtual-quick比invoke-virtual效率更高,直接从实际类型的vtable中获取方法索引,而省略了dvmResolveMethod从变量的引用类型获取该方法在vtable索引ID的步骤,所以效率更高。
然而由于绕过了检查,在打包前类A的vtable值是vtable[0]=a_t2,而由于新增了a_ti方法,类A中vtable的值变成vtable[0]=a_t1,但是obj.a_t2()这行代码在odex中的指令其实是invoke-virtual-quick A.vtable[0],所以打包前用的是a_t2打包后用的是a_t1,这就导致了方法错乱。这都是多个dex的情况。
这样就只能寄希望于在Dalvik虚拟机上的mergeDex方案,但是这个方案需要再dalvik虚拟机上进行,如果65535方法限制时,会造成内存爆炸,很可能导致更新失败。
总结:Dalvik:
QQ空间和手Q的热修复方案:
-
QQ空间的处理方式是在每个类中插入一个来自其他Dex的hack.class,由此让所有类都不满足在同一个dex中的条件从而无法pre-verified,因此不会打上IS_PREVERIFIED标签(缺点效率太低,每次都会去加载类,并且侵入打包流程,APP打开时可能遇到白屏)
-
Tinker的方式是合成全量的dex文件,这样所有类都在全量dex中解决,从而消除类重复而带来的冲突。(粒度太细,指令合成,性价比比较低)
-
QFix的方式是获取虚拟机底层函数,提前解析所有补丁类。以此绕过pre-verify检查。(需要获取底层虚拟机的函数也就是提前加载class到原pClassDex栈中的函数,不够稳定可靠。并且与QQ空间方案一样无法增加public函数)
Sophix的方案
目的在解析这个dex的时候找不到这个类的定义。因此,只需要移除定义的入口,对于类的具体内容不进行删除,这样可以最大限度的减少offset的修改。
verifyAndOptimizeClasses中通过dexGetClassDef获取classDef(类的定义),
dexGetClassDef则是通过pDexFile->pClassDex[idx]获取的, 其中pClassDex则是由pHeader->classDefsOff偏移处开始的,一个dex里面一共有pHeader->classesDefsSiz个类定义。因此只需要把header的从classDefsOff偏移开始的classesDefsSiz个classDef,删除其中想要替换的类名就可以了。
然后修改pHeader->classesDefsSiz为修改后的数量即可。
到此为止我的理解,最终该方案虽然是合成了dex文件,但是做法只是删除了header文件中的classDefs的类的引用,这样让机器自动去查找class2 class3 等dex,自动查找功能则是由Android原生的multi-dex的实现来实现的(multi-dex是把一个APK里面用到的所有类分到classes.dex classes2.dex classes3.dex等之中,而每个dex都只包含了部分的类定义,但单个dex也是可以加载的,因为只要把所有dex都加载进去,本dex不存在的类就可以在运行期间在其他的dex中找到)
对于Application的处理
问题,由于Application是整个APP的入口,因此在进入到替换的完成dex之前就会先通过Application代码呢,因为application只能在原dex文件中,因此在加载当前dex中的application时,未加载新的dex文件,此时application类会被打上IS_PREVERIFIED标志,当真正运行时,由于新dex中的class替换旧dex,导致不在同一个dex文件中,检验失败,抛出异常。
解决方式:
sophix:直接将Application的pre-verify标志删除掉。但最后发现这样做存在不可避免的问题,因此改成了如下所说的流程,类似于Amigo的流程,只是不通过gradle插件而是让开发者自行替换。
tinker:让用户使用TinkerApplication注册到manifest中,真正的application则是当做参数传递给TinkerApplication,这样做是将用户的代码和真正的application分离开,这样入口application事先加载并不会影响真正的application,真正的application会被TinkerApplication根据生命周期通过反射调用。真正的Application会跟其他类相同的方式加载。唯一缺点就是需要对原有Application进行一定的修改。
Amigo(美团修复方案):跟Tinker的方案类似,利用自定的gradle插件将APP的Application替换成Amigo自己的另一个Application,然后等该修复的都修复完之后,再将原Application加载回来,开发者无感知。
Sophix将Application的preVerified删除掉,类加载的时候就会在执行ResolveClass,之前说过ResolveClass会对当前类doVerify操作,会对每一个指令进行校验,加载没有加载过的类也就会调用,也就是Resolve各个使用到的类,又由于Application是入口,在Application加载前,补丁dex文件中的类都未被加载,此时Resolve的各个类都是原Dex中的。接下在当补丁加载完成后,这些已经加载的类用到新dex的类时,由于也被打了pre-verified标签所以判断不在同一个dex中就会报错。
解决该问题有两个方案:
1、让Application用到的所有非系统类都和Application位于同一个dex里,这就可以保证pre-verified标志被打上,避免进入dvmOptResolveClass,而在加载完成后,再清除pre-verified标志,是的接下来使用其他类也不会报错。
2、把Application里面除了热修复框架代码以外的其他代码都剥离开,单独提出放在一个其他类里面,这样是的Application不会直接用到过多的非系统类,这样,保证每个单独拿出来的类都和Application处于同一个dex的概率还是比较大的,如果想要更保险,Application可以采用反射的方式访问这个单独的类,这样就彻底把Application和其他的类隔绝开了。
第一种方法比较简单,因为Android官方multi-dex机制会自动将Application用到的类都打包到主dex中,所以只要把热修复初始化放在attachBaseContext前面,一般都没问题。第二种方法稍加繁琐,实在代码架构层面进行重新设计,不过可以一劳永逸的解决问题。
App的真是启动顺序:
Application.attachBaseContext -> ContentProvider.onCreate -> Application.onCreate -> Activity.onCreate
因此为了保证热更新程序运行之前尽量少的有类加载那么就需要放在App.attachBaseContext中,但是attachBaseContext可能还没有权限认证,因此有可能网络不可用,所以不能在这里进行热更新下载等。
但是即使全部初始化都正确,仍然会有问题,在最终执行的时候,大多数时候都是以oat机器码的方式执行的,这里当引用localStorageUtil的init方法时,如果是根据method id直接从dex相关数据中获取ArtMethod结构然后执行,是没问题的。
但是如果oat文件做过比较大的优化(见多态的影响),变为直接从localStorageUtil对象的所属类LocalStorageUtil里面去查找init方法,这个时候可能就有问题。这时LocalStorageUtil的类的方法结构发生变化即方法索引变化了,添加或减少了方法并对该方法有了影响,就会造成问题。
正是由于这种限制,Sophix后面引用了一种新的更稳健的初始化方式,保证初始化在单独的入口类中进行,后面再用反射的方式替换为原有的Application,采用新的初始化方式。即:SophixApplication替代入口,真正实现逻辑通过反射调用,这样加载时不会加载其他非系统类。
最后其实跟Amigo的做法相同,只是把入口类暴露给了开发者,对于开发者更透明。
总结:
Dalvik:合成一个dex,做法是将原有的dex头文件head文件中注册的需要更新的类给剔除掉,只是剔除了注册信息,让dex查找的时候找不到,与multi-dex方式类似,方法会自动去其他的dex文件中找,以实现热更新替换方案。这可以避免两个dex合成一个dex时,方法数超过65535时内存爆炸的情况,也可以正常让dex最大可能的使用dvmopt优化,并且不受多态的影响(见书99页,多态对QFix的影响)
见书本105页。
art虚拟机:直接将新的dex文件替换名字为classes.dex,原dex文件依次命名为classes2 3 4.dex,由于Art虚拟机的dex加载方式,自动实现热更新。
对于Application的实现,最终方案还是替换Application,用代理Application来桥接真正的Application,利用反射调用真正Application的声明周期。