标签:dex类的加载

热修复

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-5f03c28442a06644594671/] 可以看到在native层加载类调用的是findClassNoInit函数,这个函数非常长,我们截取其中的关键代码看下这个函数是如何加载一个类的: [crayon-5f03c28442a0e987927626/] 可以看到findClassNoInit首先会从缓存的已加载了的类的HashTable中去查找该类是否已经加载过,如果已经加载过直接返回该类,否则会从dex中查找该类,如果找到会将其转换为ClassObject对象(对应java层的Class),然后将其添加到HashTable中进行缓存,然后会调用dvmLinkClass函数链接该类,而在链接的过程中调用了Resolve.cpp中的dvmResolveClass函数来解析class,在这个函数中会出现QQ空间热修复提到的预校验问题,因为这个函数比较重要而且函数也不长,因此看下其完整的代码: [crayon-5f03c28442a13222501942/] 可以从源代码的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-5f03c2844302b731415359/] 可以看到BaseDexClassLoader的findClass方法实际上是调用了pathList这个成员的findClass方法,而pathList是一个DexPathList对象,该成员是在BaseDexClassLoader的构造函数中赋值的,也就是说当实例化一个BaseDexClassLoader对象时也随之创建了一个DexPathList对象,代码如下 [crayon-5f03c28443032672145791/] 所以接下来看看DexPathList类的findClass方法。 [crayon-5f03c28443033637277421/] 可以看到在DexPathList类的findClass方法中会迭代遍历dexElements成员,该成员是一个Element[]类型的数组,其中的每一个元素Element中就包含了DexFile这个数据结构,而DexFile就是安卓中dex文件在java层对应的数据描述,然后调用DexFile类的loadClassBinaryName方法来查找加载某个类,从findClass这个函数可以看到,在循环遍历Element[]中每一个Element中的DexFile时,如果在当前DexFile中加载到了要查找的类,会直接返回该类。否则继续迭代遍历。那么如果多个dex文件中包含了同一个类,肯定会优先返回在Element[]数组中靠前的DexFile中的那个类,注意这很重要,这是后面讲到的QQ空间热修复的原理所在。dex加载流程基本就讲完了,我们再来挖一些细节,我们来看下dexElements这个成员是在哪里赋值的,查看源码发现是在DexPathList的构造函数中赋值的,代码如下 [crayon-5f03c28443037693400890/] 也就是说在构造DexPathList对象的时候会调用makeDexElements方法,该方法会返回一个Element[]数组,该数组中的每一个Element包含了要加载的dex文件在java层对应的描述DexFile,这个函数是非常重要的函数,因此我们看下具体实现: [crayon-5f03c2844303b473661194/] 可以看到该函数会根据不同的文件类型如dex,apk,jar,zip等进行不同的处理,但是最终都会调用loadDexFile函数来将file参数指向的dex文件转换为dex文件在java层对应的数据描述DexFile对象,然后将该对象添加到Element中,最终返回。而loadDexFile函数实际上是调用了DexFile类的loadDex函数, [crayon-5f03c2844303f533119388/] 而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-5f03c28443047119164519/] 而该函数会根据安卓sdk的不同版本,调用不同的install函数来加载额外的dex。 [crayon-5f03c28443049245793190/] 这里以V19为例进行分析,因为其他的都是大同小异,原理都是相同的,只不过不同的版本在细节处理上可能不同而已。 [crayon-5f03c2844304b890684183/] 可以看到原理即是基于前面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热补丁动态修复技术介绍