文章内容
本文整理自享学团队直播课《热修复(三):反射实现运行期热修复》。
上一篇把「类替换」这条链的原理走完了:补丁 dex 用反射转成一组 Element,插到 PathClassLoader 内部 DexPathList 维护的 dexElements 数组最前面,类加载按顺序先命中补丁,旧类被顶掉。这一篇不再讲原理,而是亲手用反射写出来HotFix.installPatch()并进行应用测试。
整个运行期热修复,就是几行反射代码。它依赖的支点只有一个——反射。因为要改的是 PathClassLoader 内部的 pathList、dexElements 这些私有字段和私有方法,普通代码碰不到,只有反射能在运行时越过访问限制拿到它、改它。
但这几行代码有两个真正的坑,也是这一课反复强调的:
pathList不在PathClassLoader身上,在它的父类BaseDexClassLoader里,而且是私有——所以不能直接getField,要沿父类链逐级往上找。makePathElements是一个带参且可能被重载的私有静态方法——反射找它时参数类型必须精确匹配,否则找不到;而且它是静态方法,反射调用时第一个参数要传null。
一、为什么是"插到最前面"
类加载器找类,不是直接读 APK,而是走一条链:loadClass 只负责顶层调度,真正找类在 DexPathList。Android 的 BaseDexClassLoader 覆写了 findClass,把查找转交给成员 pathList(DexPathList 对象):
// BaseDexClassLoader:真正找类的动作交给 pathListprotected Class<?> findClass(String name) throws ClassNotFoundException { // 收集加载过程中每一个 Element 抛出的异常,最后一起加上去 List<Throwable> suppressedExceptions = new ArrayList<Throwable>(); // 找类这件事,交给 DexPathList Class c = pathList.findClass(name, suppressedExceptions); if (c == null) { // 就是这行日志:Didn't find class "xxx" on path: DexPathList[...] ClassNotFoundException cnfe = new ClassNotFoundException( "Didn't find class \"" + name + "\" on path: " + pathList); cnfe.addSuppressed(suppressedExceptions); throw cnfe; } return c;}DexPathList.findClass 的做法是遍历它内部持有的 Element[] dexElements 数组,每个 Element 里装一个 DexFile,用 DexFile 去加载类。这是一个 for 循环,"先到先得":
// DexPathList.findClass:从 dexElements 数组里顺序找类public Class findClass(String name, List<Throwable> suppressed) { // 就是这个循环决定「先找到先用」 for (Element element : dexElements) { DexFile dex = element.dexFile; if (dex != null) { // 命中就返回,后面的 Element 根本不会看 Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed); if (clazz != null) { return clazz; } } } return null;}AOSP 给这个方法写的注释很直白:"This will find the one in the earliest listed path element."——最早列出的 Element 先被命中。所以热修复往里插,只能插到最前面,插到后面等于没插。

二、反射的三个基本要素
反射是 Java 里很基础的东西,这一课挑了三个点展开讲。
1. Class.forName 和 ClassLoader.loadClass 的区别
两个方法都能把一个字符串全限定名变成一个 Class 对象,区别在于会不会触发初始化。
要理解这个区别,先记住一个类的加载要经过这几个阶段:
加载 → 链接(验证 → 准备 → 解析) → 初始化 → 使用 → 卸载初始化阶段干的事,是给静态成员赋值、执行静态代码块。Class.forName 默认会完整走到初始化,ClassLoader.loadClass 默认只加载、不初始化。看 Android libcore 的实现就明白了,classLoader.loadClass(name) 调的是两个参数的重载,第二个参数 resolve 默认是 false:
// ClassLoader.loadClass:只加载,不初始化protected Class<?> loadClass(String className, boolean resolve) throws ClassNotFoundException { // 1. 先查缓存,加载过的类不再重复加载 Class<?> clazz = findLoadedClass(className); if (clazz == null) { // 2. 缓存没有,先交给父加载器(双亲委派) ClassNotFoundException suppressed = null; try { clazz = parent.loadClass(className, false); } catch (ClassNotFoundException e) { suppressed = e; } if (clazz == null) { // 3. 父加载器也没有,才轮到自己去真正加载 try { clazz = findClass(className); } catch (ClassNotFoundException e) { e.addSuppressed(suppressed); throw e; } } } return clazz;}而 Class.forName 会调用它的三参重载,第二个参数 initialize 默认是 true,也就是会走到初始化:
// Class.forName:一层套一层,最终走到 native 的 forName0public static Class<?> forName(String className) throws ClassNotFoundException { // 单参版本:initialize 传 true(会初始化),loader 取调用者的类加载器 return forName(className, true, VMStack.getCallingClassLoader());} public static Class<?> forName(String className, boolean initialize, ClassLoader loader) throws ClassNotFoundException { // loader 为 null 时,默认用 BootClassLoader(Android 的 bootstrap) if (loader == null) { loader = BootClassLoader.getInstance(); } // 调 native 完成真正的加载与初始化,初始化与否由 initialize 决定 return forName0(className, initialize, loader);}用一个例子验证:写一个带静态代码块的类,再用两个方法分别加载它:
// 一个带静态代码块的类static class A { static { System.out.println("A 的静态代码块执行了"); }} // 用 Class.forName 加载:会初始化,所以会打印「静态代码块执行了」Class.forName("com.enjoy.enjoyfix.ExampleUnitTest$A"); // 用 ClassLoader.loadClass 加载:只加载不初始化,什么都不会打印ClassLoader.getSystemClassLoader().loadClass("com.enjoy.enjoyfix.ExampleUnitTest$A");
所以用 ClassLoader.loadClass 加载的类,它的静态代码块可能根本没跑过。这个区别在很多框架解析类、做动态代理时都会踩到。
2. getField 和 getDeclaredField(getMethod 同理)
反射取属性 / 方法有两对 API,区别是可见范围:
getField/getMethod:只能拿到public的,包括从父类继承来的public成员。getDeclaredField/getDeclaredMethod:只能拿到当前类声明的成员,但不管它是private还是public都能拿,拿不到父类的。
那问题来了:pathList 是 BaseDexClassLoader 的私有字段,而我现在手里的是 PathClassLoader 的 Class 对象。getField 拿不到(它是私有),getDeclaredField 也拿不到(它不在 PathClassLoader 当前类、而在父类)。两边都不行,怎么办?
办法是先用 Class.getSuperclass() 拿到父类,再对父类用 getDeclaredField:
// 从 PathClassLoader 往上找,拿到父类 BaseDexClassLoaderClass<?> clazz = pathClassLoaderClass.getSuperclass();// 再到父类上用 getDeclaredField 拿私有字段 pathListField pathListField = clazz.getDeclaredField("pathList");逐级 getSuperclass 往上走,直到找到目标字段。这就是后面 SharedReflectUtils.findField 的本质——一个沿父类链向上查找的工具方法。这是本节课代码里最关键的一个通用件。
3. 反射慢吗?隐藏 API 能不能反射?
关于反射的性能,结论是:会慢,但少量反射不会成为瓶颈。它的开销来自几处:
- 反射一个方法,要动态解析这个类所有元数据,把目标方法的数据组装成一个
Method对象; - 通过
Method.invoke执行时,参数都是Object类型——你传一个int,它会自动装箱成Integer,真正执行到这个方法时又要拆箱回int,来回折腾; - 执行方法相当于隔了一层
Method中间者,不像是直接方法调用那么顺滑。
所以"反射影响性能"这话本身没错,但别什么都怪反射。在手机性能越来越好的现在,少量几次反射根本不会成为你的性能瓶颈;拖垮性能的往往是其他代码。
至于能不能反射 framework 内部的类,要看 Android 的隐藏 API 限制。Android 把"未公开的路由"分了三档:黑名单(完全禁止反射,反射就会崩)、灰名单 / 浅灰名单(可以反射,只是会打印 "Accessing hidden field/method" 的警告)。这一课的案例跑在 Android 9.0 模拟器上,makePathElements 就在灰名单里,所以能反射成功、能跑通,只是 Logcat 里会看到隐藏 API 的访问提示。
三、HotFix.installPatch实现

现在把上面这些拼成真正的代码。课堂里的实现分五步,也是白板上写的那条主线:
1. 获得当前应用的 PathClassLoader2. 反射拿到它身上(父类里)的 pathList(DexPathList 对象)3. 反射拿到 pathList 里的 dexElements 数组4. 用同一套 makePathElements 逻辑,把补丁 dex 也转成 Element 数组5. 新数组 = 补丁 Element 数组 + 原 dexElements 数组,反射写回 dexElements先写一个工具类 SharedReflectUtils,把"沿父类链找字段 / 找方法"封装起来。注意两点:找字段只传名字,找方法必须连参数类型一起传(因为方法有重载,光靠名字区分不了):
// 反射工具类:沿父类链 向上查找私有字段与私有方法public class SharedReflectUtils { // 从 instance 的类型出发,沿父类链查找名为 name 的字段 public static Field findField(Object instance, String name) throws NoSuchFieldException { Class<?> cls = instance.getClass(); // 一直往上找到 Object 为止,Object 之上是 null while (cls != null && cls != Object.class) { try { // 用 getDeclaredField 找当前类声明的字段(含私有) Field field = cls.getDeclaredField(name); // 设为可访问,私有字段才有反射读写能力 field.setAccessible(true); return field; } catch (NoSuchFieldException e) { // 当前类没有,就往父类找 cls = cls.getSuperclass(); } } throw new NoSuchFieldException(name + " not found in " + instance.getClass()); } // 从 instance 的类型出发,沿父类链查找签名匹配的方法 // 注意:paramTypes 是方法的精确参数类型,重载靠它区分 public static Method findMethod(Object instance, String name, Class<?>... paramTypes) throws NoSuchMethodException { Class<?> cls = instance.getClass(); while (cls != null && cls != Object.class) { try { Method method = cls.getDeclaredMethod(name, paramTypes); method.setAccessible(true); return method; } catch (NoSuchMethodException e) { cls = cls.getSuperclass(); } } throw new NoSuchMethodException(name + " not found in " + instance.getClass()); }}拿到工具类,HotFix.installPatch 就是这几个调用串起来:
// 运行期热修复:把补丁 dex 用反射插到 dexElements 最前面public class HotFix { public static void installPatch(Application application, File patch) { // 补丁包都不存在,后面的步骤就没意义了,直接跳过 if (patch == null || !patch.exists()) { return; } try { // 第一步:拿到当前应用的类加载器 // 用 application.getClassLoader(),而不要用 application.getClass().getClassLoader() // 后者若传进来的是 android.app.Application(而非自定义子类),拿到的是 BootClassLoader ClassLoader classLoader = application.getClassLoader(); // 第二步:反射拿 pathList(在父类 BaseDexClassLoader 里,private) // 沿父类链找到 BaseDexClassLoader 后,取它的 pathList 字段的反射 Field Field pathListField = SharedReflectUtils.findField(classLoader, "pathList"); // 从类加载器对象上,取出 DexPathList 实例 Object pathList = pathListField.get(classLoader); // 第三步-3.1:把补丁 dex 转成 Element 数组 // 反射调用 DexPathList 的 makePathElements(private static) // 参数顺序:List<File>(补丁文件集合)、File(优化目录)、List<IOException>(记录异常) Method makePathElements = SharedReflectUtils.findMethod(pathList, "makePathElements", List.class, File.class, List.class); // 补丁只有一份,用一个单元素集合装起来传给第一个参数 List<File> patches = new ArrayList<File>(); patches.add(patch); // 补丁声明里可能塞 IOException 的集合,执行时出现的异常会记到里面 List<IOException> suppressedExceptions = new ArrayList<IOException>(); // 因为 makePathElements 是静态方法,invoke 的第一个参数传 null,不要传 pathList // 第二个参数给 dex 优化的输出目录(odex 落盘位置),必须是程序私有、可读写的目录 Object[] patchElements = (Object[]) makePathElements.invoke( null, patches, patch.getParentFile(), suppressedExceptions); // 第三步-3.2:扩容出新数组 = 补丁数组 + 原 dexElements 数组 // 别忘了:dexElements 是 final 字段,反射赋值时不接受 Object[],必须与原数组同组件类型 Field dexElementsField = SharedReflectUtils.findField(pathList, "dexElements"); Object[] oldElements = (Object[]) dexElementsField.get(pathList); // 用 Array.newInstance 按原数组的组件类型创建新数组,避免类型不匹配 Object[] newElements = (Object[]) Array.newInstance( oldElements.getClass().getComponentType(), patchElements.length + oldElements.length); // 把补丁数组放到最前面 System.arraycopy(patchElements, 0, newElements, 0, patchElements.length); // 把原数组放到补丁后面 System.arraycopy(oldElements, 0, newElements, patchElements.length, oldElements.length); // 第三步-3.3:把新数组写回 dexElements 字段 dexElementsField.set(pathList, newElements); } catch (Exception e) { // 反射失败就忽略,不能让补丁把 App 搞崩 e.printStackTrace(); } }}四、代码分析
1. PathClassLoader 与 BaseDexClassLoader
PathClassLoader 是系统默认给应用用的加载器,它继承自 BaseDexClassLoader。关键在构造上——PathClassLoader 把 optimizedDirectory 传成了 null:
// PathClassLoader:优化目录恒为 null,由系统统一管理public class PathClassLoader extends BaseDexClassLoader { public PathClassLoader(String dexPath, ClassLoader parent) { super(dexPath, null, null, parent); }}而 BaseDexClassLoader 在构造时,用 dex 路径创建了一个 DexPathList,存在私有字段 pathList 里。这就是为什么我们要去反射 pathList,而且它不在 PathClassLoader、而在父类 BaseDexClassLoader:
// BaseDexClassLoader:持有 DexPathList 成员 pathListpublic class BaseDexClassLoader extends ClassLoader { private final DexPathList pathList; public BaseDexClassLoader(String dexPath, File optimizedDirectory, String librarySearchPath, ClassLoader parent) { super(parent); this.pathList = new DexPathList(this, dexPath, librarySearchPath, optimizedDirectory); }}2. DexPathList 的构造与 dexElements 赋值
DexPathList 构造时,把传给它的 dex 路径拆分成文件列表,再用 makePathElements 转成 Element 数组,赋给 dexElements:
// DexPathList 构造:把 dex 路径转成 Element 数组this.dexElements = makePathElements(splitDexPath(dexPath), optimizedDirectory, suppressedExceptions);注意它先调用了 splitDexPath——这就是为什么 dex 路径字符串可以用冒号分隔多个 dex,splitDexPath 会把字符串按 : 拆开,转成一个 List<File>:
// splitDexPath:把形如 /sdcard/a.dex:/sdcard/b.dex 的字符串拆成文件列表private static List<File> splitDexPath(String path) { // 按冒号分隔成多段,每段是一个 dex / apk 的地址 return splitPaths(path, null, false);}另外,DexPathList 构造时还会校验优化目录必须可读可写:
// 优化目录必须是程序私有、可读写的目录,否则直接抛异常if (!(optimizedDirectory.canRead() && optimizedDirectory.canWrite())) { throw new IllegalArgumentException( "optimizedDirectory not readable/writable: " + optimizedDirectory);}这就是为什么我们反射调 makePathElements 时,第二个参数不能随便传一个不存在的路径,而要传应用的私有目录(比如 patch.getParentFile() 或 application.getCacheDir()),否则会抛 IllegalArgumentException。
3. makePathElements 的签名
makePathElements 是 DexPathList 里的一个 private static 方法,参数是三个:文件列表、优化目录、异常列表。这就是我们反射调用时要精确匹配的签名:
// 把 dex / apk / jar 的列表转换成 Element 数组private static Element[] makePathElements(List<File> files, File optimizedDirectory,List<IOException> suppressedExceptions) { List<Element> elements = new ArrayList<Element>(); // 遍历传入的每个文件节点 for (File file : files) { File zip = null; DexFile dex = null; String name = file.getName(); if (file.isDirectory()) { // 目录节点:没有 dex 文件,只用于加载资源和 so elements.add(new Element(null, true, zip, dex)); } else if (file.isFile()) { if (name.endsWith(DEX_SUFFIX)) { // 裸 dex 文件:在这里被优化并加载成 DexFile dex = loadDexFile(file, optimizedDirectory); } else { // apk / jar:走 zip 分支,dex 在压缩包里 zip = file; } elements.add(new Element(null, false, zip, dex)); } } return elements.toArray(new Element[elements.size()]);}Element 是 DexPathList 的内部类,记录一个 dex / apk / jar 的源文件位置、是不是目录节点、以及真正装类的 DexFile。正因为它是 DexPathList 的私有内部类,你自己是拿不到 Element 这个类型的引用的,但是也不能"自己写一个 makePathElements",只能反射调系统已经写好的这个方法:
// DexPathList 的内部类 Element:包装一个 dex / apk / jarstatic final class Element { private final File dir; // 目录(或 zip 内的相对目录) private final boolean isDirectory; private final File zip; // apk / jar / zip 本体 private final DexFile dexFile; // 真正的 DexFile,热修复只关心它}五、最容易踩坑点:找方法和执行方法要求不一样
这节课讲的一个要点:反射"找方法"和"执行方法"对参数的要求不一样。
- 找方法(
getDeclaredMethod(name, paramTypes...)):参数类型必须精确匹配源代码里的签名。如果你写成List.class, File.class, List.class就去找,那只能匹配到那种签名的重载;你写成ArrayList.class去匹配,就找不到,因为源码里声明的是List。 - 执行方法(
Method.invoke(obj, args...)):obj和args是实际值,这时可以用子类。比如makePathElements声明要一个List<File>,你执行时传一个ArrayList完全没问题(多态)。
所以别把这两件事混在一起:声明签名决定怎么找,实际对象决定怎么执行。这也正是 findMethod 要带 paramTypes、而 findField 只带名字的原因。
另外一个容易忽略的点:makePathElements 是 static 方法。静态方法反射调用时,invoke 的第一个参数传 null 即可,不需要传实例对象。如果你把它当成实例方法,传了 pathList 进去,虽然也能跑(Java 允许),但语义上不正确,而且不同版本可能由此出问题。
六、问题TODO
如果按上面这套写出来、装包跑,你确实能看到"补丁类顶替了原类"的效果。但要真上生产,问题还挺多。
第一,版本适配。makePathElements 这个方法名和签名,在不同 Android 版本是不一样的历史:Android 5.x / 6.x 叫 makePathElements,Android 7.x 改叫 makeDexElements,Android 8.0+ 又改了签名、还多了一个 ClassLoader 参数(从 8.0 起 optimizedDirectory 直接被忽略了,odex 由系统统一管理)。你对着一个版本写死了,换一台机器就失效。所以要商用,得做成"按版本探测、按版本分发"的加载器。
第二,隐藏 API 限制。从 Android 9 开始,这些内部 API 大量落入灰名单、乃至黑名单。灰名单还能反射,只是打警告;黑名单直接崩。要稳定运行,还得额外处理 hidden API 绕过。这也是一个额外的工程点。
第三,反射本身的不确定性。过程里会有装箱拆箱、动态解析,虽然少量反射不是瓶颈,但作为一个会被反复调用的框架底层,还是需要考虑开销与稳定性。
第四,编译期没法自动出补丁。你不可能每次修 bug 都手动敲命令行把改动的类打成 dex。要自动化,就得在 Gradle 构建阶段自动比较旧代码和新代码、判定哪些类改了、把这些类打进一个补丁 dex,甚至自动下发到测试机。这是"运行期修复"之外的另一个大工程。
七、小结
把这一篇概括一下:热修复不是黑魔法,就是用反射把补丁 Element 插到 dexElements 最前面。它用的全是反射的普通能力——沿父类链找私有字段、按精确签名找私有静态方法、Array.newInstance 老实建数组。难点不在反射有多高级,而在于你懂不懂 DexPathList 的真实结构,以及找方法和执行方法参数要求不一样这个细节。
它留下的实践线索:
- 想亲手确认上面第一个区别,就把那个
Class.forName和loadClass的对比写成一个 JUnit 测试跑一遍,看输不输出静态代码块。 - 想验证热修复闭环,就照上一课的 demo 做:
Utils.test()里抛异常,MainActivity一启动就崩;把Utils改成不崩的版本,用dx --dex打成补丁 dex,在Application.onCreate(或attachBaseContext)里调用HotFix.installPatch,杀进程重新冷启动——如果这次不再崩,说明补丁类生效且成功顶替了原类。
本文由 AgentWorkFlow 生成