Android修炼系列(38),我将自定义 ClassLoader 的坑都踩了一遍

2,811 阅读7分钟

之前写过一篇关于类加载器的文章:

Android修炼系列(二),Class类加载过程与类加载器

介绍了 Class 类文件的加载过程、类加载器和双亲委派模型,属于偏理论型的,今天手欠,写了写自定义 ClassLoader 的 demo,发现里面的坑还挺多的,特此记录下。

ClassLoader

JAVA 虚拟机与实现语言解绑,与Class 文件字节码 这种特定形式的二进制文件格式 相关联。在类加载阶段,虚拟机会通过类的全限定名来获取该类的二进制流,再将该二进制流所代表的静态存储结构转换为方法区的运行时数据结构,最后在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区内该类的各数据访问入口。

说白了就是,虚拟机不关心我们的这种“特定二进制流”从哪里来的,从本地加载也好,从网上下载的也罢,都没关系。虚拟机要做的就是将该二进制流写在自己的内存中并生成相应的Class对象(并不是在堆中)。在这个阶段,我们能够通过我们自定义的类加载器来控制二进制流的获取方式。

编译class

我简单写了一个 Test.java 类,并将该文件放于桌面:

[/Users/zuomingjie/Desktop/Test.java]

package com.blog.a;
public class Test {
    public String getTestStr() {
        return "hello world";
    }
}

通过 javac 命令,编译 .class 文件,于桌面上:

image.png

其中 -d 用来生成 package 目录结构,com.blog.a为包名:

image.png

为什么要将 class 文件放在包结构目录下呢?

这就跟下面的全限定名有关。

MyClassLoader

来编写我们的 ClassLoader 类,并重写 loadclassfindclass 方法:

image.png

loadclass 方法,要注意调用 parent.load 接口,因为 Object 等系统类还是需要通过双亲委派模式来让父加载器加载的:

image.png

findClass 拿到 class 字节码,这里就是普通的 IO 操作读取 class 文件,就不贴代码了,最后使用 defineClass 来生成目标 Class 对象:

image.png

我们的调用代码,通过 MyClassLoader 来加载 Test.class 二进制文件,并生成 Class 对象,容易出错的地方,就是这个 filePath 和 name 值了:

image.png

运行结果,可以使用 AS 的 JavaTest:

image.png

URLClassLoader

我们也可以直接使用系统提供的 URLClassLoader 来加载本地 class 文件,其内部实际也是通过重写 findClass 方法并调用 defineClass 来实现的:

image.png

运行结果,可以使用 AS 的 JavaTest:

image.png

刚刚的例子都是在 java 环境下的,我们想下,如果我们要在 App 内加载一个外部的 Class 文件呢?

App加载Class

说干就干,首先我通过 adb push 命令,将 Test.class push 到设备的本地路径 /sdcard/com/blog/a 下:

image.png

我直接在 onCreate 中调用上文的加载方法,代码就不贴了,具体见上,注意 path 为设备根目录吗,直接运行:

image.png

结果报错,告诉我们不支持外部加载 class 文件,为啥呢?这就是 Java 虚拟机Android 虚拟机不一样的地方了,Android 系统定制了 Java 虚拟机,原生的 Java 虚拟机运行的是 class 文件,而 Android 虚拟机是直接运行 dex文件的。

我在前面文章 APK 的构建过程 时说过:

  1. aapt2 编译res/文件,生成编译后的二进制资源文件(.ap_文件)、R.java文件。
  2. Javac 工具,会将 R.java.java文件Aidl 接口文件编译成 .class 文件
  3. R8 又会将上一步产生的 .class 文件和第三方依赖中的 .class文件 编译成 .dex 文件
  4. apkbuilder 将编译后的资源(.ap_文件)、dex文件及其他资源文件(例如:so文件、asset文件等),压缩成一个 .apk 文件

我们就试着加载下 dex 文件呗?

App加载dex

编译class文件

说干就干,首先将 Test.class 文件编译成 jar 文件 于桌面上,可以通过 jar 命令:

image.png

一定要注意路径,这样写是错误的,这样会生成一个Users.zuomingjie.Desktop.com.blog.a 的错误包名的 jar 文件。正确写法应该是,先 cdUsers.zuomingjie.Desktop 路径下,再执行 jar 命令:

image.png

运行结果:

image.png

编译dex文件

我们再将 test.jar 编译成 dex 文件,可以通过 dx 命令:

image.png

dx 是 sdk 自带的工具,如果命令找不到,需要将 dx 路径添加到全局变量内,其目录在:/sdk/build-tools/{版本号}/

ok 执行命令:

image.png

执行结果:

image.png

加载前,可以先将 test.dex push 到设备 /sdcard/blog 路径下:

image.png

加载dex

我们知道,一个 App 最少是有两个 ClassLoader 的,一个是 BootClassLoader ,一个是 PathClassLoader,前者负责加载 framwork class 系统类,后者负责加载应用中的类。

其中 BootClassLoaderPathClassLoader 的 parent,注意这里的 parent 并不是父类,而是双亲委派模型的一种上层关系。

private static ClassLoader createSystemClassLoader() {
    String classPath = System.getProperty("java.class.path", ".");
    String librarySearchPath = System.getProperty("java.library.path", "");
    return new PathClassLoader(classPath, librarySearchPath, BootClassLoader.getInstance());
}

注意 PathClassLoader 只能加载已安装的 APK 的 dex 文件,我们实际用来加载外部 dex 文件的 ClassLoader 是 DexClassLoader

public DexClassLoader(String dexPath, String optimizedDirectory, String librarySearchPath, ClassLoader parent)
  • dexPath: dex 文件的路径
  • optimizedDirectory: dex 的加压路径,一般在 data/{package name}/xxx 路径
  • librarySearchPath: 目标类中所使用的native库存放的路径
  • dexClassLoader: dexClassLoader 的父加载器,一般为当前的类加载器

DexClassLoader 的使用方法也很简单,下面来加载 test.dex:

image.png

运行结果:

image.png

现在我在主工程内,即在 主dex 新增一个同限定名的 Test 类:

package com.blog.a;
public class Test {
    public String getTestStr() {
        return "hello APK dex";
    }
}

我再调用 DexClassLoader:

image.png

直接运行,不出所料:

image.png

但这里你没有疑惑吗?为啥能够强转?不是说不同 classLoader 加载的同限定名类,就是不同类吗?哈哈 结论是对的,但因为代码里 parent = getClassLoader(),而根据双亲委派模型,DexClassLoader 在 loadClass 时会首先使用 parent 装载器加载,所以默认会先从 base.apk 中加载 com.blog.a.Test,所以这里强转并没有问题。

优先加载dex

现在通过类名,获取 Test.class,并执行内部方法:

image.png

执行结果,发现是使用的 主dex 的类:

image.png

如果我想优先使用 test.dex 类内的方法呢,要怎么搞?

通过查看 DexClassLoader 源码,代码就不全贴了,findClass 后会调用到 DexPathList findClass 方法,在这里会遍历 dexElementsdexElements 内部为 Element(file),即我们 dex 文件信息:

image.png

但是 DexPathList 是被每个 ClassLoader 分别持有的,如果将 DexClassLoader 的 dexFile name 也添加在 PathhClassLoader 的集合首位是不是就 ok 了呢?

image.png

答案是肯定的,亲测有效。

实现也很简单,直接通过反射,先拿到每个 ClassLoader 各自的 dexElements 集合:

image.png

之后进行合并,test.dex 放在 base.dex 前面:

image.png

最后重新复值:

image.png

我们打印下 PathhClassLoader 新的集合内容:

image.png

运行结果:

image.png

现在再通过类名,获取 Test.class,执行其方法试下呢:

image.png

运行结果,发现已经变了,会优先执行 test.dex 文件:

image.png

思考

有两点需要思考下:

  • 如果 test.dex 文件中使用了资源,我们能调用吗?

  • 如果 test.dex 文件中使用了四大组件,我们能启动组件吗?

答案都是否定的。

那为啥使用了资源,我们就没法调用了呢?我们知道在 APK 构建过程中,aapt 会编译资源文件,生成二进制资源文件(.ap_文件)和 R.java文件,这些在编译期间就已经完成了,所以我们动态加载的 dex 文件 内的资源肯定是找不到的。

为啥四大组件也不行呢?因为每个组件都要在 Manifest 文件中注册,而 Manifest 文件会在 APK 安装的时候,会被系统 PMS 读取并记录,而安装完后, PMS 就不会再去重新读取 Manifest 文件了,所以 dex 文件 内组件因为没有被注册而导致无法启动。

那就没法解决吗?肯定不是,后面会介绍 Android 的插件化技术。

  • 资源加载,AssetManager.addAssetPath

  • 四大组件支持,Hook 和 静态代理

本节完。