文章内容
本文整理自享学团队直播课《Android 插件化(一):类加载》。
插件化的第一件事:**让宿主把自己没装过的「插件类」加载进来。**拆开看,底层动作就三步:用一个 DexClassLoader 去加载插件 APK,反射拿到它内部的 dexElements,再合并进宿主自己的 dexElements。这三步做完,宿主 loadClass 一个插件类,和加载自己的类没有区别。
结论就上面三句,但直接看反射代码,你大概不明白为什么非得这么绕。往下按顺序走:先搞清楚插件化要解决什么,再看类加载的流程、ClassLoader 的结构、双亲委派的机制,最后落到合并 dexElements 的反射代码上。
一、插件化是什么:把主板和外设想清楚
插件化这个概念,硬件早就玩明白了。你拆开一台台式机主板,上面有 CPU 插槽、内存条接口、显卡接口、USB 鼠标键盘口。鼠标坏了,你拔下来换一个,主板不用动。这就是插件化:主板是「宿主」,鼠标键盘显示器这些是「插件」,可以随时插拔。

软件的插件化磨蹭到 2015、2016 年才火起来,卡住它的是维护成本。硬件里一个鼠标坏了,你总不能扛着整台电脑去修;所以硬件很早就是「主板 + 可换件」的结构。软件早年 APK 小、模块少,更新一次整体重发也没多大事,一直没被逼着做插件化。等到大厂业务多起来,淘宝要往闲鱼、飞猪导流,总不能为了导个流就在淘宝里再开发一个闲鱼:包体越来越大、模块耦合越来越高、方法数逼近 65535 上限、占用内存过多,这才把插件化提上日程。
插件化的好处:包体积小、模块解耦、按需下载,甚至能免安装启动别的 APK。
它和「组件化」的区别主要在上线方式:
- 组件化:开发时是好几个独立模块,上线时合并成一个 APK;
- 插件化:开发时也是多个模块,上线时分成宿主 APK + 插件 APK,分开上线(你甚至可以只上宿主,插件不上)。
拿主板打比方:组件化等于厂家把主板和鼠标焊在一起再出厂,坏了只能整机修;插件化是主板、鼠标各卖各的,你想要哪个模块就装哪个,体积自然也小了。
那实现插件化到底要解决哪三件事?课堂上老师问了一圈,归纳下来就三步:
- 加载插件的类;
- 启动插件的四大组件;
- 加载插件的资源。
这三节课就分别解决这三步。这一讲只讲第一步:类怎么加载。
题外话:现在主流的插件化框架(DynamicLoadApk、VirtualAPK、RePlugin 等)底层思路大同小异。
二、类是怎么被加载的:生命周期和 Class 对象
先把「一个类从字节码变成能用的东西」这条流程列出来。类的生命周期是:
加载(loading) → 连接(验证/准备/解析) → 初始化 → 使用 → 卸载这一步的重点是「加载」。加载阶段干了三件事:
- 通过类名拿到二进制的字节流(对 Android 来说就是对应的一份
class.dex或.class文件); - 把这个字节内容加载进内存;
- 在内存里创建一个
Class对象。
类里有什么?方法、字段、构造方法,这些元数据全都挂在 Class 对象上。你用反射时,先 Class.forName(...) 或 xxx.getClass() 拿到 Class 对象,再通过它拿到 Field(属性)、Method(方法)、Constructor(构造),然后调用/设置。这就跟操作一个普通对象一样。
所以有个很关键的因果:**类没被加载,就没有 Class 对象;没有 Class 对象,反射就无从谈起。**这也是为什么「想反射一个类,得先保证它被加载了」。
顺便提一下反射和动态代理的关系(动态代理正是靠反射在运行期拿到方法再统一处理)。反射是很多框架的底座,热修复、插件化、动态代理都踩在它上面。这块不是本讲重点,心里有个大方向就行。
三、Android 的 ClassLoader 体系
类是由加载器加载的。Android 的加载器继承关系是这样:

java.lang.ClassLoader:所有加载器的鼻祖;dalvik.system.BaseDexClassLoader:Android 真正干活的基类,继承ClassLoader;dalvik.system.PathClassLoader:继承BaseDexClassLoader;dalvik.system.DexClassLoader:继承BaseDexClassLoader;BootClassLoader是 Android 类加载器体系里最顶层的一个(也是个ClassLoader),注意它跟BaseDexClassLoader是平级的,不是父子。
网上流传一个说法:「DexClassLoader 能加载未安装的 APK,PathClassLoader 只能加载系统里已安装的」。这个说法是错的。这两个类除了构造方法里第二个参数(优化目录 optimizedDirectory)不同,行为完全一样:它们都继承 BaseDexClassLoader,都靠内部的 DexPathList 去 findClass。8.0(API 26)开始,那个优化目录参数直接被标记为废弃了,连用都不用。所以你可以认为它俩没有任何区别。
那谷歌当初干嘛搞两个?说出来有点好笑:纯粹为了「以后可能要分开维护」。他们本意是 PathClassLoader 给自己(系统)用,DexClassLoader 给应用开发者用,万一以后要加各自的功能,分开改省事。结果沿用至今,两个类的实现完全一致,谁也没扩展出什么特有功能。所以写项目时你只要记住:PathClassLoader 是安卓用来加载应用默认 dex 的,DexClassLoader 是你要动态加载外部 dex/APK 时用的,两个随便用都行。
谁加载什么类
这里有个容易搞反的地方。加载职责不是按「是不是自己写的」来分的,而是按「属于 SDK 还是属于应用」来分:
BootClassLoader加载的是 SDK 类:android.*的东西,比如Activity、String这种系统里本来就有的类;PathClassLoader加载的是应用类:你自己写的、以及你依赖的所有第三方库,都算应用类。
很多人以为 AppCompatActivity(AndroidX)是「系统类」,因为它也是谷歌推的。其实它算应用类:你把 AndroidX 当依赖引进来,它跟 Glide、OkHttp 一样都打进了你的 dex,由 PathClassLoader 加载。只有 android.* 那些才归 BootClassLoader。
继承 ≠ parent
还有一个最容易混淆的点:继承关系(extends)和 parent(父加载器)是两回事。
- 继承:
PathClassLoader extends BaseDexClassLoader; - parent:
PathClassLoader的 parent 是BootClassLoader。
这两个是不同的维度。一个类的加载器,它的 parent 指的是「加载请求会先委托给谁」,跟它继不继承别的类没有关系。
最直接的办法是打印出来看:在 App 里打印当前类和 Activity 的 classloader,得到的链是 PathClassLoader → getParent() 拿到 BootClassLoader → 再往上就是 null,BootClassLoader 自己到顶。
四、双亲委派:加载之前,先往上问一圈
最关键的一环在 loadClass:类加载器是怎么靠它找到一个类的?看 java.lang.ClassLoader.loadClass 的源码:
// java.lang.ClassLoader.loadClass:双亲委派的核心就在这protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // 1. 先看自己有没有加载过这个类,加载过就直接返回 Class<?> c = findLoadedClass(name); if (c == null) { // 2. 自己没有,就先把请求委托给 parent(双亲委派) if (parent != null) { c = parent.loadClass(name, false); } } // 3. parent 也没找到,才轮到自己去真正加载 if (c == null) { c = findClass(name); } return c; }}这段是通用的,DexClassLoader、PathClassLoader 都没有重写它,用的都是同一份。关键细节:loadClass 先往上找,找不到才轮到自己往下加载。
拿宿主的场景走一遍。假设你新建了一个 DexClassLoader,parent 传的是宿主的 PathClassLoader,那么 loadClass("A") 会这样走完一条链:
dexClassLoader.loadClass("A")进来,先findLoadedClass("A"),自己没有;- 委托给 parent,也就是
pathClassLoader.loadClass("A"); PathClassLoader同样先findLoadedClass,没有,再委托给它的 parent,也就是BootClassLoader;BootClassLoader到顶了,它的loadClass是重写过的:findLoadedClass没有就直接走自己的findClass去真加载(native 层),加载完不管成功与否都直接 return,不再往上委托;- 回到
PathClassLoader,发现BootClassLoader返回的是空,那就轮到自己findClass去加载; - 回到
DexClassLoader,如果 parent 一直没找到,最后还是自己findClass兜底。

这个「先传给 parent,再由父往上找」的机制就叫双亲委派:parent 是父加载器,加载请求先「委派」给它,由它继续往上一层,名字就是这么来的。
记住一个细节:7.0 / 8.0 之后,
DexClassLoader构造时parent可以直接传null。如果你明确知道自己要加载的类,父加载器肯定没有,传个空能让查找更快(少走一圈 parent)。不过早期版本传null会直接报错,要看版本兼容。
为什么要双亲委派
- 性能:已经加载过的类,不用每个类加载器都重复加载。同一条链上,父加载器加载过的类,子加载器直接从缓存拿,省一次真正的类加载。
- 安全:也回答了一个经典问题:「我自己写一个
String类,能不能替换掉系统的String?」答案是不能。因为String属于android.*,早就被BootClassLoader加载进缓存了。你不管在哪写一个String,loadClass时双亲委派先去BootClassLoader那儿,它缓存里已经有了,直接返回系统的String,你的那份根本不会被加载进来。要是没有双亲委派,谁都能用自己的类覆写系统类,那还得了。
所以顺带理解一个面试题:**为什么 DexClassLoader 的 parent 不传 BaseDexClassLoader?**因为系统里从来没有一个 BaseDexClassLoader 实例去加载过类,你把它当 parent 传进去,跟传个空没区别;而传 PathClassLoader 就能复用宿主已加载的类,避免重复加载。
五、类到底藏在哪:DexPathList 和 dexElements
上面说的 findClass,在 Android 里走的是 BaseDexClassLoader 这条链:
// dalvik.system.BaseDexClassLoader.findClass:真正加载的入口@Overrideprotected Class<?> findClass(String name) throws ClassNotFoundException { // 委托给内部持有的 pathList(一个 DexPathList 对象)去找 Class<?> clazz = pathList.findClass(name, null); if (clazz == null) { throw new ClassNotFoundException("Didn't find class on path: " + pathList); } return clazz;}BaseDexClassLoader 内部持有一个叫 pathList 的字段,类型是 DexPathList。DexPathList.findClass 的实现:
// dalvik.system.DexPathList.findClass:遍历 dexElements,逐个 element 尝试public Class<?> findClass(String name, List<Throwable> suppressed) { // 遍历整个 dexElements 数组 for (Element element : dexElements) { // 每个 element 内部都对应一个 DexFile,让它去找 Class<?> clazz = element.findClass(name, definingContext, suppressed); if (clazz != null) { return clazz; // 命中就直接返回 } } return null; // 全都找不到才返回空}关键在 dexElements 这个数组:每个元素是一个 Element,每个 Element 内部包装一个 DexFile:
// DexPathList.Element.findClass:实际把请求交给它包装的那个 DexFilepublic Class<?> findClass(String name, ClassLoader definingContext, List<Throwable> suppressed) { if (dexFile != null) { // 真正落到 native 层去加载 dex 里的类 return dexFile.loadClassBinaryName(name, definingContext, suppressed); } return null;}(以上片段对应 Android 8.0 源码,DexPathList 的 findClass 会带一个 suppressed 异常收集参数,这里为讲清主线省略了异常汇集细节。9.0 之后 Element 内部字段有改动,但「dexElements 数组 + 每个 element 包一个 DexFile」这条链没变。)
这条链的对应关系是:一个 dex 文件对应一个 Element;dexElements 是数组,所以一个 APK 里有几个 dex(分包),就有几个 Element。 findClass 按顺序遍历这些 element,谁先命中谁返回。记住这个「先到先得」的顺序,后面插件化就是利用这一点。
小知识:
Element这个类,谷歌原本想叫PathElement,因为历史上 Facebook 反射用它内部的dexElements字段做适配,改名字会影响一大波反射代码,所以一直没改成。这也是为什么很多插件化代码里你会看到dexElements这个字段名。
而 loadClass 和 findClass 的关系是:loadClass(双亲委派)先判断该不该自己加载,轮到自己的时候,走的就是 findClass → DexPathList → dexElements → Element → DexFile 这条链。

六、核心:把插件的 dex 合并进宿主的 dexElements
前面铺垫这么多,落到这里就有用了。宿主(PathClassLoader)加载自己的类,靠的是遍历 pathList.dexElements 里的那些 element。插件 APK 你下载下来放在 /sdcard/plugin.apk,宿主从没装过它,dexElements 里自然没有对应的 element,所以找不到插件类。
补上缺的那一项就行:把插件 APK 对应的 element 通过反射合并进宿主的 pathList.dexElements,宿主之后就能找到插件类。
具体的做法分几步:
- 拿到一个能代表「插件 dex」的
DexClassLoader(它就是为加载外部 dex/APK 设计的); - 从它内部反射出插件的
DexPathList和dexElements; - 从宿主的
PathClassLoader里反射出宿主的DexPathList和dexElements; - 新建一个数组,长度 = 宿主 element 数 + 插件 element 数,把宿主的放前面、插件的放后面;
- 用反射把这个新数组写回宿主
pathList.dexElements。
流程图再直观一点:

反射这里有个容易写错的地方:dexElements 是私有、而且是非静态的。非静态意味着字段定义在类上,取值却必须拿着对象来取。所以反射时,先用 getDeclaredField 拿到字段,get/set 时要传入一个具体对象(宿主的 DexPathList 对象)。好在这个字段定义在 DexPathList 里,宿主和插件对象都能取到同一个 Field,可以共用。
写成工具类 LoadUtil:
public class LoadUtil { public static void loadClass(Context context, String apkPath) throws Exception { // 1. 反射拿到两个私有+非静态字段(宿主和插件可共用同一份 Field) // pathList 字段定义在 BaseDexClassLoader;dexElements 字段定义在 DexPathList Class<?> baseDexCls = Class.forName("dalvik.system.BaseDexClassLoader"); Field pathListField = baseDexCls.getDeclaredField("pathList"); pathListField.setAccessible(true); Class<?> dexPathListCls = Class.forName("dalvik.system.DexPathList"); Field dexElementsField = dexPathListCls.getDeclaredField("dexElements"); dexElementsField.setAccessible(true); // 2. 拿到宿主 PathClassLoader,反射得到宿主的 pathList 和 dexElements ClassLoader hostCL = context.getClassLoader(); // 实际就是 PathClassLoader Object hostPathList = pathListField.get(hostCL); // 宿主的 DexPathList Object[] hostDexElements = (Object[]) dexElementsField.get(hostPathList); // 宿主的 element 数组 // 3. 创建插件的 DexClassLoader(parent 传宿主,方便复用宿主已加载类) DexClassLoader pluginCL = new DexClassLoader(apkPath, context.getDir("dex", 0).getAbsolutePath(), // 优化目录必须是私有目录 null, hostCL); Object pluginPathList = pathListField.get(pluginCL); // 插件的 DexPathList Object[] pluginDexElements = (Object[]) dexElementsField.get(pluginPathList); // 插件的 element 数组 // 4. 合并:宿主元素在前,插件元素在后 int hostLen = hostDexElements.length; int pluginLen = pluginDexElements.length; Object[] newDexElements = (Object[]) Array.newInstance( hostDexElements.getClass().getComponentType(), // 数组元素类型 hostLen + pluginLen); System.arraycopy(hostDexElements, 0, newDexElements, 0, hostLen); System.arraycopy(pluginDexElements, 0, newDexElements, hostLen, pluginLen); // 5. 覆盖:把合并后的数组写回宿主的 dexElements 字段 dexElementsField.set(hostPathList, newDexElements); }}有几个点要解释:
- 为什么用反射拿字段:
pathList在BaseDexClassLoader里、dexElements在DexPathList里,都是私有字段,外部拿不到,只能反射。setAccessible(true)是让私有字段可访问。 - 优化目录必须是私有目录:
DexClassLoader的第二个参数是优化目录(存放 odex),它必须是应用自己的私有目录(如context.getDir("dex", 0)),放/sdcard会被系统拒绝。 - 宿主放前面、插件放后面:因为
findClass是「先到先得」,把宿主的 element 放前面,保证优先命中宿主已有类,避免跟宿主自己的类冲突;插件类因为是新增的、宿主里没有,放后面也能被找到,不会顶掉宿主已有的类。
写完工具类,在 MyApplication 的 onCreate 里调一下:
public class MyApplication extends Application { @Override public void onCreate() { super.onCreate(); try { // 把 /sdcard/plugin.apk 里的类合并进宿主加载器 LoadUtil.loadClass(this, "/sdcard/plugin.apk"); } catch (Exception e) { e.printStackTrace(); } }}插件 APK 里放一个简单的类,带一个静态方法:
public class PluginClass { public static void printPrint() { System.out.println("我是插件的补充方法"); }}加载完之后,插件类已经在宿主的 classloader 里了,宿主直接反射调用:
// 宿主里通过反射调用插件类的静态方法Class<?> pluginCls = Class.forName("com.plugin.PluginClass");Method printPrint = pluginCls.getMethod("printPrint");printPrint.invoke(null); // 静态方法不需要实例对象,所以第二个参数传 null跑一下,Logcat 里会打出:
我是插件的补充方法宿主代码里只用了普通的 Class.forName + 反射,全程没碰插件 DexClassLoader 的句柄,却成功加载了一个从未安装过的插件类。
这里你会自然想到:那热修复是不是也这么干?没错,热修复也是往
dexElements里塞 dex,只是它把补丁 dex 放最前面(让补丁类优先命中),跟插件化「宿主在前、插件在后」正好相反。两者底层的映射关系是同一个东西。这是下一讲的作业之一,你先记个印象。
七、小结
这一篇的结论:插件化加载类,就是把插件 dex 的 element 合并进宿主 classloader 的 dexElements 数组,之后宿主就能像加载自己类一样把插件类 loadClass 出来。
- 插件化 = 加载插件类 (本讲内容)+ 启动插件四大组件 + 加载插件资源;
- 类加载三步:字节流 → 进内存 → 创建
Class对象,反射的起点就是这个Class对象; PathClassLoader与DexClassLoader没有本质区别(8.0 后优化目录参数废弃);按职责分,SDK 类归BootClassLoader,应用类归PathClassLoader;- 双亲委派让加载「先往上找、再往下加载」,目的是性能 + 安全;
BaseDexClassLoader.findClass → DexPathList → dexElements → element → DexFile,这就是类真正被找到的链;- 核心实现:
new DexClassLoader(插件 APK)→ 反射分别取宿主和插件的dexElements→ 合并 → 反射写回宿主的dexElements。
留几个TODO:
- 自己写一遍
LoadUtil.loadClass,把插件 APK 放到模拟器/sdcard下,看宿主能不能反射调用到插件类的静态方法; - 试着把「宿主在前、插件在后」的顺序对调,想想会发生什么(提示:跟类的
findClass先到先得有关); - 思考题:如果插件里恰好有一个类跟宿主里某个类全限定名一样,合并后宿主会加载到哪个版本?为什么?(提示:找
findLoadedClass。) - 顺着这篇的思路,想一下热修复是怎么让补丁类优先被加载的,下一讲我们会讲。
本文由 AgentWorkFlow 生成