在前面从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从功能上来将已经完全等同了。
dex加载原理
安卓中PathClassLoader和DexClassLoader都是继承自BaseDexClassLoader,因此我们只需要分析BaseDexClassLoader即可。我们知道加载一个类都是调用的ClassLoader的loadClass方法,而该方法会调用findClass方法。因此我们首先从BaseDexClassLoader的findClass方法入手。该方法源码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
@Override protected Class<?> findClass(String name) throws ClassNotFoundException { List<Throwable> suppressedExceptions = new ArrayList<Throwable>(); Class c = pathList.findClass(name, suppressedExceptions); if (c == null) { ClassNotFoundException cnfe = new ClassNotFoundException("Didn't find class \"" + name + "\" on path: " + pathList); for (Throwable t : suppressedExceptions) { cnfe.addSuppressed(t); } throw cnfe; } return c; } |
可以看到BaseDexClassLoader的findClass方法实际上是调用了pathList这个成员的findClass方法,而pathList是一个DexPathList对象,该成员是在BaseDexClassLoader的构造函数中赋值的,也就是说当实例化一个BaseDexClassLoader对象时也随之创建了一个DexPathList对象,代码如下
1 2 3 4 5 |
public BaseDexClassLoader(String dexPath, File optimizedDirectory, String libraryPath, ClassLoader parent) { super(parent); this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory); } |
所以接下来看看DexPathList类的findClass方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
public Class findClass(String name, List<Throwable> suppressed) { for (Element element : dexElements) { DexFile dex = element.dexFile; if (dex != null) { Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed); if (clazz != null) { return clazz; } } } if (dexElementsSuppressedExceptions != null) { suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions)); } return null; } |
可以看到在DexPathList类的findClass方法中会迭代遍历dexElements成员,该成员是一个Element[]类型的数组,其中的每一个元素Element中就包含了DexFile这个数据结构,而DexFile就是安卓中dex文件在java层对应的数据描述,然后调用DexFile类的loadClassBinaryName方法来查找加载某个类,从findClass这个函数可以看到,在循环遍历Element[]中每一个Element中的DexFile时,如果在当前DexFile中加载到了要查找的类,会直接返回该类。否则继续迭代遍历。那么如果多个dex文件中包含了同一个类,肯定会优先返回在Element[]数组中靠前的DexFile中的那个类,注意这很重要,这是后面讲到的QQ空间热修复的原理所在。dex加载流程基本就讲完了,我们再来挖一些细节,我们来看下dexElements这个成员是在哪里赋值的,查看源码发现是在DexPathList的构造函数中赋值的,代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
public DexPathList(ClassLoader definingContext, String dexPath, String libraryPath, File optimizedDirectory) { if (definingContext == null) { throw new NullPointerException("definingContext == null"); } if (dexPath == null) { throw new NullPointerException("dexPath == null"); } if (optimizedDirectory != null) { if (!optimizedDirectory.exists()) { throw new IllegalArgumentException( "optimizedDirectory doesn't exist: " + optimizedDirectory); } if (!(optimizedDirectory.canRead() && optimizedDirectory.canWrite())) { throw new IllegalArgumentException( "optimizedDirectory not readable/writable: " + optimizedDirectory); } } this.definingContext = definingContext; ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>(); this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory, suppressedExceptions); if (suppressedExceptions.size() > 0) { this.dexElementsSuppressedExceptions = suppressedExceptions.toArray(new IOException[suppressedExceptions.size()]); } else { dexElementsSuppressedExceptions = null; } this.nativeLibraryDirectories = splitLibraryPath(libraryPath); } |
也就是说在构造DexPathList对象的时候会调用makeDexElements方法,该方法会返回一个Element[]数组,该数组中的每一个Element包含了要加载的dex文件在java层对应的描述DexFile,这个函数是非常重要的函数,因此我们看下具体实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
private static Element[] makeDexElements(ArrayList<File> files, File optimizedDirectory, ArrayList<IOException> suppressedExceptions) { ArrayList<Element> elements = new ArrayList<Element>(); /* * Open all files and load the (direct or contained) dex files * up front. */ for (File file : files) { File zip = null; DexFile dex = null; String name = file.getName(); if (name.endsWith(DEX_SUFFIX)) { // Raw dex file (not inside a zip/jar). try { dex = loadDexFile(file, optimizedDirectory); } catch (IOException ex) { System.logE("Unable to load dex file: " + file, ex); } } else if (name.endsWith(APK_SUFFIX) || name.endsWith(JAR_SUFFIX) || name.endsWith(ZIP_SUFFIX)) { zip = file; try { dex = loadDexFile(file, optimizedDirectory); } catch (IOException suppressed) { /* * IOException might get thrown "legitimately" by the DexFile constructor if the * zip file turns out to be resource-only (that is, no classes.dex file in it). * Let dex == null and hang on to the exception to add to the tea-leaves for * when findClass returns null. */ suppressedExceptions.add(suppressed); } } else if (file.isDirectory()) { // We support directories for looking up resources. // This is only useful for running libcore tests. elements.add(new Element(file, true, null, null)); } else { System.logW("Unknown file type for: " + file); } if ((zip != null) || (dex != null)) { elements.add(new Element(file, false, zip, dex)); } } return elements.toArray(new Element[elements.size()]); } |
可以看到该函数会根据不同的文件类型如dex,apk,jar,zip等进行不同的处理,但是最终都会调用loadDexFile函数来将file参数指向的dex文件转换为dex文件在java层对应的数据描述DexFile对象,然后将该对象添加到Element中,最终返回。而loadDexFile函数实际上是调用了DexFile类的loadDex函数,
1 2 3 4 5 6 7 8 9 |
private static DexFile loadDexFile(File file, File optimizedDirectory) throws IOException { if (optimizedDirectory == null) { return new DexFile(file); } else { String optimizedPath = optimizedPathFor(file, optimizedDirectory); return DexFile.loadDex(file.getPath(), optimizedPath, 0); } } |
而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
MultiDex的使用请参看https://developer.android.com/studio/build/multidex.html#about
MultiDex的源码不复杂,总共只4个java文件。其中最重要的是MultiDex.java这个类。不管使用哪种方式配置,其本质都是调用了MultiDex的install方法。该方法很长,大家可以参看源码,但最核心的是调用了
1 |
installSecondaryDexes(loader, dexDir, files); |
而该函数会根据安卓sdk的不同版本,调用不同的install函数来加载额外的dex。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
private static void installSecondaryDexes(ClassLoader loader, File dexDir, List<File> files) throws IllegalArgumentException, IllegalAccessException, NoSuchFieldException, InvocationTargetException, NoSuchMethodException, IOException { if (!files.isEmpty()) { if (Build.VERSION.SDK_INT >= 19) { V19.install(loader, files, dexDir); } else if (Build.VERSION.SDK_INT >= 14) { V14.install(loader, files, dexDir); } else { V4.install(loader, files); } } } |
这里以V19为例进行分析,因为其他的都是大同小异,原理都是相同的,只不过不同的版本在细节处理上可能不同而已。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
private static void install(ClassLoader loader, List<File> additionalClassPathEntries, File optimizedDirectory) throws IllegalArgumentException, IllegalAccessException, NoSuchFieldException, InvocationTargetException, NoSuchMethodException { /* The patched class loader is expected to be a descendant of * dalvik.system.BaseDexClassLoader. We modify its * dalvik.system.DexPathList pathList field to append additional DEX * file entries. */ Field pathListField = findField(loader, "pathList"); Object dexPathList = pathListField.get(loader); ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>(); expandFieldArray(dexPathList, "dexElements", makeDexElements(dexPathList, new ArrayList<File>(additionalClassPathEntries), optimizedDirectory, suppressedExceptions)); //省略若干细节处理代码 } /** * A wrapper around * {@code private static final dalvik.system.DexPathList#makeDexElements}. */ private static Object[] makeDexElements( Object dexPathList, ArrayList<File> files, File optimizedDirectory, ArrayList<IOException> suppressedExceptions) throws IllegalAccessException, InvocationTargetException, NoSuchMethodException { Method makeDexElements = findMethod(dexPathList, "makeDexElements", ArrayList.class, File.class, ArrayList.class); return (Object[]) makeDexElements.invoke(dexPathList, files, optimizedDirectory, suppressedExceptions); } private static void expandFieldArray(Object instance, String fieldName, Object[] extraElements) throws NoSuchFieldException, IllegalArgumentException, IllegalAccessException { Field jlrField = findField(instance, fieldName); Object[] original = (Object[]) jlrField.get(instance); Object[] combined = (Object[]) Array.newInstance( original.getClass().getComponentType(), original.length + extraElements.length); System.arraycopy(original, 0, combined, 0, original.length); System.arraycopy(extraElements, 0, combined, original.length, extraElements.length); jlrField.set(instance, combined); } |
可以看到原理即是基于前面dex加载流程的原理,具体实现是通过反射拿到当前ClassLoader的pathList成员,然后通过反射调用pathList的makeDexElements函数实现加载file参数指向的dex文件,然后将pathList的dexElements设置为调用makeDexElements函数时返回的Elements数组和原ClassLoader已经加载的dex的Elements数组之和,这样就实现了加载多dex的效果。
QQ空间热修复
QQ空间热修复的原理和MultiDex极其相似,只不过在将pathList的dexElements设置为调用makeDexElements函数时返回的Elements数组和原ClassLoader已经加载的dex的Elements数组之和这一步是将patch对应的Elements数组放到原dex对应的Elements数组的前面,这样查找相同的类时就会优先查找到patch中修复的类。从而实现热修复。QQ空间热修复的本质是类替换,在使用同一个类时不在是使用原dex中的类,而是使用优先查找到的patch中对应的类。当然在具体实现过程也有一些细节要进行处理,比如说类校验等,具体的可以参考QQ空间团队官方的博客。【QQ空间技术团队】安卓App热补丁动态修复技术介绍
