文章内容
本文整理自享学团队直播课《热修复(四):Android N 混编问题》。
前一篇把「类替换」这条链用反射实现了一遍:补丁 dex 转成一组 Element,插到 PathClassLoader 内部 DexPathList 的 dexElements 数组最前面,类加载按顺序先命中补丁,旧类被顶掉。这套做法在 Android 6.0 上跑通了。但到了 Android 7.0,它可能整个失效:不是代码写错,而是运行时的行为变了。
那么为什么到了 Android N,类替换热修复会失效,以及 Tinker 是怎么绕过它的?
Android N 把 ART 从「安装时全量 AOT 预编译」改成了「解释执行 + JIT + 空闲后台编译」的混合模式;系统会在设备空闲时把之前 JIT 记录下来的热点类编译成一个 AppImage 镜像,并在应用下一次启动、进程刚创建时就把它加载进系统 PathClassLoader。而类加载器对「已经加载过的类」是不会重新加载的,所以一个被编进 AppImage 的类,在你任何业务代码运行之前就进了类缓存,之后你把补丁插到 dexElements 最前面也救不了它。
Tinker 的解法:在运行时用一个自定义 ClassLoader 替换掉系统那个 PathClassLoader,并把原始的 dexElements 连同补丁一起塞进新加载器,让程序找类时彻底不走系统那个带 AppImage 缓存的加载器。
这条链的全局轮廓是这样的:
- Dalvik:Android 最初的虚拟机,逐条解释执行 dex 字节码。
- ART:Dalvik 的升级版(Android 5.0 起默认为运行时)。
- 安装时 AOT:Android 5.0 / 6.0 的 ART,装 App 时就把 dex 整体预编译成机器码。
- 混合编译(混编):Android N 起的 ART,装 App 时不再全量 AOT,而是解释 + JIT + 空闲后台编译。
- AppImage:混合编译在设备空闲时,把 JIT 记录的热点类编出来的镜像文件,应用启动时被加载。
- 系统 PathClassLoader:应用进程启动时由系统创建的类加载器,AppImage 里的类就装在它里面。
- 类缓存(loaded classes):类加载器内部记录「已加载类」的缓存,命中就不重载。这就是是混编让类替换失效的关键。
一、为什么说「已经加载过的类就没法修复」
类替换热修复成立的前提是:类加载器找类时,会顺序遍历 dexElements,先命中补丁、就返回补丁。前面也确认过 BaseDexClassLoader 的 loadClass 是先查缓存的:
// 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;}这句话是这一整篇的根:一个类只要被某个类加载器加载过,就会进它内部那个 loaded-classes 集合,之后再调用 loadClass 也是直接返回这个缓存对象。 补丁插到 dexElements 最前面只是改变了「还没加载的类」会被谁找到,改变不了「已经被加载」这个事实。
所以问题被简化成:如果一个类(比如 A)在你业务代码运行前,就被系统加载进了 PathClassLoader 的缓存,那你的补丁就永远到不了它。 Android N 的混编模式正好让类 A 在你补丁执行前就被加载了。
二、Android 5.0 / 6.0 的 ART:安装时就帮你把代码编好
Android 的运行时从 Dalvik 换成了 ART(Android 5.0 起默认),两者最本质的区别是要不要在运行前把字节码变成机器码。
- Dalvik 是解释执行:
dex字节码要经虚拟机逐条解释才能跑,多一层翻译,所以慢。 - ART 用 AOT(Ahead-Of-Time,预编译):装 App 的时候就把
dex整体翻译成机器码(oat),运行时直接执行机器码,不再解释。
这就是为什么 5.0 / 6.0 装应用明显比 4.4 慢:系统在安装阶段就把编译的活儿干完了。代价是安装慢、占存储(空间换时间),收益是运行时快。Android 5.0 / 6.0 的 ART 就是这条路。
Android 5.0 / 6.0 的 ART(安装时 AOT):应用安装 → ART 预编译 dex 成机器码(oat) → 运行时直接执行机器码,不解释 → 安装慢、运行快但全量 AOT 有个问题:不知道你到底会用哪些代码,干脆把 dex 里所有类都编一遍,白费了不少编译时间、也占了存储。Android N 想了想,决定改成「按需编译」。
三、Android N 的混合编译:解释 + JIT + 空闲后台编译
到了 Android N,ART 不再在安装时全量 AOT,而是进入混合模式(混合编译)。不再预编译全部,老老实实解释执行,但加了一步聪明的记录:
Android N 起的 ART(混合编译):应用安装 → 不预编译首次运行 → 仍解释执行 dex 字节码,但 JIT 会在运行时把「热点代码」(常用方法/类)编译成机器码 → 同时把热点代码的调用信息记录到 profile 文件设备空闲时(充电/休眠) → BackgroundDexOptService 按 profile 用 dex2oat 做全时段编译 → 把热点类编译成一个 AppImage 镜像(内部是 base.art)应用下一次启动 → 系统加载 AppImage,里面的类在业务代码运行前就进了 PathClassLoader这段流程里有两个东西是关键:profile 文件和 AppImage。
- JIT(Just-In-Time):运行时才编译。应用每次跑起来,把「哪几个方法最常被调用」记下来,记到 profile 文件里,这就是后面该优先编译的清单。
- AppImage:
dex2oat在空闲时把 profile 记录的热点类整体编译出来的镜像文件(base.art)。它的作用类似一个「预热缓存」:把最可能用到的类提前加载好,应用一启动就内存映射进来,启动更快。
系统给你做了一个「哪些类最热」的预判缓存,而这个缓存是在你业务代码跑起来之前就加载好的。

四、热修复方案失效
把「已加载类没法修复」和「混编会提前把类加载走」这两点接起来,问题就很清楚了。假设你的某个类 A 被判断为热点、编进了 AppImage。那么应用下一次启动、系统刚创建完进程、创建好 PathClassLoader 的时候,A 就已经被这个加载器加载进缓存了,这时候你任何一行业务代码都还没跑。
等你真正运行到热修复代码(比如在前面那篇的 Application.onCreate 里调 HotFix.installPatch),你反射把补丁插到 dexElements 最前面,看起来补丁类在前、旧类在后。但下一次有人 loadClass(\"A\") 时:
loadClass先调findLoadedClass(\"A\");A在系统PathClassLoader的缓存里,命中,直接返回旧的那个A;- 根本不会走到
findClass去遍历dexElements找补丁。
所以被编进 AppImage 的类,热修复对它是无效的。这不是反射代码的问题,而是「类已经被加载」这个事实决定的,而这正是 Android N 混编最麻烦的影响。
顺带说一下为什么「插个 dex 在 Android N 之前还能用」:Android N 之前是安装时全量 AOT,但你插进去的补丁 dex 并不在预编译的范围内,findClass 顺序遍历 dexElements 时还是先命中补丁,所以能生效。混编之后,被编进 AppImage 的类提前被加载走,这条「先到先得」的遍历就对那些类失效了。

五、Tinker 的解法:运行时替换整个 PathClassLoader
那怎么办?Tinker 的思路不是去修那个已加载的缓存,而是整个不用系统那个 PathClassLoader,自己创建一个 ClassLoader,把原始 dexElements 复制进去、再把补丁插到最前面,然后运行时把系统到处记着的 PathClassLoader 引用统统替换成这个新的。
1. 入口:SystemClassLoaderAdder.installDexes
Tinker 在安装补丁 dex 的入口里,对 Android N(SDK_INT >= 24)单独走了 NewClassLoaderInjector.inject 这条新路径,其它版本还是老一套 V23 / V19 / V14 / V4。
// SystemClassLoaderAdder.installDexes:按系统版本分发补丁安装策略private static void installDexes(Application application, ClassLoader loader, File dexoptDir, List<File> files) throws Throwable { // 先按版本/顺序把补丁 dex 排好序(补丁在前的策略在这里) files = createSortedAdditionalPathEntries(files); ClassLoader classLoader = loader; // 关键分支:Android N(>=24) 且不是受保护应用 → 走运行时替换 PathClassLoader if (Build.VERSION.SDK_INT >= 24 && !isProtectedApp) { // 让 NewClassLoaderInjector 自己造一个新加载器并注入到系统关键位置 classLoader = NewClassLoaderInjector.inject(application, loader, dexoptDir, files); } // 说明:dalvik 下如果内部类不与它的外层类同一个加载器,dex2opt 阶段会失败 // 下面按版本把 dex 安装进 classLoader,Android N 用这里的 classLoader(已是新的) if (Build.VERSION.SDK_INT >= 23) { V23.install(classLoader, files, dexoptDir); } else if (Build.VERSION.SDK_INT >= 19) { V19.install(classLoader, files, dexoptDir); } else if (Build.VERSION.SDK_INT >= 14) { V14.install(classLoader, files, dexoptDir); } else { V4.install(classLoader, files, dexoptDir); }}2. 核心动作:NewClassLoaderInjector.inject
inject 先构造好带补丁的新加载器 result,再把它交给 doInject 做替换动作:把当前线程的上下文加载器换成它,再把系统里几处指向 PathClassLoader 的引用全部反射替换成它。下面这步才是「替换掉系统那个带 AppImage 缓存的加载器」的实现:
// NewClassLoaderInjector.doInject:把新建的带补丁 ClassLoader 注入系统关键位置// 前面 inject() 已经用「原始 dexElements + 补丁 dex」构造好了新加载器 result,// 这一步就是把它塞进系统到处记着旧 PathClassLoader 的引用里private static void doInject(Application application, ClassLoader classLoader) throws Throwable { // 关键:把「当前线程的上下文加载器」换成我们新造的加载器 Thread.currentThread().setContextClassLoader(classLoader); // 找到真实的 Application 前缀(mBase,也就是它的 Context 包装对象) final Context baseContext = (Context) findField(application.getClass(), "mBase").get(application); // 从 baseContext 里拿到装载该应用信息的 PackageInfo(内部就是 LoadedAPK) final Object basePackageInfo = findField(baseContext.getClass(), "mPackageInfo").get(baseContext); // 把 PackageInfo 里记录的那个 mClassLoader 替换成新加载器 findField(basePackageInfo.getClass(), "mClassLoader").set(basePackageInfo, classLoader); // 资源对象里也可能记住旧加载器,Android 8.0 之前要一并替换,避免资源关联到旧加载器 if (Build.VERSION.SDK_INT < 27) { final Resources res = application.getResources(); try { // 把 Resources 持有的 mClassLoader 换掉 findField(res.getClass(), "mClassLoader").set(res, classLoader); // 布局填充器(LayoutInflater)内部也持有一个指向旧加载器的引用 final Object drawableInflater = findField(res.getClass(), "mDrawableInflater").get(res); if (drawableInflater != null) { // 换掉 DrawableInflater 里的 mClassLoader findField(drawableInflater.getClass(), "mClassLoader").set(drawableInflater, classLoader); } } catch (Throwable ignored) { // 资源这套替换失败不致命,忽略即可 } }}findField 就是前面那篇的「沿父类链找字段」工具,Tinker 自己也有一个:
// NewClassLoaderInjector.findField:沿父类链向上找私有字段,可访问化后返回private static Field findField(Class<?> clazz, String name) throws NoSuchFieldException { // 从当前类开始往上找 Class<?> currClazz = clazz; while (true) { try { // 当前类声明的字段(含私有)再设为可访问 final Field result = currClazz.getDeclaredField(name); result.setAccessible(true); return result; } catch (Throwable ignored) { // 没找到就继续往父类找,直到 Object } if (currClazz == Object.class) { // 找到 Object 都没有,说明字段不在这个继承链上 throw new NoSuchFieldException( "Cannot find field " + name + " in " + clazz.getName() + " and its super classes."); } currClazz = currClazz.getSuperclass(); }}把这几段串起来,Tinker 解决混编问题的完整逻辑是:
- 系统创建
PathClassLoader(AppImage 里那些类已经装在它的缓存里); - Tinker 用「原
PathClassLoader的dexElements+ 补丁 dex」构造一个新的ClassLoader,它手里有完整可找的类(原始 + 补丁),不依赖父加载器兜底; - 运行时把它注入:
setContextClassLoader+ 反射替换LoadedAPK(mClassLoader)、Resources(mClassLoader)、LayoutInflater(mClassLoader); - 之后程序找类不再走系统那个带 AppImage 缓存的
PathClassLoader,从而根本不会被提前加载的旧类卡住,补丁自然就生效了。
注意第 2 步的关键:新加载器必须自己携带完整的 dexElements(原始 + 补丁),而不是只设一个指向系统的父加载器。 这也是课上那个「设父加载器」方案不成立的原因:只设父加载器,查找仍会绕回系统加载器的缓存,拿到旧类。

六、上线问题TODO
大量的适配问题:
- Android 5.x / 6.x:叫
makePathElements,参数是文件列表、优化目录、异常列表; - Android 7.x:改叫
makeDexElements; - Android 8.0+:签名又变,还多了一个
ClassLoader参数,而且从 8.0 起optimizedDirectory直接被忽略(odex 由系统统一管理); - Android 9.0:内部类更是大量落入灰名单 / 黑名单,反射会受到隐藏 API 限制。
# 打补丁包:把修复后的单个 .class 用 dx 打成 dex(老版本 SDK 用 dx,新版本用 d8)# 输出到 sdcard/patch.jar,再上传到应用可读的目录,运行期热修复时反射读取dx --dex --output=sdcard/patch.jar com/join/hotfix/utils.class所以真正商用前一定要做「按版本探测、按版本分发」的适配:不同版本套不同的反射逻辑。这也是课后的第一个作业:去对比不同 Android 版本源码里 makePathElements / makeInMemoryDexElement 的签名差异(比如 9.0 走的是 makeInMemoryDexElement),把 4.4 以下、4.4~6.0、6.0 以上各挑一个版本看一遍就够,因为这三种签名已经覆盖了主要跨度。
七、小结
把这一篇的结论浓缩成一句:Android N 混编让「类替换」热修复失效,根源不是反射写错了,而是被编进 AppImage 的热点类在进程启动时就被系统 PathClassLoader 加载进缓存,类加载器对已加载类不重载,补丁插到 dexElements 最前面也改变不了这个事实;Tinker 的解法是不再用系统那个 PathClassLoader,而是运行时新建一个携带「原始 dexElements + 补丁」的 ClassLoader,把它注入到 contextClassLoader、LoadedAPK、Resources、LayoutInflater 等所有指向旧加载器的地方。
本文由 AgentWorkFlow 生成