Android 热修复(四):安卓 N 混编为什么让「类替换」失效

文章内容

本文整理自享学团队直播课《热修复(四):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 是先查缓存的:

java
// 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 就是这条路。

plain text
Android 5.0 / 6.0 的 ART(安装时 AOT):应用安装 → ART 预编译 dex 成机器码(oat) → 运行时直接执行机器码,不解释 → 安装慢、运行快

但全量 AOT 有个问题:不知道你到底会用哪些代码,干脆把 dex 里所有类都编一遍,白费了不少编译时间、也占了存储。Android N 想了想,决定改成「按需编译」。

三、Android N 的混合编译:解释 + JIT + 空闲后台编译

到了 Android N,ART 不再在安装时全量 AOT,而是进入混合模式(混合编译)。不再预编译全部,老老实实解释执行,但加了一步聪明的记录:

plain text
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)。它的作用类似一个「预热缓存」:把最可能用到的类提前加载好,应用一启动就内存映射进来,启动更快。

系统给你做了一个「哪些类最热」的预判缓存,而这个缓存是在你业务代码跑起来之前就加载好的。

Android N 混合编译流程:安装→解释+JIT→空闲dex2oat→AppImage→启动加载

四、热修复方案失效

把「已加载类没法修复」和「混编会提前把类加载走」这两点接起来,问题就很清楚了。假设你的某个类 A 被判断为热点、编进了 AppImage。那么应用下一次启动、系统刚创建完进程、创建好 PathClassLoader 的时候,A 就已经被这个加载器加载进缓存了,这时候你任何一行业务代码都还没跑。

等你真正运行到热修复代码(比如在前面那篇的 Application.onCreate 里调 HotFix.installPatch),你反射把补丁插到 dexElements 最前面,看起来补丁类在前、旧类在后。但下一次有人 loadClass(\"A\") 时:

  1. loadClass 先调 findLoadedClass(\"A\");
  2. A 在系统 PathClassLoader 的缓存里,命中,直接返回旧的那个 A;
  3. 根本不会走到 findClass 去遍历 dexElements 找补丁。

所以被编进 AppImage 的类,热修复对它是无效的。这不是反射代码的问题,而是「类已经被加载」这个事实决定的,而这正是 Android N 混编最麻烦的影响。

顺带说一下为什么「插个 dex 在 Android N 之前还能用」:Android N 之前是安装时全量 AOT,但你插进去的补丁 dex 并不在预编译的范围内,findClass 顺序遍历 dexElements 时还是先命中补丁,所以能生效。混编之后,被编进 AppImage 的类提前被加载走,这条「先到先得」的遍历就对那些类失效了。

混编导致热修复失效:被编进 AppImage 的类已进 PathClassLoader 缓存

五、Tinker 的解法:运行时替换整个 PathClassLoader

那怎么办?Tinker 的思路不是去修那个已加载的缓存,而是整个不用系统那个 PathClassLoader,自己创建一个 ClassLoader,把原始 dexElements 复制进去、再把补丁插到最前面,然后运行时把系统到处记着的 PathClassLoader 引用统统替换成这个新的。

1. 入口:SystemClassLoaderAdder.installDexes

Tinker 在安装补丁 dex 的入口里,对 Android N(SDK_INT >= 24)单独走了 NewClassLoaderInjector.inject 这条新路径,其它版本还是老一套 V23 / V19 / V14 / V4。

java
// 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 缓存的加载器」的实现:

java
// 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 自己也有一个:

java
// 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 解决混编问题的完整逻辑是:

  1. 系统创建 PathClassLoader(AppImage 里那些类已经装在它的缓存里);
  2. Tinker 用「原 PathClassLoader 的 dexElements + 补丁 dex」构造一个新的 ClassLoader,它手里有完整可找的类(原始 + 补丁),不依赖父加载器兜底;
  3. 运行时把它注入:setContextClassLoader + 反射替换 LoadedAPK(mClassLoader)、Resources(mClassLoader)、LayoutInflater(mClassLoader);
  4. 之后程序找类不再走系统那个带 AppImage 缓存的 PathClassLoader,从而根本不会被提前加载的旧类卡住,补丁自然就生效了。

注意第 2 步的关键:新加载器必须自己携带完整的 dexElements(原始 + 补丁),而不是只设一个指向系统的父加载器。 这也是课上那个「设父加载器」方案不成立的原因:只设父加载器,查找仍会绕回系统加载器的缓存,拿到旧类。

Tinker 运行时替换 PathClassLoader:自定义 ClassLoader 注入系统关键位置

六、上线问题TODO

大量的适配问题:

  • Android 5.x / 6.x:叫 makePathElements,参数是文件列表、优化目录、异常列表;
  • Android 7.x:改叫 makeDexElements;
  • Android 8.0+:签名又变,还多了一个 ClassLoader 参数,而且从 8.0 起 optimizedDirectory 直接被忽略(odex 由系统统一管理);
  • Android 9.0:内部类更是大量落入灰名单 / 黑名单,反射会受到隐藏 API 限制。
bash
# 打补丁包:把修复后的单个 .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 生成

觉得不错?给作者点个赞吧

分类:Android系统

评论 (0)

无需注册,留言经站长审核后公开。

加载评论中…

Android 热修复(四):安卓 N 混编为什么让「类替换」失效 · Yinchen 的博客