文章内容
本文整理自享学团队直播课《热修复(二):dex 补丁热修复原理》。
上一篇我们把这四种主流的方案(QZone、Tinker、AndFix、Robust)的原理讲了一遍。那一篇的核心差异记住这一个就可以了:AndFix 走方法替换(native 层改 ArtMethod),Robust 走字节码插桩,QZone 和 Tinker 走类替换。
类加载方案的核心动作只有一件事:
把补丁 dex 用反射转成一组 Element,插到 PathClassLoader 内部 DexPathList 维护的 dexElements 数组的最前面。 类加载是按数组顺序挨个找类、先找到先用的,补丁插在最前面,所以补丁类永远先被命中,原类等于被覆盖了。
前提条件:这个替换发生在类第一次被加载之前。loadClass 的第一步就是查缓存 findLoadedClass,一个类一旦被当前加载器加载过,就进了缓存,之后无论你怎么改 dexElements 都换不掉它。所以补丁要在下次冷启动、目标业务类还没被加载时注入(工程上就是放在 Application.attachBaseContext 里)。它不需要用户重装、也不需要弹更新,但改完不会立刻生效,得等下一次冷启动。这一点和上一篇对 QZone 标「冷启生效」、对 Tinker 标「重启生效」相符合。
要理解上面那句话,得先把三件事讲清楚:
- Android 的 ClassLoader 长什么样
- loadClass 是按什么顺序找类的
- 一个 dex 文件是怎么变成一个 Element

一、Android 的 ClassLoader 家族
ClassLoader 在 Java 里是一个抽象类,不能直接实例化,用的一定是它的子类。Android 里最常用的直接子类有两个:BootClassLoader 和 BaseDexClassLoader。其中 BaseDexClassLoader 本身也不常用,真正干活的是它下面两个子类 PathClassLoader 和 DexClassLoader(Android 8.0 还加了 InMemoryClassLoader)。
BootClassLoader 加载 Android Framework 的类:Activity、Service、String 等BaseDexClassLoader 抽象父类,本身不直接使用 ├── PathClassLoader 加载我们自己写的类:MyActivity、MyApplication、Utils 等 └── DexClassLoader 显式 new 出来的加载器,加载 SD 卡 / APK / dex- BootClassLoader 加载的是 Framework 层的类。Activity、Service、String 这些不是我们写的,是 App 跑起来时系统帮你加载的,归 BootClassLoader。
- PathClassLoader 加载的是我们自己写的类。一个安卓应用怎么启动:Zygote 进程 fork 出应用进程,fork 之后执行 ZygoteInit 做初始化,初始化时创建 PathClassLoader。所以应用里的
Utils、MainActivity这些类都归它。 - DexClassLoader 在正常程序运行阶段其实并不存在,除非你主动 new 一个。它专门用来从任意路径加载 dex / apk / jar,补丁场景就是它。
Java 里每个类都是 Class 对象,Class 里有个成员 classLoader 记录了「这个类是被谁加载的」。打印MainActivity.class.getClassLoader(),输出的是 PathClassLoader;打印 Activity.class.getClassLoader(),输出的是 BootClassLoader。
这里有个常见误区:网上很多博客说 PathClassLoader 和 DexClassLoader 有本质区别,比如「一个能加载 SD 卡、一个不能」。其实没有区别。两个类除了构造参数不同,功能完全一样,都能加载 SD 卡下的 apk / dex。唯一的机械差异在构造上——PathClassLoader 把 optimizedDirectory 传成 null,DexClassLoader 才真正传优化目录。
// PathClassLoader:优化目录恒为 nullpublic class PathClassLoader extends BaseDexClassLoader { public PathClassLoader(String dexPath, ClassLoader parent) { super(dexPath, null, null, parent); }}二、LoadClass 双亲委派机制
类不是凭空出现的。我们用 new 一个对象时,代码里没有显式调用 loadClass,是因为系统或框架已经把类加载好了。但所有类最终都经过 ClassLoader.loadClass(String name)。主动加载时你传一个类的全限定名进去。
loadClass 的骨架(Android libcore 版,注意和 OpenJDK 不一样,没有 getClassLoadingLock、也没有 findBootstrapClassOrNull):
// Android libcore:java/lang/ClassLoader.java 的 loadClassprotected Class<?> loadClass(String className, boolean resolve) throws ClassNotFoundException { // 1. 先查缓存:这个类加载过没有 Class<?> clazz = findLoadedClass(className); if (clazz == null) { ClassNotFoundException suppressed = null; try { // 2. 交给父加载器找(双亲委派)——注意 Android 这里不判 parent 是否为空 clazz = parent.loadClass(className, false); } catch (ClassNotFoundException e) { suppressed = e; } if (clazz == null) { try { // 3. 父加载器也找不到,才轮到自己用 findClass 找 clazz = findClass(className); } catch (ClassNotFoundException e) { e.addSuppressed(suppressed); throw e; } } } return clazz;}
注意 parent,它是 ClassLoader 类里的一个成员,类型也是 ClassLoader。ClassLoader 里面又挂着一个 ClassLoader,这层结构可以看成组合、代理或责任链,具体是哪一种设计模式,要看它实际承担的功能。
关键在「顺序」:每一次 loadClass,都是先从自己的缓存 findLoadedClass 找,缓存没有,先往上抛给 parent.loadClass,一直抛到最顶层的 BootClassLoader,层层都找不到,才落回自己,用 findClass 去真正加载。这个「父先于子」的顺序,正是后面热修复能「用新类骗过旧类」的技术原理(先加载先生效,后加载优先读取缓存)。
三、类查找的真正执行层:DexPathList
上一节的 loadClass 只是顶层的调度,它调用 findClass。在 Android 里,BaseDexClassLoader 覆写了 findClass,真正的查找交给它的成员 pathList(DexPathList 对象)。看一下 BaseDexClassLoader:
// 加载所有 dex / apk / jar 的基类public class BaseDexClassLoader extends ClassLoader { // 关键成员:DexPathList 对象,里面装着 dexElements 数组 private final DexPathList pathList; // 构造方法:把 dex 路径和优化目录一起传给 DexPathList public BaseDexClassLoader(String dexPath, File optimizedDirectory, String librarySearchPath, ClassLoader parent) { super(parent); // 用 dex 路径创建一个 DexPathList this.pathList = new DexPathList(this, dexPath, librarySearchPath, optimizedDirectory); } // 覆写 findClass,真正的找类动作 @Override protected Class<?> findClass(String name) throws ClassNotFoundException { List<Throwable> suppressedExceptions = new ArrayList<Throwable>(); // 找类交给 pathList,最终是遍历 dexElements 数组 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); for (Throwable t : suppressedExceptions) { cnfe.addSuppressed(t); } throw cnfe; } return c; }}那 pathList.findClass 具体怎么找?它遍历 dexElements 数组,每个 Element 里放一个 DexFile,用 DexFile 加载。
// DexPathList.findClass:从 dexElements 数组里找类public Class findClass(String name, List<Throwable> suppressed) { // 就是这个 for 循环决定了「先找到先用」 for (Element element : dexElements) { DexFile dex = element.dexFile; if (dex != null) { Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed); if (clazz != null) { // 命中即返回,后面的 Element 根本不看 return clazz; } } } return null;}这个方法的 javadoc 原文写得很清楚:"This will find the one in the earliest listed path element."——AOSP 自己都说了,「最早列出的 Element 先被找到」。这正是热修复把补丁插在最前排的依据。
再往下一层,DexFile 真正加载时会进入 C++ 层做 IO:把 dex 文件解析出来(dex 不是直接用的,要先优化成 odex;ART 下这里其实会被 AOT 编译进 odex/oat 里的本地机器码;Android 8.0 又会先抽成 vdex 再生成 odex),解析出来的 class 数据缓存起来,再按类名找对应的 class。
四、一个 dex 怎么变成一个 Element
上面反复提到 Element,它具体是什么?在 DexPathList 里,Element 是一个内部类,它记录一个 dex / apk / jar 的源文件位置、是不是目录节点、以及真正装类的 DexFile。真实字段是 dir、isDirectory、zip、dexFile:
// dex / apk / jar 的包装类:DexPathList 的内部类static final class Element { // 目录(或 zip 内的相对目录) private final File dir; // 是不是目录节点 private final boolean isDirectory; // apk / jar / zip 本体 private final File zip; // 真正装类的 dex(热修复只关心这个字段) private final DexFile dexFile; // 构造方法:记录源文件、是否目录和 dex 文件 Element(File dir, boolean isDirectory, File zip, DexFile dexFile) { this.dir = dir; this.isDirectory = isDirectory; this.zip = zip; this.dexFile = dexFile; }}一个 dex 文件就对应一个 Element。Element 数组是怎么生成的?看 DexPathList.makePathElements,注意它是 private static,参数里有一个 optimizedDirectory:
// 把 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; File dir = new File(""); DexFile dex = null; String path = file.getPath(); String name = file.getName(); if (file.isDirectory()) { // 目录节点:只用于找资源和 so,没有 dexFile elements.add(new Element(file, true, null, null)); } else if (file.isFile()) { if (name.endsWith(DEX_SUFFIX)) { // 裸 dex 文件:在这里被优化并加载成 DexFile(优化目录只用在这里) try { dex = loadDexFile(file, optimizedDirectory); } catch (IOException ex) { System.logE("Unable to load dex file: " + file, ex); } } else { // apk / jar:走 zip 分支,dex 在压缩包里 zip = file; } } ... } // 返回 Element 数组 return elements.toArray(new Element[elements.size()]);}这里有两件事要注意。
第一,优化目录不会被存进 Element。optimizedDirectory 只是传给 loadDexFile(file, optimizedDirectory),用来决定 dex 优化产物(odex)落盘的位置;Element 里存的是优化后的 DexFile,不是那个目录。而且这个目录必须是程序私有、可读写的目录——源码里做了校验:
// DexPathList 构造函数里,优化目录必须可读写if (!(optimizedDirectory.canRead() && optimizedDirectory.canWrite())) { throw new IllegalArgumentException( "optimizedDirectory not readable/writable: " + optimizedDirectory);}第二,dexElements 数组是在 DexPathList 的构造函数里赋值的,补丁 dex 走的就是同一条路:
// DexPathList 构造函数中,把 dex 路径转成 Element 数组this.dexElements = makePathElements(splitDexPath(dexPath), optimizedDirectory, suppressedExceptions);所以「补丁 dex 怎么变成 Element 数组」答案就是:让补丁 dex 走和普通 dex 完全相同的 makePathElements(它是 private static,但可以反射调用),就能得到一组补丁 Element。
那这个补丁 dex 怎么来的?可以在终端执行这条命令,把单个 class 打成补丁 dex:
# 在编译产物目录下,把单个 .class 打成补丁 dex(旧版 SDK 用 dx,新版本是 d8)cd app/build/intermediates/classes/debugdx --dex --output=a.dex com/enjoy/enjoyfix/Utils.class
五、热修复的核心:把补丁 Element 插到最前面
到这里,「一个 dex 等于一个 Element、Element 数组由 PathClassLoader 的 pathList.dexElements 持有」已经清楚了。热修复要做的,就是把补丁 Element 数组和原来的 dexElements 数组合并,然后把新数组反射写回 dexElements 字段。整个流程分为五步:
- 获取当前应用的 ClassLoader。
- 反射拿到它父类 BaseDexClassLoader 的 pathList(DexPathList 对象)。
- 反射拿到 pathList 里的 dexElements 数组。
- 用同一套 makePathElements 逻辑(反射)把补丁 dex 也转成 Element 数组。
- 新数组 = 补丁 Element 数组 + 原 dexElements 数组,反射赋值回 dexElements 字段。
// 完整的热修复反射实现骨架public static void injectPatch(Context context, File patchFile) throws Exception { // 1. 当前应用的类加载器(不要强转成 PathClassLoader,厂商 ROM 中间可能套了一层) ClassLoader classLoader = context.getClassLoader(); // 2. 反射拿 BaseDexClassLoader.pathList(沿父类链找) Field pathListField = findField(classLoader, "pathList"); Object pathList = pathListField.get(classLoader); // 3. 反射拿 DexPathList.dexElements(它是 private final) Field dexElementsField = findField(pathList, "dexElements"); Object[] oldElements = (Object[]) dexElementsField.get(pathList); // 4. 用同一套 makePathElements 逻辑,把补丁 dex 也转成 Element 数组 File optDir = context.getDir("odex", Context.MODE_PRIVATE); Object[] patchElements = makePatchElements(pathList, patchFile, optDir); // 5. 合并:补丁在前,原数组在后,再写回去 // 关键:数组类型必须是 Element[],不能是 Object[](反射赋值不做协变) 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); dexElementsField.set(pathList, newElements);} // 沿父类链找字段,找到就 setAccessibleprivate static Field findField(Object instance, String name) throws NoSuchFieldException { for (Class<?> clazz = instance.getClass(); clazz != null; clazz = clazz.getSuperclass()) { try { Field field = clazz.getDeclaredField(name); field.setAccessible(true); return field; } catch (NoSuchFieldException ignored) { // 继续往父类找 } } throw new NoSuchFieldException(name + " not found in " + instance.getClass());}
第 4 步的 makePatchElements,就是「反射调 makePathElements」。
// makePathElements / makeDexElements 在不同版本的名字和签名都不同,逐个探测private static Object[] makePatchElements(Object pathList, File patchFile, File optDir) throws Exception { List<File> files = Collections.singletonList(patchFile); List<IOException> suppressed = new ArrayList<IOException>(); Class<?> cls = pathList.getClass(); // Android 5.x / 6.x(视频里的版本):private static // Element[] makePathElements(List<File>, File, List<IOException>) try { Method m = cls.getDeclaredMethod("makePathElements", List.class, File.class, List.class); m.setAccessible(true); // static,传 null return (Object[]) m.invoke(null, files, optDir, suppressed); } catch (NoSuchMethodException ignored) { // 继续探测下一个版本 } // Android 7.x:makeDexElements(List<File>, File, List<IOException>) try { Method m = cls.getDeclaredMethod("makeDexElements", List.class, File.class, List.class); m.setAccessible(true); return (Object[]) m.invoke(null, files, optDir, suppressed); } catch (NoSuchMethodException ignored) { // 继续探测下一个版本 } // Android 8.0+:makeDexElements(List<File>, File, List<IOException>, ClassLoader) // (optimizedDirectory 已被忽略,odex 由系统统一管理) Method m = cls.getDeclaredMethod("makeDexElements", List.class, File.class, List.class, ClassLoader.class); m.setAccessible(true); return (Object[]) m.invoke(null, files, optDir, suppressed, pathList.getClass().getClassLoader());}makePathElements 是 private static,所以 invoke 的第一个参数传 null 才语义正确。补充一句:Android 9 起这些内部 API 落入了灰名单/黑名单,需要额外过一道 hidden API 限制,这是另一个要单独处理的点。
另一个常识要纠正:很多人以为 final 修饰的字段不能反射改。其实可以,AOSP 里就专门记了一笔。dexElements 字段的定义是这样的:
/** * List of dex/resource (class path) elements. * Should be called pathElements, but the Facebook app uses reflection * to modify 'dexElements' (http://b/7726934). */private final Element[] dexElements;注意到没,连 AOSP 的注释都写明了「Facebook 的 App 就是靠反射改 dexElements」。所以「final 拦不住反射」,setAccessible(true) 之后照样能写。真正会被编译期常量折叠、改了也没用的,只有 static final 的基本类型和字符串常量;实例字段不在此列。
注入时机也很关键。因为 loadClass 第一步就是 findLoadedClass 查缓存,补丁必须在业务类第一次被加载之前打进去,所以放在 Application.attachBaseContext 里:
// 在最早期注入补丁,赶在所有业务类加载之前public class MyApplication extends Application { @Override protected void attachBaseContext(Context base) { super.attachBaseContext(base); File patch = new File(base.getDir("patch", MODE_PRIVATE), "patch.dex"); try { // 注入失败就走原逻辑,不能让补丁把 App 搞崩 injectPatch(base, patch); } catch (Exception e) { e.printStackTrace(); } }}六、插入,而不是覆盖
有同学会问:补丁数组和原数组里都有同名类(比如都有 Key 类),怎么处理?
不用处理。因为类加载是按 dexElements 数组顺序挨个找的。它从前面的 Element 里找到了这个类,直接返回,根本不会去看后面的 Element——上一节那段 DexPathList.findClass 的 for 循环和它的 javadoc("the one in the earliest listed path element")就是依据。补丁 Element 被插在数组最前面,所以同名类一定先被补丁命中,原来的类反而永远轮不到。这正是「插入」而不是「覆盖」的原因——不需要改动原来的数组元素,只需要改变查找顺序。
七、这只能跑通 demo,还不能商用
视频最后反复强调:按今天讲的原理去实现,你能看到效果,但不能商用。原因有两类。
第一类是运行期的版本适配。makePathElements 的签名在不同 Android 版本一直在变(第一节类图里写成 makeXXXElements() 就是明示),dex 在 Dalvik 下优化成 odex、在 ART 下被 AOT 编译、Android 8.0 又要先转 vdex;不同版本的 ClassLoader、Element 字段结构也不一样。你适配了一个版本,换一个版本可能就失效——所以要用上面那种按版本探测的分发方式。
第二类是编译期的问题——光有运行期反射不够,你还得能自动生成补丁。你不可能每次修 bug 都手动敲 dx --dex 命令,把改动的类打包成 dex。要自动化,就要在编译期(Gradle 阶段)自动判定哪些类发生了修改,把这些类打进补丁包,甚至能自动把补丁发给测试。这些是「运行期修复」之外的另一个大工程。
视频里还点名了几个会让类加载方案失效的具体坑,名字要记准:
- Android N 混编:7.0 起 AOT + JIT + 解释执行混合编译,dex/oat 的生成时机变了,补丁可能不生效- 方法内联:调用点被直接替换成方法体,换掉了类,运行的仍是内联进去的旧代码八、小结
把这一篇的核心概括一下:类加载热修复,核心是改变查找顺序,而不是改变类本身。PathClassLoader 的 pathList.dexElements 数组,是类查找的顺序列表;你用反射把补丁 Element 放在它前面,类加载就先命中补丁类,旧类自然被「顶掉」。整个过程只有反射,不需要重新打包 APK,但需要一次冷启动,让补丁在目标类加载前注入。
本文由 AgentWorkFlow 生成