标签:热修复

热修复

【native层方法替换热修复原理】之AndFix原理解析

dexposed是阿里推出的一个AOP框架,github官网地址https://github.com/alibaba/dexposed, 从官网描述来看最初定位是用来解决面向切面编程的一些问题,不过因为具备java方法hook的效果,所以在刚推出时也被开发者当作代码热修复框架。该框架实现原理绝大部分参考了大名鼎鼎的Xposed。不过在我看来DexPosed和Xposed相比是巨大的进步和思维的创新,因为Xposed的使用需要root,因此只具备研究价值而不具备应用价值(黑灰产等不符合价值观的应用不在我们考虑范围之内)。一个需要root才能使用的框架只适合开发者在做一些研究和分析的时候使用,比如做逆向分析研究某个App某个核心函数的关键参数的值,可以把该App放到自己的root手机上,再使用Xposed框架写个hook模块hook这个函数即可。而Dexposed从设计之初就只打算支持hook自己App所在的进程,所以无需root。比如该框架可以被用作热修复用,也可以引入到开发者的App做一些AOP操作。换而言之就是DexPosed不仅具备研究价值还具备应用价值。不过因为Dexposed对art运行时支持较差,所以现在来看的话应用价值也不是很大。但是我们可以学些下其原理实现部分,关于Dexposed的实现原理网上有很多文章进行讲解,因此不做过多赘述。而且Dexposed实现原理部分大部分参考自Xposed,所以你也可以直接去看Xposed的实现原理,这个原理的理论依据我在前面实际上已经提到过,就是native方法注册和执行流程的实现原理。不清楚的可以先看下:【native层方法替换热修复原理】之native方法在dalvik虚拟机中的执行流程 也就是Dexposed参考借鉴了Xposed,而Xposed是参考借鉴了dalvik虚拟机JNI模块的实现原理。 AndFix是阿里推出的一个安卓热修复框架,github官网地址:https://github.com/alibaba/AndFix。从官网描述来看其定位就是用来进行热修复的(AndFix is a library that offer hot-fix for Android App.)。而Dexposed官网定位是用来进行AOP操作。AndFix和其他厂商的热修复方案相比其最大的特点是即时生效,无需重启。原因是AndFix做的是方法替换,处理粒度是方法,而其他厂商本质上做的是类替换,处理粒度是类。不管时JVM虚拟机还是安卓自有的虚拟机,一个类被加载到虚拟机中之后,再次使用时是不会重新加载的,所以如果不重启原先的bug类还在虚拟机中,就不会去加载同名的修复bug后的类。而方法替换是在原来类的基础上进行的,而且方法每次调用都会执行,所以当替换后再调用就会使用替换后的方法。 AndFix的实现原理我就不做过多介绍,在阿里官方出品的《深入探索Android热修复技术原理》这本书中已经做了较为详细的解释,我这里先大概提一下: 使用DexFile.loadDex方法加载补丁dex,获取到补丁Dex对应的DexFile对象dexFile 使用dexFile.loadClass加载类的时候,传入自定义的ClassLoader,在该ClassLoader中使用dexFile.loadClass来加载类。 在加载类的过程中会通过反射判断该类所有声明的方法是否包含需要热修复的注解,如果包含该注解,说明该方法是需要被热修复方法 对于需要热修复的方法传入获取到原bug方法src和修复bug后的方法dest对应的java层反射获取的Method对象给native层方法替换函数,完成这2个方法在native层对应的Mehotd/ArtMethod对象的替换 具体的替换逻辑就是把这2个java层方法对应在虚拟机运行时的Method/ArtMethod数据结构中的每一个元素的值进行替换。那么这里有2个问题值得思考: 为什么替换这2个数据结构的内容就能达到热修复效果呢? 通过java层反射得到的Method对象如何能够找到其对应的虚拟机运行时的Method/ArtMethod数据结构? 第一个问题,我们在分析【native层方法替换热修复原理】之native方法在dalvik虚拟机中的执行流程时其实已经讲过。这里还是简单提下,以dalvik虚拟机为例: 当虚拟机完成类加载工作之后,解释器会对已经装载的代码进行检查,验证符合虚拟机规范格式之后,最终调用dvmInterpret函数初始化解释器斌开始解释执行字节码,dvmInterpret函数是解释器的入口函数。该函数定义如下: [crayon-5f03adfc970ab676669571/] 该函数的第二个参数即是Mehtod类型的指针,所以Method对象是dalvik虚拟机解释器的重要输入。对方法的执行本质上就是对Method数据结构的操作,Art下类似本质上是对ArtMethod进行操作。 所以只要我们替换了这2个数据结构在运行时的内容,那么执行的逻辑就是替换后的内容(更详细原理介绍请参看【native层方法替换热修复原理】之native方法在dalvik虚拟机中的执行流程)。 对于第二个问题如何通过反射时得到的Method对象获取到运行时对于的Method和ArtMethod对象呢?我们先看下AndFix是如何做的,以dalvik为例,如下是方法替换的代码: [crayon-5f03adfc970b8869463000/] 代码中我注释的那一行就是根据java层反射获取的Method对象src获取native层Method指针meth的逻辑。因此我们看下FromReflectedMethod函数的定义,该函数位于安卓源码的dalvik\vm\oo\Jni.cpp中。 [crayon-5f03adfc970bc540378776/] 从该函数的注释上可以看到其作用是根据一个反射的方法或构造方法得到其对应的methodID,所以很明显这本质上就是一个JNI调用。另外从返回值来看应该是jmethdoID才对,但是我们希望得到的是Method数据结构,所以jmethodID这个数据结构的第一个成员一定是一个Method对象指针。另外我们看下dvmGetMethodFromReflectObj的实现,该函数位于安卓源码的\dalvik\vm\Reflect.cpp中。 [crayon-5f03adfc970c0269860265/] 从注释可以看到,其作用是根据一个反射得到的Method对象来获取native层对应的Method指针,而且可以看到是通过dvmSlotToMethod这个函数来得到的,如果你对Dexposed或Xposed的hook原理比较清楚的话,对这个函数应该比较熟悉,其作用是从一个ClassObject类型的对象中根据slot序号取对应的Method指针。 [crayon-5f03adfc970c4761846961/] 那么这个slot是什么呢?能在java层获取到吗?实际上这个slot就是java层反射Method中的一个成员变量,所以在java层完全能够获取到,比如Xposed就是这么做的,如下是Xposedjava层hook的一段代码: [crayon-5f03adfc970c7138535470/] 到这里我们就把native层方法替换热修复系列的难点给讲解透彻了,现在做个总结: native层方法替换热修复的关键是把java层通过反射获取到的Method对象对应在native层的Method/ArtMethod数据结构的成员的值给替换掉 使用JNI接口FromReflectedMethod即可依据java层反射获取的Method对象来找到其对应的native层的Method/ArtMethod对象。 FromReflectedMethod本质上仍然是使用dvmSlotToMethod来得到native层Mehotd对象指针,所以如果能够在java层就得到该函数对应的slot的话可以直接使用dvmSlotToMethod函数得到其对应的native层Mehotd对象指针,像Xposed那样。  

热修复

【native层方法替换热修复原理】之native方法在dalvik虚拟机中的执行流程

在前面【native层方法替换热修复原理】之虚拟机运行时数据结构ClassObject/Class和Method/ArtMethod 中我们讲到了2个重要的数据结构ClassObject和Mehod,也提到了这2个数据结构中的一些重要成员,这一节就来看看native方法在dalvik虚拟机中是如何执行的。也就是dalvik虚拟机中JNI模块的实现原理。 我们已经知道jni中native方法包括2种情况:一种是静态注册,一种是动态注册。不清楚的同学可以查看专栏:NDK开发从入门到精通。 这2种方式注册的native函数在dalvik虚拟机中的执行流程是不一样,先来看下动态注册方式的native函数的执行流程。 在安卓NDK开发之JNI函数动态注册中我们讲过native方法的动态注册是通过RegisterNatives这个函数来完成的,因此我们以该函数为切入点来分析下native函数的注册和执行流程。该函数位于安卓源码的dalvik/vm/Jni.cpp。先来看下其定义: [crayon-5f03adfc976c1830198454/] 可以看到该函数最重要的逻辑就是调用dvmRegisterJNIMethod函数来完成JNI方法的注册,跟进该函数的实现: [crayon-5f03adfc976c9354241712/] 可以看到该函数处理逻辑于流程是这样的: 根据方法的签名在ClassObject对象所代表的类clazz的direct方法区中查找该方法 如果在direct方法区中没找到,则在virtual方法区中查找该方法,如果2者都没找到抛出NoSuchMethodError异常 如果找到先判断该方法是否是native方法,如果不是打警告日志:不能注册非native方法,然后直接返回false,也就是注册方法失败 如果是native方法,使用JNI桥函数dvmUseJNIBridge将该方法所代表的Method数据结构与传入的fnPtr指针指向的c/c++代码中的本地函数建立映射关系(也就是注册) 所以重点在于dvmUseJNIBridge这个函数是如何做到将java层方法对应的Method数据结构与fnPtr指针指向的c/c++代码中的本地函数建立映射关系的?看下该函数的实现: [crayon-5f03adfc976cf444515547/] 从注释上可以看到该函数的作用就是将method->nativeFunc指向JNI桥函数,将method->insns指向实际的函数(point at the actual function)。而完成这个操作的函数是dvmSetNativeFunc,所以我们重点看下该函数是如何实现的(该函数位于安卓源码的dalvik/vm/oo/Class.cpp): [crayon-5f03adfc976d4780229986/] 从注释上可以看到该函数实际上就是用新的值替换nativeFunc和insns字段(Replace method->nativeFunc and method->insns with new values),从代码实现来看分为2种情况: insns不为空,那么2者都更新 insns为空,仅仅更新 nativeFunc 另外从代码注释上来看insns和nativeFunc的状态转换包括3种情况(There are three basic states): (1) (initial) nativeFunc = dvmResolveNativeMethod, insns = NULL (2) (internal native) nativeFunc = <impl>, insns = NULL (3) (JNI) nativeFunc = JNI call bridge, insns = <impl> 第一种是初始状态,nativeFunc字段指向初始值dvmResolveNativeMethod,insns为空 第二种是内部native方法状态:nativeFunc字段指向某个具体的实现,insns为空 第三种是JNI方法状态(也就是开发者在java代码中声明的native方法):nativeFunc字段指向 JNI call bridge,比如通过前面的分析我们已经知道动态注册的native函数在注册过程中会将该字段指向JNI桥函数(dvmCheckCallJNIMethod或dvmCallJNIMethod),insns指向函数的具体实现,也就是注册时传入的fnPtr指针 不管哪种状态,只要该方法是native方法,其nativeFunc字段一定不为NULL。 而JNI桥函数(dvmCheckCallJNIMethod或dvmCallJNIMethod)的作用就是根据传入的Method结构体中保存的参数以及具体实现代码(insns指向的内容)做平台相关的实现。以dvmCallJNIMethod为例,如下一段代码是dvmCallJNIMethod函数中一段重要的调用流程: [crayon-5f03adfc976db156463752/] 其中的dvmPlatformInvoke就是根据不同平台来实现。 到这里通过动态注册的方式注册的native函数的执行流程就分析完了,先小结下: RegisterNatives注册native函数是通过dvmRegisterJNIMethod完成的,该函数会根据注册时传入的函数签名依次从direct,virtual方法区中查找该方法对应的Method对象,查找到之后判断该方法是否为native方法,如果是则将该Method对象和注册时传入的fnPrt指针指向的c/c++层函数建立映射关系 将java方法对应的Method对象和c/c++层函数建立映射关系的本质就是用新的值替换nativeFunc和insns字段 整个函数调用链是这样的:RegisterNatives->dvmRegisterJNIMethod->dvmUseJNIBridge->dvmSetNativeFunc。这条调用链走完之后就已经将java层声明的native函数和我们在JNI代码中定义的c/c++函数建立起了映射关系,此时Method数据结构中nativeFunc字段指向的值是JNI桥函数(dvmCheckCallJNIMethod或dvmCallJNIMethod),insns指向的值是对应的c/c++中定义的函数的函数地址。 等到执行该native函数的时候,本质上是执行该函数的JNI桥函数,在该桥函数中会根据Method数据结构中保存的参数和insns指向的c/c++函数的地址进行执行。这个过程是通过dvmPlatformInvoke函数完成的,该函数是一个和平台相关的调用(实际上从函数名就可以看出来,所以该函数的具体实现应该使用到了类似汇编代码)。 思考:从上面的流程我们能得到一些什么启示? 从上面的流程我们已经知道了native函数在dalvik中的注册流程,也知道了注册的本质就是修改Method数据结构的nativeFunc字段和insns字段,那么知道这些有什么用呢?再进一步想想我们为何要注册native函数?不就是为了在执行一个java层声明的函数时去执行另外一个(c/c++层的)函数吗?调用一个函数实际上去执行另外一个函数不就是劫持或者hook吗?试想下如果我们能够在运行时随意修改一个Mehod对象对应的这2个字段的内容,那么执行该函数时是不是就会去调用我们指定的函数呢?比如将nativeFunc字段指向一个我们自己定义的函数(当然这个函数定义必须满足以下形式) [crayon-5f03adfc976e1387850026/] ,在这个函数中我们可以实现一些自己的操作,然后再去调用原函数,这样不就达到类似hook的行为了吗?实际上在后面带领大家分析native层方法替换框架,如Xposed和DexPosed等框架时你会发现他们就是这么做的,换而言之他们借鉴或参考了dalvik虚拟机JNI实现原理的这一套逻辑。 前面分析了通过动态注册方式注册的native函数,接下来分析下静态注册方式注册的native函数的执行流程?在分析之前,我们先来思考一个问题?静态注册和动态注册的native函数的区别在哪里?是代码实现不一样吗?很明显实现是一样的,区别仅仅在于函数的命名是不一样的,同时缺少了动态注册的过程。静态注册函数需要符合命名规则。那么命名会影响什么呢?很显然是函数的查找,所以我在分析之前先预测静态注册函数执行流程的区别和动态注册相比在于函数查找逻辑不一样,换而言之就是得到Method数据结构方式不一样。后面大家可以看我预测的对不对。 对于静态注册方式,因为没有动态注册过程,所以我们需要找另外一个切入点分析,这个切入点就是类加载了之后方法是如何加载及调用的? 通过前面的一系列热修复文章的学习,我们已经知道类加载机制的最终目标就是为目标类生成一个ClassObject数据结构,并将其存储在运行时环境中,为解释器的执行提供相应类方法的字节码。同时在dex中类的加载流程和unexpected DEX异常中已经讲过类的加载过程中ClassObject对象的生成是在findClassNoInit方法中。同时也知道该方法生成ClassObject实际上调用的是loadClassFromDex方法(还不清楚的同学可以先了解下专栏中的热修复相关专题),所以我们以该方法为入口进行分析。该方法位于安卓源码的dalvik\vm\oo\Class.cpp中。因为该方法实际上是调用了loadClassFromDex0方法,所以我们直接分析loadClassFromDex0方法,这个方法非常长,做了很多事,因为我们主要是希望弄明白静态注册的native方法的执行流程。所以省略其他和方法加载无关的代码: [crayon-5f03adfc976e8133311368/] 从上面可以看到和动态注册类似也分为direct方法和virtual的加载,不管哪种类型最终都是调用loadMethodFromDex方法,跟进该方法: [crayon-5f03adfc976ee812417956/] 这个方法做的事也比较多,因为我们是分析native方法的静态注册,所以重点关注上述代码我注释的地方,可以看到,如果是native方法,那么会将Method对象的nativeFunc成员的值赋为dvmResolveNativeMethod,这也是为何前面提到动态注册native函数时Method对象的nativeFunc成员的初始值是dvmResolveNativeMethod的原因。 有了前面分析动态注册时的经验,我们就知道直接分析该函数即可(该函数位于安卓源码dalvik\vm\Native.cpp目录下)。 [crayon-5f03adfc976f5117808782/] 从上面的注释可以看到该函数的作用就是解析一个native函数,然后调用它。如果解析成功 method->insns将被替换(if the resolution succeeds, method->insns is replaced)。从执行流程上来看包括以下几个重要步骤: 首先通过dvmLookupInternalNativeMethod函数来查找,对于dalvik虚拟机内部定义的native函数而言,是可以找到的,比如安卓源码dalvik\vm\native路径下定义的各个类中的native方法, 对于开发者在java层定义的方法通过lookupSharedLibMethod函数来查找,找到之后返回指向该函数的指针 接下来和动态注册时调用流程就差不多了,调用dvmUseJNIBridge将该函数代表的Method对象和前面在动态库中查找到的函数的地址建立映射关系,注意映射关系建立完成之后,Method->insns字段的值为从动态库中查找到的native函数的地址func,Method->nativeFunc的值为JNI桥函数。所以(*method->nativeFunc)(args, pResult, method, self);这一句实际调用的是JNI桥函数。 分析完了之后现在回过头来看我前面的预测,是不是很正确?区别仅仅在于方法的查找逻辑不同,在已经知道java层函数对应的Method对象和native层函数的地址之后,后面建立映射关系的逻辑几乎一模一样。而且可以看到这2种方式查找方法时的侧重点是不同的,静态注册时侧重点在于如何找到该方法对应的native方法的地址,而动态注册查找方法时侧重点在于如何通过注册时传入的函数签名查找到该函数对应的Method对象。原因很简单,因为动态注册是在so加载时注册,此时java层方法调用还未执行,仅仅只是建立java层方法和native层方法的映射关系而已,而且会传入该函数在c/c++层对应的指针,所以native层函数地址是已知条件,而java层函数对应的Method对象是未知条件。而静态注册的执行是在运行时从java层跳到native层时,此时已经发生函数调用,Java层函数对应的Method结构是已知条件,而对应的native层函数是未知条件。 从上面分析可以看到整个过程涉及到了ClassObject和Method数据结构的很多成员,这也是为何前面要对这2个数据结构进行分析的原因,同时也可以感受到数据结构在代码编写中的重要性,函数本质上就是对各种数据结构进行操作和更新。同时也可以看到源码中很多函数都使用了委托调用的形式,即将一个功能很大的函数拆分为n个小功能点的短函数。这也是代码规范的一个技巧。 在分析源码时强烈推荐大家用源码阅读工具Source Insight,方便分析流程跳转。 不管是静态注册还是动态注册,其目的都是建立起java层方法和native层c/c++方法的映射关系,本质上都是用新的值替换Method数据结构中的nativeFunc和insns字段的值。…

热修复

【native层方法替换热修复原理】之虚拟机运行时数据结构ClassObject/Class和Method/ArtMethod

通过前面热修复与动态加载专栏热修复系列文章的讲解我们知道了以下2点重要信息 在安卓java层,描述dex文件的数据结构是DexFile这个类,PathClassLoader对dex文件的加载在java代码中最终都是委托DexFile调用loadDex完成的 在native层,在dalvik模式下描述dex文件中一个类的数据结构是ClassObject这个类 那么这里就有了另外一个问题,在dalvik虚拟机运行时环境中类和方法是如何描述的呢?Art模式下又是如何描述的呢? 根据前面dex中类的加载流程和unexpected DEX异常的分析类加载过程我们已经知道在java层类的加载最终是通过DexFile的loadClass函数完成的。因此我们只需要顺着该函数分析其对应的native函数在dalvik模式和art模式下的实现即可。首先回顾下该函数在java层的调用链: [crayon-5f03adfc97bbd113471909/] 可以看到在java层类的调用经过层层中转最终是委托给了DexFile的一个native函数defineClassNative来完成的。所以我们只需要分析defineClassNative这个函数在dalvik模式和art模式下分别是如何实现的即可。首先分析下dalvik模式。 在dex中类的加载流程和unexpected DEX异常中已经提到过在dalvik模式下native层加载一个类最终调用的是findClassNoInit函数,该函数返回的是一个ClassObject指针,所以类加载机制的最终目标就是为目标类生成一个ClassObject数据结构,并将其存储在运行时环境中,为解释器的执行提供相应类方法的字节码。所以在运行时类的描述就是ClassObject这个数据结构。而方法在运行时的描述就是ClassObject中定义的一个成员Method数据结构。 弄清楚这2个数据结构对后面我们学习dalvik虚拟机JNI模块实现原理,hook框架Xposed以及阿里的DexPosed AOP框架以及AndFix热修复框架有非常重要的帮助。 注意:虽然现在是安卓默认使用Art运行时,但是对于开发者而言,开发出的应用程序经过编译和打包之后,仍然是一个包含dex字节码的APK文件。也就是仍然适合dalvik虚拟机的那一套指令,只不过Art运行时在apk安装时会将应用的dex字节码翻译成本地机器码,执行时直接执行本地机器码而不再是解释执行。关于dalvik虚拟机和Art运行时区别建议参看:https://blog.csdn.net/luoshengyang/article/details/18006645。 前面提到过我们只需要分析defineClassNative这个函数在art下是如何实现的即可。该函数在art下的实现函数是DexFile_defineClassNative,该函数位于安卓源码art/runtime/native/dalvik_system_DexFile.cc文件. [crayon-5f03adfc97be3710058889/] 从这段代码我们可以看到art模式下类的加载是通过ClassLinker这个类调用DefineClass来完成的,加载成功之后返回一个mirror::Class的指针,换而言之就是在art模式下描述一个类运行时的数据结构是mirror::Class,也就是这个类的作用相当于dalvik模式下的ClassObject。 所以接下来看下ArtMethod成员的定义(该数据结构定义位于安卓源码的art\runtime\mirror\art_method.h) 到这里基本上就把安卓framework层Dex文件,类文件以及方法的描述彻底弄清楚了,这里做个小结: 在安卓framework的java层,描述dex文件的数据结构是DexFile这个类,PathClassLoader对dex文件的加载在java代码中最终都是委托DexFile调用loadDex完成的。 在framework的native层,描述dex文件对应的数据结构是c++层DexFile类,描述dex文件中一个类的数据结构在dalvik模式下是ClassObject结构体,在art模式下是mirror::Class;描述方法的数据结构在dalvik模式下是Method结构体,Mehotd类是dalvik虚拟机解释执行时的重要输入,在art模式下是ArtMethod Method结构体中包含了一个insns成员,当该方法为非native方法时,该insns成员指向了该方法实际的指令。同时也包含一个nativeFunc成员,当该方法为native方法时,会执行nativeFunc指向的函数的代码。 java层的方法在运行时的表示为Method(dalvik模式下)和ArtMethod(art模式下) 对于热修复而言: java层的Dex加载以及类加载机制衍生出了基于MultiDex改造的热修复理论,代表作为QQ空间热修复,Tinker以及Nuwa。虽然这他们解决类预校验标志的方法不尽相同,但是他们热修复的理论部分原理是一样的。 native层JNI方法和native方法的执行原理衍生出了native层方法替换的热修复方案,代表做包括Xposed hook框架,Dexposed以及AndFix。

Instant Run

从ClassNotFoundException看java的类加载机制和Instant Run类加载部分的设计

我们都知道java中的类加载机制采用的是双亲委派机制(其实更严格的说法应该是未经加载过的类采用双亲委派机制加载,已经加载过的不是)。利用这种特性我们可以做到自定义ClassLoader加载额外路径的jar进而做到代码热更新,考虑如下一种场景:在宿主jar中存在一个类X,在该类中存在一个函数someFun,在另外一个插件jar中包含一个和宿主jar同包名同类名的类X,在该类中也存在和宿主jar中X的相同签名的函数someFun,在宿主jar的main函数中调用了类X的someFun函数,那么如何做到让main函数中调用的函数someFun是来自插件jar中而不是宿主jar中的呢?根据java的双亲委派机制,我们很容易想到可以自定义一个ClassLoader用来加载插件jar,然后将这个自定义ClassLoader设置为当前ClassLoader的父加载器即可,因为按照双亲委派加载,在main函数中使用到类X时会委托当前ClassLoader的父加载器去加载类X,而此时其父加载器为我们自定义的类加载器,所以会优先从插件jar中加载类X,这样调用的方法也即为插件jar中的X类的同名方法。 比如:存在类com.test.caseclass.X,宿主jar和插件jar的这个类X中都包含一个print函数,宿主jar和插件jar中的print函数的实现不一样,为了简单起见,这里只是简单的打印一句输出作为不同的实现,宿主jar中的X类代码如下: [crayon-5f03adfc98079708480026/] 插件jar的X类代码如下: [crayon-5f03adfc98080589849174/] 在宿主的main方法中存在一处调用类X的print函数的代码, [crayon-5f03adfc98082394888075/] 那么很显然此时调用的是宿主jar中的类X的print函数,因此输出为: [crayon-5f03adfc98085588689372/] 那么我们如果想要让X.print调用的是插件jar中的print函数应该怎么做呢?我们只需要自定义一个ClassLoader,然后将其加载路径设置为插件jar,然后将该自定义ClassLoader设置为当前系统默认应用类AppClassLoader的父加载器即可(在java中用户的类的默认加载器是AppClassloader)。整个类加载器的委托加载关系转换如下图所示: 此时代码如下: [crayon-5f03adfc98088309055672/] 运行程序,输出如下: 可以看到确实是加载了插件jar中的类X而不是宿主jar中的类X,这个很好理解,因为双亲委派机制,查找类X时会优先从myClassloader中查找,因此查找到的是插件jar的类X,所以输出是插件jar中的X.print()。接着考虑复杂一点的情况,如果在类X中引用了类Y,而这个类Y只在宿主jar中存在,在插件jar中不存在,那么程序的输出会时什么呢?比如此时类X的print函数代码是这样的: [crayon-5f03adfc9808d501055513/] 其中的Y.print仅仅只是打印一句:System.out.print(“this msg is from Y class”);注意此时我们的插件jar中不存在类Y,类似代码热修复场景仅仅类X的print函数代码出现bug,我们打算修复类X的print函数,类X引用到的类Y还是使用原本在宿主jar中存在的类Y。那么此时程序的输出是什么呢?请大家先思考几秒钟再往下看。 正确答案是运行时抛出ClassNotFoundException。如下图所示: 纳尼?为何会是类找不到异常呢?类Y不是在宿主jar中吗?不是父加载器(myClassLoader)找不到应该到子加载器即此时的系统类加载器(AppClassLoader)中查找吗?那应该能够找到类Y啊。为何会是ClassNotFoundException呢? 这就需要对java中的类加载机制和双亲委派模型细节有较为深入的了解了,因为java中的类加载器从加载过程来看实际上都可以概括为2类加载器,一类是初始加载器(initiating loader),一类是定义加载器(defining loader),前者是调用loadClass方法的加载器,后者是调用defineClass方法的加载器,而双亲委派意味着真正完成类的加载工作的类加载器和启动这个加载过程的类加载器很大的可能不是同一个加载器,在 Java 虚拟机判断两个类是否相同的时候,使用的是类的定义加载器。也就是说,哪个类加载器启动类的加载过程并不重要,重要的是最终定义这个类的加载器。而双亲委派模型中的父加载器找不到该类会到子加载器中查找是针对loadClass而言的,也即是初始化加载器而言的,而上述示例中在Main.class中引用了类X,因此java虚拟机会去加载类X的字节码,此时java默认的加载器AppClassLoader就是类X的初始类加载器(调用loadClass),但是因为双亲委派机制实际加载类X的加载器是我们自定义的myClassLoader,因为myClassLoader是AppClassLoader的父加载器,同时myClassLoader的加载路径插件plug.jar中能够找到类X的字节码内容。此时在加载类X的字节码的时候发现类X引用了类Y,所以会调用loadClass去加载类Y,所以此时类Y的初始化加载器是myClassLoader,所以按照双亲委派模型,会委托myClassLoader的父加载器ExtClassLoader去加载类Y,很显然父加载器找不到,然后自己尝试加载,但是在myClassLoader的加载路径插件plug.jar中找不到类Y,所以抛出异常,注意事实上此时是不会到myClassLoader的子加载器AppClassLoader中查找类Y的,即使在其子加载器AppClassLoader加载路径中能够找到该类的字节码。因为类Y的初始化加载器是myClassLoader。 两种类加载器的关联之处在于:一个类的定义加载器是它引用的其它类的初始加载器,如类 com.example.Outer引用了类 com.example.Inner,则由类 com.example.Outer的定义加载器负责启动类 com.example.Inner的加载过程。 方法 loadClass()抛出的是 java.lang.ClassNotFoundException异常;方法 defineClass()抛出的是 java.lang.NoClassDefFoundError异常。 关于java中ClassLoader的更多详细权威的讲解请参看:深入探讨 Java 类加载器   相信大家知道了抛出异常的原因之后很容易想出一个非常简单的解决办法,即在插件jar中也保存一份类Y的代码,这样myClassLoader查找类Y时就能够找到该类,作为一个简单的demo这样做是可以的,但是如果是在实际使用中,当我们想动态替换一个类的功能的时候,如果这个类引用的类比较多,其引用的类也存在引用其他类的情况,因此我们需要将所有使用到的类都放到插件jar中,这种方式本质上相当于重新打包发布了一个宿主jar,只不过这个宿主jar中的类X修改了一部分代码,很显然这样做和我们的初衷动态的修复某一个类是相违背的,我们只希望去动态的修复那个出现bug的类,而其他的类都不用处理。如果大家对主流的热修复比较了解的话,大家可能就清楚这种在插件jar中保留宿主jar中的类的方案类似热修复中的全量替换,即我全部都不使用原来的宿主jar中的类,重新发布一个jar包作为插件jar去动态加载。那能否不把插件类X引用到的类都放到插件jar中同时不抛出异常呢?即插件jar中只保留要修复的类去动态加载,其引用的类仍然使用原来的宿主jar中的。答案是肯定的,谷歌的Instant Run中类加载部分的设计可以用来解决这类问题。 网上关于Instant Run的类加载部分的讲解都是简单的一句双亲委派机制带过,但是事实上,双亲委派机制不是Instant Run类加载部分设计的精妙之处,简单的双亲委派和前面提到的那个例子一样会抛出ClassNotFoundException,事实上Instant Run关于类加载部分设计的精妙之处在于双亲委派和代理加载构成闭环,如下图所示是谷歌Instant Run的设计图: (注:图片来源于网络) 即将安卓中的默认加载器PathClassLoader(类似java中的AppClassLoader)的父加载器设置为自定义的IncrementalClassLoader,但是IncrementalClassLoader只是个空壳,其对类的加载实际上是代理给了其内部定义的一个DelegateClassLoader去加载,同时将PathClassLoader也设置为DelegateClassLoader的父加载器,也就是说系统默认的加载器PathClassLoader的父加载器是自定义IncrementalClassLoader,其内部的DelegateClassLoader的父加载器是PathClassLoader,这样构成了一个闭环,用前面的示例来举例的话,当PathClassLoader加载类X的时候会委托IncrementalClassLoader加载,而IncrementalClassLoader会把该加载代理给DelegateClassLoader,这样DelegateClassLoader加载到类X,同时加载类X的时候发现类X引用了类Y,所以此时类Y的初始化加载器为DelegateClassLoader,根据前面的分析DelegateClassLoader是无法加载类Y的,但此时因为设置了PathClassLoader为DelegateClassLoader的父加载器,因此会委托PathClassLoader加载,而类Y在PathClassLoader的加载路径中,因此能够成功加载到类Y,从而解决前面示例中的问题。 关于谷歌Instant Run类加载部分的代码因为不是本节重点,因此不做详细描述,而且谷歌Instant Run类加载部分的代码非常简单,大家可以从官网进行了解:https://android.googlesource.com/platform/tools/base/+/gradle_2.0.0/instant-run/instant-run-server/src/main/java/com/android/tools/fd/runtime/IncrementalClassLoader.java 重点是弄清楚前面提到的示例中为何当插件jar中的类X引用的类Y不在插件jar中时会抛出异常和如何解决该异常,以及Instant Run类加载部分设计的思想。 前面说过Instant Run类加载部分的设计可以用来解决前面示例提到的插件jar中类X引用的类Y不在插件jar中时导致的ClassNotFoundException,推而广之,该设计可以用来解决广义上的类似热修复的补丁类/动态加载的插件类引用了原始jar/dex中的某个类导致的ClassNotFoundException,能够很好的做到补丁类代码和业务代码互不影响的效果,即能够被代理ClassLoader加载的类会通过DelegateClassLoader加载,不能被代理ClassLoader加载的类会被系统默认的ClassLoader加载(java中的AppClassLoader和安卓中的PathClassLoader),就像没使用代理ClassLoadr一样透明,从而很好的做到了动态加载的类代码和原业务代码相隔离的效果。即动态加载时只需要关心补丁类而不用关心业务代码类(宿主jar/dex中的类),即只用修复bug类,而无需全量合成替换。 虽然Instan Run的设计思想很好的解决了动态加载的插件jar的类代码和原宿主jar业务代码相隔离的效果,但是并不意味着这种思想可以直接移植到java代码运行环境中而不加任何修改,原因是安卓中的ClassLoader的findClass方法和java中的ClassLoader的findClass的原理是不一样的,安卓中的findClass是从dex文件中查找类,和java中的loadClass方法一样安卓查找类时如果一个类以及加载会从缓存中取该类的Class对象。而java中的这个逻辑是在loadClass方法中的。如果直接采用Instan Run的这个设计思想,将自定义的MyClassLoader的类查找委托给代理的DelegateClassLoader的findClass方法,那么此时我们重写的肯定是ClassLoader的findClass方法,举个简单例子如下: [crayon-5f03adfc980a9857762977/] 考虑这样一种情况,如果插件jar中的某个类X引用了插件jar中的类Y,那么当加载类X时,会使用代理ClassLoader的findClass方法去加载类Y,然后当宿主jar中也用到类Y的时候此时按照这个设计思想很显然代理ClassLoader还是会通过findClass方法加载类Y,那么此时就会抛出一个attempted duplicate class definition的异常,也就是某个类被同一个ClassLoader加载了2次,也即对同一个类使用同一个ClassLoader实例调用了2次findClass方法,这在java中是不允许的。 之所以会调用2次findClass方法原因是我们重写的是findClass方法,而java中一个类已经加载后下次使用会从缓存中加载的逻辑是在loadClass方法中的。而Instan Run的关于ClassLoader整个设计思想是自定义的ClassLoader直接调用代理DelegateClassLoader的findClass方法而不是loadClass,所以直接引用到java平台的话第二次还是会调用代理DelegateClassLoader的findClass方法。为何安卓上没这个问题,前面说过安卓中的findClass方法和java中是不一样的,安卓中的findClass方法实际上是委托DexPathList的findClass方法来完成类的查找的,不清楚的小伙伴可以参看也谈安卓dex的动态加载与MultiDex和热修复 那么如何解决呢?知道原因是第二次还会调用findClass导致的就很容易解决了,我们可以在DelegateClassLoader中设置一个缓存Map,用来保存已经加载的类名称与其对应的Class对象,然后在findClass方法中加载类的时候先判断在缓存Map中是否存在该类的对象,如果存在直接返回,否则在调用父类的findClass方法即可,换而言之就是把java中ClassLoader的loadClass方法中缓存逻辑加到代理DelegateClassLoader中即可。

热修复

dex中类的加载流程和unexpected DEX异常

在也谈安卓dex的动态加载与MultiDex和热修复中我们讲解了安卓dex文件的加载过程,那么dex文件加载到内存后类的加载流程是怎样的呢?为何被打上预校验的类与其引用的类不在同一个dex中会出现unexpected DEX异常呢?这就需要从安卓中类加载的native层代码寻找答案了。从native代码我们能够更加清楚的明白QQ空间热修复的插桩字节码的原理以及比QQ空间更优的解决办法。 前面说过加载一个类起始于ClassLoader中的loadClass方法,关键函数的调用流程如下: ClassLoader.loadClass->DexPathList.findClass->DexFile.loadClassBinaryName->dalvik_system_DexFile.cpp中Dalvik_dalvik_system_DexFile_defineClassNative ->Class.cpp中的dvmDefineClass->findClassNoInit [crayon-5f03adfc9841d987590562/] 可以看到在native层加载类调用的是findClassNoInit函数,这个函数非常长,我们截取其中的关键代码看下这个函数是如何加载一个类的: [crayon-5f03adfc98425897549585/] 可以看到findClassNoInit首先会从缓存的已加载了的类的HashTable中去查找该类是否已经加载过,如果已经加载过直接返回该类,否则会从dex中查找该类,如果找到会将其转换为ClassObject对象(对应java层的Class),然后将其添加到HashTable中进行缓存,然后会调用dvmLinkClass函数链接该类,而在链接的过程中调用了Resolve.cpp中的dvmResolveClass函数来解析class,在这个函数中会出现QQ空间热修复提到的预校验问题,因为这个函数比较重要而且函数也不长,因此看下其完整的代码: [crayon-5f03adfc9842b559257695/] 可以从源代码的if语句中提炼出导致抛出unexpected DEX异常需要同时满足以下3个条件(其实是4个条件,只不过因为resClassCheck->classLoader != NULL是恒成立的,所以只能考虑其余3个条件),如下图所示 可以看到只有当3个条件同时满足的时候才会抛出unexpected DEX异常,出现QQ空间热修复中提到的pre-verified问题。换而言之,只要我们破坏这三个条件中的某一个即可防止该异常抛出,我们来看下这三个条件成立的情况: !fromUnverifiedConstant,即fromUnverifiedConstant==false,而这个fromUnverifiedConstant即是调用dvmResolveClass函数时传入的一个bool参数 IS_CLASS_FLAG_SET(referrer, CLASS_ISPREVERIFIED),加载的类的flag是CLASS_ISPREVERIFIED referrer->pDvmDex != resClassCheck->pDvmDex,加载的类和被引用类不在同一个dex中 再来看看如何破坏这3个条件,一个一个来看: 解决某个被打上CLASS_ISPREVERIFIED预校验标识导致unExpected异常包含3种解决思路: 以QQ空间为代表的防止类被打上该标志,通过字节码插桩方式让原dex中的每个类引用一个与其不在同一个dex中的某个类,这样原dex中的每一个类都不会被打上CLASS_ISPREVERIFIED标志 以Tinker为代表的让打上该标志的类和其引用的所有的类都在同一个dex中,具体做法是让补丁类与原dex进行合成为新的dex,这样即使某个被打上CLASS_ISPREVERIFIED标志的类引用了该补丁类,它们也仍然在同一个dex中 通过让补丁类触发”const-class” / “instance-of”指令来达到让fromUnverifiedConstant标志为true的目的,从而避免预校验过程 首先QQ空间这种方式是不大推荐的,因为QQ空间实际上是破坏了执行dexopt阶段时类被打上CLASS_ISPREVERIFIED标识,而dalvik虚拟机之所以要在dexopt阶段就将这些类打上该标识是为了提高运行效率,但是如果在dexopt阶段阻止某些本应该被打上CLASS_ISPREVERIFIED/CLASS_ISOPTIMIZED标志的类被打上该标识, 那么这些类的Verify和Optimize都将在类的初始化阶段进行,很显然在类的初始化阶段去执行这些操作会降低运行效率,因为dexopt操作是在apk安装时进行的,这会将verify和optimize从安装时改到了运行时进行,用图表示如下: 图片引用自https://yq.aliyun.com/articles/222892?utm_content=m_32258# 另外也可以看到QQ空间的插桩做法实际上是对未出现bug前的原dex进行处理,因为无法预计哪个类可能会出现bug,所以需要对原dex中的每个类都插入一段引用另外一个单独dex中类的代码片段。因此最坏的情况就是无出现bug的类,此时插桩就失去了意义还会白白损耗性能。 而Tinker以及方案3属于对出现bug之后的处理,Tinker将出现bug的类修复后与原dex进行合成,方案3通过让出现bug的类修复后主动触发”const-class” / “instance-of”指令。 从操作过程来看,QQ空间方案属于预防,在发布的原包dex的每个类中插入一段引用另外一个单独dex中类的代码片段从而达到每个类都不被打上CLASS_ISOPTIMIZED标志,以避免出现bug后热修复时预校验问题。属于未雨绸缪,不管下不下雨,随时带把伞使得万一下雨时有伞可用。Tinker和方案3属于补救,Tinker出现bug修复后,让修复的类和原dex合成为全新的dex,不规避打上CLASS_ISOPTIMIZED,而是避免打上了该标志的类与其直接引用的所有的类不在同一个dex中这一问题。方案3通过让补丁类主动触发”const-class” / “instance-of”指令避免补丁类进行预校验。很显然出现bug后补救的方案更好,因为预防在很多情况下是不必要的,就像可能绝大多数情况下都不会下雨,比如前面说过最坏的情况就是无bug类出现此时插桩只会白白损耗性能。而补救是在出现bug之后,已经明确知道出现bug了。  

热修复

也谈安卓dex的动态加载与MultiDex和热修复

在前面从java虚拟机ClassLoader到安卓dex动态加载之ClassLoader动态加载jar的文章中讲到了java中jar的动态加载,而安卓和java一样都是使用的java语言开发,因此最终文件肯定还是字节码,只不过安卓不在使用java虚拟机来加载这些字节码,而且字节码也不再是.class/jar的文件格式,而是安卓特有的dex文件格式。也正因为这些相似性,java中的类加载机制被安卓给借鉴过来了,只不过安卓实现了自己的类加载器,而不在是java中的那些ClassLoader,因为要加载的文件格式已经不同。在安卓中的类加载器主要包括PathClassLoader和DexClassLoader,这两者都是继承自BaseDexClassLoader,其中PathClassLoader是安卓中的默认加载器。本文重点讲解一下安卓dex的加载原理,以及基于该原理衍生出来的MultiDex,热修复等技术。 在讲解dex加载原理之前,这里先纠正下网上广泛流传的错误结论,网上很多文章都说PathClassLoader和DexClassLoader的区别是前者不能加载指定目录的dex,只能加载已经安装的apk的dex,而后者可以,我可以负责任的说这是一个错误的结论,这2种类加载器都可以加载指定目录下的dex文件,从本质上来讲这2个ClassLoader无任何区别,唯一的区别是在安卓API26之前,即安卓8.0系统之前前者不能指定dex优化后的odex文件存放目录而后者可以,另外从8.0开始谷歌为了安全考虑已不允许开发者指定odex存储路径,此时这2个ClassLoader从功能上来将已经完全等同了。 安卓中PathClassLoader和DexClassLoader都是继承自BaseDexClassLoader,因此我们只需要分析BaseDexClassLoader即可。我们知道加载一个类都是调用的ClassLoader的loadClass方法,而该方法会调用findClass方法。因此我们首先从BaseDexClassLoader的findClass方法入手。该方法源码如下: [crayon-5f03adfc987f3224573958/] 可以看到BaseDexClassLoader的findClass方法实际上是调用了pathList这个成员的findClass方法,而pathList是一个DexPathList对象,该成员是在BaseDexClassLoader的构造函数中赋值的,也就是说当实例化一个BaseDexClassLoader对象时也随之创建了一个DexPathList对象,代码如下 [crayon-5f03adfc987fa744598061/] 所以接下来看看DexPathList类的findClass方法。 [crayon-5f03adfc987fd754479351/] 可以看到在DexPathList类的findClass方法中会迭代遍历dexElements成员,该成员是一个Element[]类型的数组,其中的每一个元素Element中就包含了DexFile这个数据结构,而DexFile就是安卓中dex文件在java层对应的数据描述,然后调用DexFile类的loadClassBinaryName方法来查找加载某个类,从findClass这个函数可以看到,在循环遍历Element[]中每一个Element中的DexFile时,如果在当前DexFile中加载到了要查找的类,会直接返回该类。否则继续迭代遍历。那么如果多个dex文件中包含了同一个类,肯定会优先返回在Element[]数组中靠前的DexFile中的那个类,注意这很重要,这是后面讲到的QQ空间热修复的原理所在。dex加载流程基本就讲完了,我们再来挖一些细节,我们来看下dexElements这个成员是在哪里赋值的,查看源码发现是在DexPathList的构造函数中赋值的,代码如下 [crayon-5f03adfc98801225496485/] 也就是说在构造DexPathList对象的时候会调用makeDexElements方法,该方法会返回一个Element[]数组,该数组中的每一个Element包含了要加载的dex文件在java层对应的描述DexFile,这个函数是非常重要的函数,因此我们看下具体实现: [crayon-5f03adfc98806102525165/] 可以看到该函数会根据不同的文件类型如dex,apk,jar,zip等进行不同的处理,但是最终都会调用loadDexFile函数来将file参数指向的dex文件转换为dex文件在java层对应的数据描述DexFile对象,然后将该对象添加到Element中,最终返回。而loadDexFile函数实际上是调用了DexFile类的loadDex函数, [crayon-5f03adfc9880b583219379/] 而loadDex函数实际上也是直接调用DexFile的构造函数。从这个过程可以看到dex的最终加载是DexFile这个类来完成的。但是对于ClassLoader而言,dex的加载是通过DexPathList类的makeDexElements函数来完成的,该函数会根据传入的dex文件的路径(实质参数是dex文件路径所代表的File对象的集合)返回一个与dex文件相关联的包含DexFile的数组elements,而该数组elements正是从dex中加载类时迭代遍历的Element[]数组。既然只需要一个dex文件的路径就可以得到在加载某个类时查找的DexFile。那么如果我们能够通过反射拿到某个ClassLoader的DexPathList对象,然后通过反射调用其makeDexElements函数就可以加载我们我们自己指定的路径的dex文件了,比如加载Assets目录下的插件apk。当然除了通过makeDexElements将指定路径的dex文件加载到内存以外还需要修改原DexPathList类的elements字段的内容。事实上谷歌的MultiDex的原理正式基于此。 总结一下dex的加载过程,这个是理解MultiDex和热修复原理的理论基础。 当通过PathClassLoader加载某个类时,实质上是调用的BaseDexClassLoader的findClass,而BaseDexClassLoader的findClass实质上调用的是DexPathList类的findClass 当调用BaseDexClassLoader的findClass方法时已经产生了DexPathList对象(因为当构造BaseDexClassLoader对象的时候就会创建DexPathList对象),而在构造DexPathList对象时会通过该类的makeDexElements函数将dex文件加载到内存,同时返回一个包含java层的DexFile类的Element[]数组dexElements(如果读者还想进一步了解DexFile类是如何加载dex文件到内存的,实质是在native层调用linux系统函数mmap来将文件映射到内存的,详细分析可以看http://www.cnblogs.com/lanrenxinxin/p/4712224.html) 查找类时是从步骤2中得到的dexElements数组中迭代查找,如果找到会直接返回该类,也就是说会优先返回在dexElements数组中靠前的dex文件中的类。 其中步骤2衍生出了谷歌MultiDex原理,步骤3衍生出了QQ空间热修复原理。当然实现的话3个步骤都包含。另外从上述过程可以看到要实现加载指定路径的dex,比如Assets目录下的dex文件,有两种方式: 一种是通过反射获取当前ClassLoader的pathList成员,然后通过反射调用其makeDexElements函数,事实上谷歌的MultiDex就是采用的这种方式 另外一种是使用DexClassLoader,然后通过反射获取该DexClassLoader的pathList,然后通过反射获取pathList的dexElements成员,事实上与QQ空间热修复原理类似的开源的nuwa就是采用的这种方式。这种方式的本质还是调用了makeDexElements函数(在构造DexClassLoader时会创建DexPathList对象,而DexPathList的构造函数中调用了makeDexElements函数) 谷歌MultiDex的源码位于https://android.googlesource.com/platform/frameworks/multidex/+/d79604bd38c101b54e41745f85ddc2e04d978af2/library/src/android/support/multidex MultiDex的使用请参看https://developer.android.com/studio/build/multidex.html#about MultiDex的源码不复杂,总共只4个java文件。其中最重要的是MultiDex.java这个类。不管使用哪种方式配置,其本质都是调用了MultiDex的install方法。该方法很长,大家可以参看源码,但最核心的是调用了 [crayon-5f03adfc98812636788705/] 而该函数会根据安卓sdk的不同版本,调用不同的install函数来加载额外的dex。 [crayon-5f03adfc98815163049466/] 这里以V19为例进行分析,因为其他的都是大同小异,原理都是相同的,只不过不同的版本在细节处理上可能不同而已。 [crayon-5f03adfc98818202006578/] 可以看到原理即是基于前面dex加载流程的原理,具体实现是通过反射拿到当前ClassLoader的pathList成员,然后通过反射调用pathList的makeDexElements函数实现加载file参数指向的dex文件,然后将pathList的dexElements设置为调用makeDexElements函数时返回的Elements数组和原ClassLoader已经加载的dex的Elements数组之和,这样就实现了加载多dex的效果。 QQ空间热修复的原理和MultiDex极其相似,只不过在将pathList的dexElements设置为调用makeDexElements函数时返回的Elements数组和原ClassLoader已经加载的dex的Elements数组之和这一步是将patch对应的Elements数组放到原dex对应的Elements数组的前面,这样查找相同的类时就会优先查找到patch中修复的类。从而实现热修复。QQ空间热修复的本质是类替换,在使用同一个类时不在是使用原dex中的类,而是使用优先查找到的patch中对应的类。当然在具体实现过程也有一些细节要进行处理,比如说类校验等,具体的可以参考QQ空间团队官方的博客。【QQ空间技术团队】安卓App热补丁动态修复技术介绍