-
什么是Android的热修复
热修复是一种在应用程序运行时对已发布版本进行动态修复bug或更新功能的技术。
当APP发布上线之后,如果出现了严重的bug,通常需要重新发版来修复,但是重新走发布流程可能时间比较长,重新安装APP用户体验也不友好,所以出现了热修复,热修复就是通过发布在服务器上一个插件(一个文件或者apk、jar包等),使APP运行的时候通过服务器接口查询并下载插件文件,然后加载插件里面的代码,从而解决缺陷,并且对用户来说是无感的(有时候可能需要重启一下APP)。
热修复的实现方案,一种是类加载方案,即dex插桩,这种思路在插件化中也会用到;还有一种是底层替换方案,即修改替换ArtMethod。采用类加载方案的主要是以腾讯系为主,包括微信的Tinker、qq空间的QZone、美团的Robust、饿了么的Amigo;采用底层替换方案的主要是阿里系的AndFix等。
他们采用的修复方式不同,比如AndFix和Robust采用native层hook Java层代码 bug fix,他们是即时生效的;而Tinker和QZone采用类替换,需要重启APP才能生效。本文我们来分析类替换的修复方式。
-
原理
类替换方式的原理是根据Android的类加载机制实现的。
从Java虚拟机的角度上讲,只存在两种不同的类加载器:
-
Bootstrap ClassLoader:使用C++实现,是虚拟机的一部分。它主要负责加载存放在%JAVAHOME%/lib目录中的,或者被-Xbootclasspath指定的类库到虚拟机内存中,Bootstrap ClassLoader无法被java程序直接引用。
继承自java.lang.ClassLoader的类加载器: -
Extension ClassLoader:主要负责加载%JAVAHOME%/lib/ext目录中的,或者被java.ext.dirs系统变量指定路径的所有类。
Application ClassLoader:也被称为系统类加载器(因为其实getSystemClassLoader的返回对象),主要负责加载用户类路径(ClassPath)下的类库
由于Android虚拟机的执行文件是Dex文件,而不是JVM中的Class文件,所以Java是中的类加载器是无法加载Dex文件的,因此,Android中存在另外一套ClassLoader。
Android中的ClassLoader根据用途可分为一下3种:
- BootClassLoader:主要用于加载系统的类,包括java和android系统的类库,和JVM中不同,BootClassLoader是ClassLoader内部类,是由java实现的,它也是所有系统ClassLoader的父ClassLoader。
- PathClassLoader:用于加载Android系统类和开发编写应用的类,只能加载已经安装应用的dex或apk文件,也是getSystemClassLoader的返回对象。
- DexClassLoader:可以用于加载任意路径的zip,jar或者apk文件,也是进行安卓动态加载的基础。
PathClassLoader和DexClassLoader都继承自BaseDexClassLoader,BaseDexClassLoader继承自ClassLoader。
应用在启动时会构造一个PathClassLoader来负责加载程序内的类,在加载过程中会调用父类ClassLoader的loadClass方法:
protected Class<?> loadClass(String className, boolean resolve) throws ClassNotFoundException { Class<?> clazz = findLoadedClass(className); if (clazz == null) { ClassNotFoundException suppressed = null; try { clazz = parent.loadClass(className, false); } catch (ClassNotFoundException e) { suppressed = e; } if (clazz == null) { try { clazz = findClass(className); } catch (ClassNotFoundException e) { e.addSuppressed(suppressed); throw e; } } } return clazz; }
前面的findLoadedClass方法是去查询BootClassLoader中加载过的类,parent.loadClass是去父ClassLoader中查询有没有加载过,如果有的话就不重复加载了,如果没找到则调用findClass方法查询本身加载过的类,这就是双亲委托机制。
@Override protected Class<?> findClass(String name) throws ClassNotFoundException { // First, check whether the class is present in our shared libraries. if (sharedLibraryLoaders != null) { for (ClassLoader loader : sharedLibraryLoaders) { try { return loader.loadClass(name); } catch (ClassNotFoundException ignored) { } } } // Check whether the class in question is present in the dexPath that // this classloader operates on. 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; }
这里如果设置了公共加载器会先查询这部分加载器中加载过没有(其实这里也可以作为插件加载的一个入口),如果没有就会从pathList中查找。
private final DexPathList pathList;
DexPathList的findClass如下:
private Element[] dexElements; ... public Class<?> findClass(String name, List<Throwable> suppressed) { for (Element element : dexElements) { Class<?> clazz = element.findClass(name, definingContext, suppressed); if (clazz != null) { return clazz; } } if (dexElementsSuppressedExceptions != null) { suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions)); } return null; }
可以看到,这里会从dexElements中查找类,注意这里是正序遍历,找到了就返回,类替换修复技术的原理正是基于此,利用反射把插件dex文件的Element插入到原有dexElements的前面,这样在加载的时候就会优先返回插件中的类而不是原有打包到apk中的类,这样就实现了修复的目的。
-
-
先上代码
/** * 插件化工具类 * @author mph * @date 2023/8/22 */ object DexPluginUtil { private const val PATH_LIST = "pathList" private const val DEX_ELEMENTS = "dexElements" private const val DEX_FILE = "dexFile" /** * 将插件dex文件加载到路径中 */ @SuppressLint("DiscouragedPrivateApi") fun dexPlugin(application: Application, dexPath: String) { val file = File(dexPath) if (file.exists()) { try { //反射属性pathList val pathListField: Field = BaseDexClassLoader::class.java.getDeclaredField(PATH_LIST) pathListField.isAccessible = true //获取当前包类加载器中的pathList属性值 val pathListObj = pathListField.get(application.classLoader) //反射DexPathList类型 val dexPathListClass = pathListObj.javaClass //反射DexPathList的dexElements属性 val dexElementsField: Field = dexPathListClass.getDeclaredField(DEX_ELEMENTS) dexElementsField.isAccessible = true //获取当前包ClassLoader的pathList属性的dexElements属性值 val dexElementsObj = dexElementsField.get(pathListObj) //获取插件ClassLoader的pathList属性的dexElements属性值 //方式1 val pluginDexElementsObj = getPluginDexElementsFromDexClassLoader(file.path,application.cacheDir.absolutePath,application.classLoader) //方式2 // val pluginDexElementsObj = createPluginDexElements(dexElementsObj!!, file, application.cacheDir.absolutePath, application.classLoader) //方式3 // val pluginDexElementsObj = getPluginDexElementsFromPathClassLoader(file.path, application.classLoader) //合并 val currentLength = Array.getLength(dexElementsObj!!) val pluginLength = Array.getLength(pluginDexElementsObj) val sumDexElementsObj = Array.newInstance(dexElementsObj.javaClass.componentType!!, currentLength + pluginLength) //先添加plugin的dexElements(因为双亲委托,相同的类排在前面的会生效) for (i in 0 until pluginLength) { Array.set(sumDexElementsObj, i, Array.get(pluginDexElementsObj, i)) } //后添加原有的的dexElements for (i in 0 until currentLength) { Array.set(sumDexElementsObj, pluginLength + i, Array.get(dexElementsObj, i)) } //重新给包ClassLoader的pathList属性的dexElements设置合并后的值 ReflectUtil.setField(pathListObj, dexPathListClass, sumDexElementsObj, DEX_ELEMENTS) } catch (e: Exception) { e.printStackTrace() Throwable().stackTrace } } } /** * 使用DexClassLoader来加载dex */ private fun getPluginDexElementsFromDexClassLoader(filePath: String, optimizedPath: String, parentClassLoader: ClassLoader): Any { //创建优化缓存目录(dex文件需要优化为odex文件,需要一个缓存目录) // val optimizedDir = File(application.cacheDir.absolutePath + File.separator + "cache_odex") // if (!optimizedDir.exists()) { // optimizedDir.mkdirs() // } Log.d("Plugin", filePath.split(File.pathSeparator).toString()) //构造插件classloader val pluginPathClassLoader = DexClassLoader(filePath, optimizedPath, null, parentClassLoader) //获取插件ClassLoader的pathList属性值 val pluginPathListObj = getPathList(pluginPathClassLoader) //获取插件ClassLoader的pathList属性的dexElements属性值 val pluginDexElementsObj = getDexElements(pluginPathListObj!!) return pluginDexElementsObj!! } /** * 利用反射构造Element和DexFile,但是注意DexFile.loadDex方法已被弃用 */ private fun createPluginDexElements(dexElementsObj: Any, file: File, optPath: String, classLoader: ClassLoader): Any { //取第一个Element对象,通过它来获取DexFile的class类型 val firstElement = Array.get(dexElementsObj, 0) val firstElementDexFileObj = ReflectUtil.getField(firstElement!!, dexElementsObj.javaClass.componentType!!, DEX_FILE) val newDexFile = DexFile.loadDex(file.path, optPath, 0) //通过dalvik.system.DexPathList.Element(DexFile file, File dexZipPath)构造方法创建 return arrayOf( ReflectUtil.createObject( dexElementsObj.javaClass.componentType!!, arrayOf(newDexFile, null), firstElementDexFileObj!!.javaClass, File::class.java )!! ) } /** * 使用PathClassLoader来加载dex */ private fun getPluginDexElementsFromPathClassLoader(filePath: String, parentClassLoader: ClassLoader): Any { //构造插件classloader val pluginPathClassLoader = PathClassLoader(filePath, parentClassLoader) //获取插件ClassLoader的pathList属性值 val pluginPathListObj = getPathList(pluginPathClassLoader) //获取插件ClassLoader的pathList属性的dexElements属性值 val pluginDexElementsObj = getDexElements(pluginPathListObj!!) return pluginDexElementsObj!! } /** * 通过反射获取BaseDexClassLoader对象中的PathList对象 * * @param baseDexClassLoader BaseDexClassLoader对象 * @return PathList对象 */ @Throws(NoSuchFieldException::class, IllegalAccessException::class, IllegalArgumentException::class, ClassNotFoundException::class) fun getPathList(baseDexClassLoader: Any): Any? { return ReflectUtil.getField(baseDexClassLoader, Class.forName("dalvik.system.BaseDexClassLoader"), "pathList") } /** * 通过反射获取BaseDexClassLoader对象中的PathList对象,再获取dexElements对象 * * @param paramObject PathList对象 * @return dexElements对象 */ @Throws(NoSuchFieldException::class, IllegalAccessException::class, IllegalArgumentException::class) fun getDexElements(paramObject: Any): Any? { return ReflectUtil.getField(paramObject, paramObject.javaClass, "dexElements") } }
反射工具类代码如下:
/** * 反射工具类 * @author mph * @date 2023/8/22 */ object ReflectUtil { /** * 通过反射获取某对象,并设置私有可访问 * * @param obj 该属性所属类的对象 * @param clazz 该属性所属类 * @param field 属性名 * @return 该属性对象 */ @Throws(NoSuchFieldException::class, IllegalAccessException::class, IllegalArgumentException::class) fun getField(obj: Any, clazz: Class<*>, field: String): Any? { val localField: Field = clazz.getDeclaredField(field) localField.isAccessible = true return localField.get(obj) } /** * 给某属性赋值,并设置私有可访问 * * @param obj 该属性所属类的对象 * @param clazz 该属性所属类 * @param value 值 * @param field 属性名 */ @Throws(NoSuchFieldException::class, IllegalAccessException::class, IllegalArgumentException::class) fun setField(obj: Any?, clazz: Class<*>, value: Any?, field: String) { val localField: Field = clazz.getDeclaredField(field) localField.isAccessible = true localField.set(obj, value) } /** * 构造对象 * * @param clazz 对象类型 * @param paramTypes 参数类型 * @param args 参数 */ fun createObject(clazz: Class<*>, args: Array<Any?>, vararg paramTypes: Class<*>): Any? { val constructor = clazz.getDeclaredConstructor(*paramTypes) constructor.isAccessible = true return constructor.newInstance(*args) } }
以上代码的逻辑总结来说就是:
- 利用反射拿到当前BaseDexClassLoader对象(不管是PathClassLoader还是DexClassLoader)中的pathList对象,然后再通过反射拿到pathList对象中的dexElements对象;
- 构造插件的dexElements;
- 合并,把插件的dexElements放在前面,原有的dexElements元素放在后面,然后把这个合并后的新dexElements通过反射设置给当前ClassLoader的pathList对象的dexElements属性上。
我试了3种方式去构造插件dexElements,下面从源码来看每种方式的原理。
-
通过DexClassLoader或PathClassLoader的方式获取包含插件dex文件的dexElements
val pluginPathClassLoader = DexClassLoader(filePath, optimizedPath, null, parentClassLoader) val pluginPathClassLoader = PathClassLoader(filePath, parentClassLoader)
DexClassLoader和PathClassLoader的获取方式是一样的原理,他们的构造方法中都是调用父类BaseDexClassLoader的构造方法:
String librarySearchPath, ClassLoader parent, ClassLoader[] sharedLibraryLoaders, boolean isTrusted) { super(parent); // Setup shared libraries before creating the path list. ART relies on the class loader // hierarchy being finalized before loading dex files. this.sharedLibraryLoaders = sharedLibraryLoaders == null ? null : Arrays.copyOf(sharedLibraryLoaders, sharedLibraryLoaders.length); this.pathList = new DexPathList(this, dexPath, librarySearchPath, null, isTrusted); if (reporter != null) { reportClassLoaderChain(); } }
在DexPathList的构造方法中:
this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory, suppressedExceptions, definingContext, isTrusted);
splitDexPath方法如下:
private static List<File> splitPaths(String searchPath, boolean directoriesOnly) { List<File> result = new ArrayList<>(); if (searchPath != null) { for (String path : searchPath.split(File.pathSeparator)) { if (directoriesOnly) { try { StructStat sb = Libcore.os.stat(path); if (!S_ISDIR(sb.st_mode)) { continue; } } catch (ErrnoException ignored) { continue; } } result.add(new File(path)); } } return result; }
说明dexPath可以设置多个路径并用系统分隔符(Unix系统上是‘:’,Windows上是';')隔开就好。
再来看makeDexElements方法:
private static Element[] makeDexElements(List<File> files, File optimizedDirectory, List<IOException> suppressedExceptions, ClassLoader loader, boolean isTrusted) { Element[] elements = new Element[files.size()]; int elementsPos = 0; for (File file : files) { if (file.isDirectory()) { elements[elementsPos++] = new Element(file); } else if (file.isFile()) { String name = file.getName(); DexFile dex = null; if (name.endsWith(DEX_SUFFIX)) { // Raw dex file (not inside a zip/jar). try { dex = loadDexFile(file, optimizedDirectory, loader, elements); if (dex != null) { elements[elementsPos++] = new Element(dex, null); } } catch (IOException suppressed) { System.logE("Unable to load dex file: " + file, suppressed); suppressedExceptions.add(suppressed); } } else { try { dex = loadDexFile(file, optimizedDirectory, loader, elements); } catch (IOException suppressed) { suppressedExceptions.add(suppressed); } if (dex == null) { elements[elementsPos++] = new Element(file); } else { elements[elementsPos++] = new Element(dex, file); } } if (dex != null && isTrusted) { dex.setTrusted(); } } else { System.logW("ClassLoader referenced unknown path: " + file); } } if (elementsPos != elements.length) { elements = Arrays.copyOf(elements, elementsPos); } return elements; }
可以看到支持目录,我们这里只看dex文件的逻辑,通过loadDexFile方法构造一个DexFile:
DexFile(String fileName, ClassLoader loader, DexPathList.Element[] elements) throws IOException { mCookie = openDexFile(fileName, null, 0, loader, elements); mInternalCookie = mCookie; mFileName = fileName; //System.out.println("DEX FILE cookie is " + mCookie + " fileName=" + fileName); }
openDexFile内部会调用openDexFileNative方法,内部会把dex文件解析,如果发生错误则跳转到catch块,就不会被添加到dexElements中,比如未开启存储访问权限,这很重要,在6.0以上未获取运行时存储权限的话则这里就会出错,从而导致获取的dexElements中没有内容。
综上所述,通过构造DexClassLoader对象的方式获取它的dexElements时,DexClassLoader对象构造流程中会自动把dex文件添加到它的dexElements中。
-
手动创建Element
我们也可以通过反射手动创建Element然后添加到dexElements中,但是这个过程中DexFile.loadDex方法被设置成了废弃api,无法保证以后的兼容性,官方推荐使用PathClassLoader来加载dex文件,也就是上面的方式,这里就不赘述了,上面代码很好理解。
-
其他及注意
-
类替换方案需要开启存储权限,这一点在6.0以上系统需要特别注意开启运行时权限
,否则无法正常加载dex文件到dexElements。 -
在10.0版本及以上,应用有了分区存储(Scoped Store)的概念,应用只能访问外部存储中的应用专属目录,这时无法直接读取外部存储/sdcard目录下的文件
,因此对于上面的代码示例就不会成功,因为在DexFile的构造方法流程中会调用openDexFileNative方法去加载dex文件,这时就会因为存储权限问题导致抛出java.io.IOException: No original dex files found for dex location (x86_64) /sdcard/TestDemo.dex
异常,但是file文件判断exists()始终会返回true,也就是说,文件路径是可以访问的,但是要读取内容就会被限制了。
可以看到,在DexPathList的makeDexElements方法构造dexElements时,loadDexFile方法发生异常会被捕获放到suppressedExceptions中(它会存放在在DexPathList的dexElementsSuppressedExceptions中),你可以通过反射看一下它产生了什么异常(这个异常会自动打印但是不会导致崩溃,所以一开始我没注意到logcat中的信息,通过反射这个属性才得知异常信息的)。private static Element[] makeDexElements(List<File> files, File optimizedDirectory, List<IOException> suppressedExceptions, ClassLoader loader, boolean isTrusted) { Element[] elements = new Element[files.size()]; int elementsPos = 0; /* * Open all files and load the (direct or contained) dex files up front. */ for (File file : files) { if (file.isDirectory()) { // We support directories for looking up resources. Looking up resources in // directories is useful for running libcore tests. elements[elementsPos++] = new Element(file); } else if (file.isFile()) { String name = file.getName(); DexFile dex = null; if (name.endsWith(DEX_SUFFIX)) { // Raw dex file (not inside a zip/jar). try { dex = loadDexFile(file, optimizedDirectory, loader, elements); if (dex != null) { elements[elementsPos++] = new Element(dex, null); } } catch (IOException suppressed) { System.logE("Unable to load dex file: " + file, suppressed); suppressedExceptions.add(suppressed); } } else { try { dex = loadDexFile(file, optimizedDirectory, loader, elements); } 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); } if (dex == null) { elements[elementsPos++] = new Element(file); } else { elements[elementsPos++] = new Element(dex, file); } } if (dex != null && isTrusted) { dex.setTrusted(); } } else { System.logW("ClassLoader referenced unknown path: " + file); } } if (elementsPos != elements.length) { elements = Arrays.copyOf(elements, elementsPos); } return elements; }
这个异常是在哪里抛出的呢?
loadDexFile方法中会构造DexFile对象,它的构造方法中会调用openDexFile方法,内部是调用的openDexFileNative这个native方法,这个native方法是在/art/runtime/native/dalvik_system_DexFile.cc中注册的:static JNINativeMethod gMethods[] = { ..., NATIVE_METHOD(DexFile, openDexFileNative, "(Ljava/lang/String;" "Ljava/lang/String;" "I" "Ljava/lang/ClassLoader;" "[Ldalvik/system/DexPathList$Element;" ")Ljava/lang/Object;”), ... }
异常就是在OpenDexFilesFromOat函数中抛出的。static jobject DexFile_openDexFileNative(JNIEnv* env, jclass, jstring javaSourceName, jstring javaOutputName ATTRIBUTE_UNUSED, jint flags ATTRIBUTE_UNUSED, jobject class_loader, jobjectArray dex_elements) { ScopedUtfChars sourceName(env, javaSourceName); if (sourceName.c_str() == nullptr) { return nullptr; } std::vector<std::string> error_msgs; const OatFile* oat_file = nullptr; std::vector<std::unique_ptr<const DexFile>> dex_files = Runtime::Current()->GetOatFileManager().OpenDexFilesFromOat(sourceName.c_str(), class_loader, dex_elements, /*out*/ &oat_file, /*out*/ &error_msgs); return CreateCookieFromOatFileManagerResult(env, dex_files, oat_file, error_msgs); }
实际情况中,修复文件是通过网络下载的,所以肯定会保存在应用专属目录。 - 类是无法被卸载的,也就是说如果类已经被加载过了后,是不会再次被加载的,所以如果我们需要热修复的类在当前状态下已经被用过了或者正在使用,则下载完插件后必须要重启才能生效,除非目标类还没有被使用过,比如四大组件的修复都需要重启,因为他们注册在Manifest中,在应用启动后就会被加载,而且这些类需要在Application的onCreate中被替换才可以,对于其他类,可以在未使用它之前的任何时候替换。总之只要是未使用之前替换都不需要重启,反之则需要重启才能生效。
- 承接上条:
被分到主dex中的class在应用启动的时候就已经被加载了(这里的加载指的是通过ClassLoader的loadClass方法加载过),哪怕尚未用到它,因此之后再去添加补丁的dexElements也不会被替换了。所以在实际开发中,如果我们选择这种dexElements添加的方式的话,那我们需要修复的类必须保证不会在主dex中,这就需要我们手动分包去实现了。也就是说,被打包到主dex(classes.dex)中的类无法被动态加载这种方式修复。
- 如何生成dex文件:
- 在sdk的build-tools/<version>/下有一个dx程序,把它添加到环境变量;
- 执行dx --dex --output <输出路径/class.dex> 源class文件路径;
- 默认源class文件路径必须和包名一致,也就是说dx的当前执行目录必须是包名的上层目录,比如包名是com.mph.bpp.Test,则必须在com目录的父目录下执行dx命令,且源class文件路径必须设置为“com/mph/bpp/Test.class”,否则会爆“class name xxx does not match path”错误;
- 如果你想在任意目录下执行dx命令、通过绝对路径指定class源文件路径的话也可以,添加--no-strict参数即可。
- kotlin类如何生成dex文件呢?同样需要先把kotlin文件转成class文件。对于普通kotlin类(即没有引用Android相关sdk的),你可以通过kotlinc命令(需要下载kotlin命令行编译工具)来生成class文件。对于有其他很多依赖和sdk引用的,通过kotlinc的方式就很繁琐了,你需要添加的引用太多了,这时候通过AS的ReBuild来自动生成,在其app的build/tmp/kotlin-classes目录下找到生成的对应class文件,然后使用dx命令来生成dex文件即可。
-
网友评论