Android 插件化(一):如何加载插件里的类

文章内容

本文整理自享学团队直播课《Android 插件化(一):类加载》。

插件化的第一件事:**让宿主把自己没装过的「插件类」加载进来。**拆开看,底层动作就三步:用一个 DexClassLoader 去加载插件 APK,反射拿到它内部的 dexElements,再合并进宿主自己的 dexElements。这三步做完,宿主 loadClass 一个插件类,和加载自己的类没有区别。

结论就上面三句,但直接看反射代码,你大概不明白为什么非得这么绕。往下按顺序走:先搞清楚插件化要解决什么,再看类加载的流程、ClassLoader 的结构、双亲委派的机制,最后落到合并 dexElements 的反射代码上。

一、插件化是什么:把主板和外设想清楚

插件化这个概念,硬件早就玩明白了。你拆开一台台式机主板,上面有 CPU 插槽、内存条接口、显卡接口、USB 鼠标键盘口。鼠标坏了,你拔下来换一个,主板不用动。这就是插件化:主板是「宿主」,鼠标键盘显示器这些是「插件」,可以随时插拔。

插件化类比:主板是宿主,外设是可插拔的插件

软件的插件化磨蹭到 2015、2016 年才火起来,卡住它的是维护成本。硬件里一个鼠标坏了,你总不能扛着整台电脑去修;所以硬件很早就是「主板 + 可换件」的结构。软件早年 APK 小、模块少,更新一次整体重发也没多大事,一直没被逼着做插件化。等到大厂业务多起来,淘宝要往闲鱼、飞猪导流,总不能为了导个流就在淘宝里再开发一个闲鱼:包体越来越大、模块耦合越来越高、方法数逼近 65535 上限、占用内存过多,这才把插件化提上日程。

插件化的好处:包体积小、模块解耦、按需下载,甚至能免安装启动别的 APK。

它和「组件化」的区别主要在上线方式:

  • 组件化:开发时是好几个独立模块,上线时合并成一个 APK;
  • 插件化:开发时也是多个模块,上线时分成宿主 APK + 插件 APK,分开上线(你甚至可以只上宿主,插件不上)。

拿主板打比方:组件化等于厂家把主板和鼠标焊在一起再出厂,坏了只能整机修;插件化是主板、鼠标各卖各的,你想要哪个模块就装哪个,体积自然也小了。

那实现插件化到底要解决哪三件事?课堂上老师问了一圈,归纳下来就三步:

  1. 加载插件的类;
  2. 启动插件的四大组件;
  3. 加载插件的资源。

这三节课就分别解决这三步。这一讲只讲第一步:类怎么加载。

题外话:现在主流的插件化框架(DynamicLoadApk、VirtualAPK、RePlugin 等)底层思路大同小异。

二、类是怎么被加载的:生命周期和 Class 对象

先把「一个类从字节码变成能用的东西」这条流程列出来。类的生命周期是:

plain text
加载(loading) → 连接(验证/准备/解析) → 初始化 → 使用 → 卸载

这一步的重点是「加载」。加载阶段干了三件事:

  1. 通过类名拿到二进制的字节流(对 Android 来说就是对应的一份 class.dex 或 .class 文件);
  2. 把这个字节内容加载进内存;
  3. 在内存里创建一个 Class 对象。

类里有什么?方法、字段、构造方法,这些元数据全都挂在 Class 对象上。你用反射时,先 Class.forName(...) 或 xxx.getClass() 拿到 Class 对象,再通过它拿到 Field(属性)、Method(方法)、Constructor(构造),然后调用/设置。这就跟操作一个普通对象一样。

所以有个很关键的因果:**类没被加载,就没有 Class 对象;没有 Class 对象,反射就无从谈起。**这也是为什么「想反射一个类,得先保证它被加载了」。

顺便提一下反射和动态代理的关系(动态代理正是靠反射在运行期拿到方法再统一处理)。反射是很多框架的底座,热修复、插件化、动态代理都踩在它上面。这块不是本讲重点,心里有个大方向就行。

三、Android 的 ClassLoader 体系

类是由加载器加载的。Android 的加载器继承关系是这样:

Android ClassLoader 继承关系

  • 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
// 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") 会这样走完一条链:

  1. dexClassLoader.loadClass("A") 进来,先 findLoadedClass("A"),自己没有;
  2. 委托给 parent,也就是 pathClassLoader.loadClass("A");
  3. PathClassLoader 同样先 findLoadedClass,没有,再委托给它的 parent,也就是 BootClassLoader;
  4. BootClassLoader 到顶了,它的 loadClass 是重写过的:findLoadedClass 没有就直接走自己的 findClass 去真加载(native 层),加载完不管成功与否都直接 return,不再往上委托;
  5. 回到 PathClassLoader,发现 BootClassLoader 返回的是空,那就轮到自己 findClass 去加载;
  6. 回到 DexClassLoader,如果 parent 一直没找到,最后还是自己 findClass 兜底。

双亲委派 loadClass 查找流程:先向上找,再向下加载

这个「先传给 parent,再由父往上找」的机制就叫双亲委派:parent 是父加载器,加载请求先「委派」给它,由它继续往上一层,名字就是这么来的。

记住一个细节:7.0 / 8.0 之后,DexClassLoader 构造时 parent 可以直接传 null。如果你明确知道自己要加载的类,父加载器肯定没有,传个空能让查找更快(少走一圈 parent)。不过早期版本传 null 会直接报错,要看版本兼容。

为什么要双亲委派

  1. 性能:已经加载过的类,不用每个类加载器都重复加载。同一条链上,父加载器加载过的类,子加载器直接从缓存拿,省一次真正的类加载。
  2. 安全:也回答了一个经典问题:「我自己写一个 String 类,能不能替换掉系统的 String?」答案是不能。因为 String 属于 android.*,早就被 BootClassLoader 加载进缓存了。你不管在哪写一个 String,loadClass 时双亲委派先去 BootClassLoader 那儿,它缓存里已经有了,直接返回系统的 String,你的那份根本不会被加载进来。要是没有双亲委派,谁都能用自己的类覆写系统类,那还得了。

所以顺带理解一个面试题:**为什么 DexClassLoader 的 parent 不传 BaseDexClassLoader?**因为系统里从来没有一个 BaseDexClassLoader 实例去加载过类,你把它当 parent 传进去,跟传个空没区别;而传 PathClassLoader 就能复用宿主已加载的类,避免重复加载。

五、类到底藏在哪:DexPathList 和 dexElements

上面说的 findClass,在 Android 里走的是 BaseDexClassLoader 这条链:

java
// 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 的实现:

java
// 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:

java
// 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 这条链。

类被找到的链路:DexPathList 遍历 dexElements,每个 element 包装一个 DexFile

六、核心:把插件的 dex 合并进宿主的 dexElements

前面铺垫这么多,落到这里就有用了。宿主(PathClassLoader)加载自己的类,靠的是遍历 pathList.dexElements 里的那些 element。插件 APK 你下载下来放在 /sdcard/plugin.apk,宿主从没装过它,dexElements 里自然没有对应的 element,所以找不到插件类。

补上缺的那一项就行:把插件 APK 对应的 element 通过反射合并进宿主的 pathList.dexElements,宿主之后就能找到插件类。

具体的做法分几步:

  1. 拿到一个能代表「插件 dex」的 DexClassLoader(它就是为加载外部 dex/APK 设计的);
  2. 从它内部反射出插件的 DexPathList 和 dexElements;
  3. 从宿主的 PathClassLoader 里反射出宿主的 DexPathList 和 dexElements;
  4. 新建一个数组,长度 = 宿主 element 数 + 插件 element 数,把宿主的放前面、插件的放后面;
  5. 用反射把这个新数组写回宿主 pathList.dexElements。

流程图再直观一点:

加载插件类的实现流程:反射合并插件 dexElements 进宿主

反射这里有个容易写错的地方:dexElements 是私有、而且是非静态的。非静态意味着字段定义在类上,取值却必须拿着对象来取。所以反射时,先用 getDeclaredField 拿到字段,get/set 时要传入一个具体对象(宿主的 DexPathList 对象)。好在这个字段定义在 DexPathList 里,宿主和插件对象都能取到同一个 Field,可以共用。

写成工具类 LoadUtil:

java
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 里调一下:

java
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 里放一个简单的类,带一个静态方法:

java
public class PluginClass {    public static void printPrint() {        System.out.println("我是插件的补充方法");    }}

加载完之后,插件类已经在宿主的 classloader 里了,宿主直接反射调用:

java
// 宿主里通过反射调用插件类的静态方法Class<?> pluginCls = Class.forName("com.plugin.PluginClass");Method printPrint = pluginCls.getMethod("printPrint");printPrint.invoke(null);   // 静态方法不需要实例对象,所以第二个参数传 null

跑一下,Logcat 里会打出:

plain text
我是插件的补充方法

宿主代码里只用了普通的 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:

  1. 自己写一遍 LoadUtil.loadClass,把插件 APK 放到模拟器 /sdcard 下,看宿主能不能反射调用到插件类的静态方法;
  2. 试着把「宿主在前、插件在后」的顺序对调,想想会发生什么(提示:跟类的 findClass 先到先得有关);
  3. 思考题:如果插件里恰好有一个类跟宿主里某个类全限定名一样,合并后宿主会加载到哪个版本?为什么?(提示:找 findLoadedClass。)
  4. 顺着这篇的思路,想一下热修复是怎么让补丁类优先被加载的,下一讲我们会讲。

本文由 AgentWorkFlow 生成

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

分类:Android系统

评论 (0)

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

加载评论中…

Android 插件化(一):如何加载插件里的类 · Yinchen 的博客