类加载器ClassLoader及Dex/Class

ClassLoader

顾名思义,类加载器用来加载 Java 类到 Java 虚拟机中。一般来说,Java 虚拟机使用 Java 类的方式如下:Java 源程序(.java 文件)在经过 Java 编译器编译之后就被转换成 Java 字节代码(.class 文件)。类加载器负责读取 Java 字节代码,并转换成 java.lang.Class类的一个实例,每个这样的实例用来表示一个 Java 类,通过此实例的 newInstance()方法就可以创建出该类的一个对象。

类加载器是 Java 语言的一个创新。它使得动态安装和更新软件组件成为可能

Java 虚拟机是如何判定两个Java类是相同的:Java 虚拟机不仅要看类的全名是否相同,还要看加载此类的类加载器是否一样。只有两者都相同的情况,才认为两个类是相同的。

类加载器的代理模式

类加载器在尝试自己去查找某个类的字节代码并定义它时,会先代理给其父类加载器,由父类加载器先去尝试加载这个类,依次类推。

代理模式是为了保证 Java 核心库的类型安全。所有 Java 应用都至少需要引用 java.lang.Object类,也就是说在运行的时候,java.lang.Object这个类需要被加载到 Java 虚拟机中。如果这个加载过程由 Java 应用自己的类加载器来完成的话,很可能就存在多个版本的 java.lang.Object类,而且这些类之间是不兼容的。通过代理模式,对于 Java 核心库的类的加载工作由引导类加载器来统一完成,保证了 Java 应用所使用的都是同一个版本的 Java 核心库的类,是互相兼容的。

不同的类加载器为相同名称的类创建了额外的名称空间。相同名称的类可以并存在 Java 虚拟机中,只需要用不同的类加载器来加载它们即可。不同类加载器加载的类之间是不兼容的,这就相当于在 Java 虚拟机内部创建了一个个相互隔离的 Java 类空间。

ClassLoader特点:遵循双亲委派模型

ClassLoader在加载一个class文件时:会询问当前ClassLoader是否已经加载过此类,如果已经加载过则直接返回,不再重复加载。如果没有加载过,会去查询当前ClassLoader的parent是否已经加载过。

因为遵循双亲委派模型,Android中的classLoader具有两个特点:

  • 类加载共享
    当一个class文件被任何一个ClassLoader加载过,就不会再被其他ClassLoader加载。
  • 类加载隔离
    不同ClassLoader加载的class文件肯定不是一个。举个栗子,一些系统层级的class文件在系统初始化的时候被加载,比如java.net.String,这个是在应用启动前就被系统加载好的。如果在一个应用里能简单地用一个自定义的String类把这个String类替换掉的话,将有严重的安全问题。

线程上下文类加载器

线程上下文类加载器(context class loader)是从 JDK 1.2 开始引入的。类 java.lang.Thread中的方法 getContextClassLoader()和 setContextClassLoader(ClassLoader cl)用来获取和设置线程的上下文类加载器。如果没有通过 setContextClassLoader(ClassLoader cl)方法进行设置的话,线程将继承其父线程的上下文类加载器。Java 应用运行的初始线程的上下文类加载器是系统类加载器。在线程中运行的代码可以通过此类加载器来加载类和资源。

类加载器与 OSGi

OSGi(开放服务网关协议,Open Service Gateway Initiative)是 Java 上的动态模块系统。它为开发人员提供了面向服务和基于组件的运行环境,并提供标准的方式用来管理软件的生命周期。OSGi 已经被实现和部署在很多产品上,在开源社区也得到了广泛的支持。Eclipse 就是基于 OSGi 技术来构建的。

OSGi 中的每个模块(bundle)都包含 Java 包和类。模块可以声明它所依赖的需要导入(import)的其它模块的 Java 包和类(通过 Import-Package),也可以声明导出(export)自己的包和类,供其它模块使用(通过 Export-Package)。也就是说需要能够隐藏和共享一个模块中的某些 Java 包和类。这是通过 OSGi 特有的类加载器机制来实现的。OSGi 中的每个模块都有对应的一个类加载器。它负责加载模块自己包含的 Java 包和类。当它需要加载 Java 核心库的类时(以 java开头的包和类),它会代理给父类加载器(通常是启动类加载器)来完成。当它需要加载所导入的 Java 类时,它会代理给导出此 Java 类的模块来完成加载。模块也可以显式的声明某些 Java 包和类,必须由父类加载器来加载。只需要设置系统属性 org.osgi.framework.bootdelegation的值即可。

OSGi 模块的这种类加载器结构,使得一个类的不同版本可以共存在 Java 虚拟机中,带来了很大的灵活性。不过它的这种不同,也会给开发人员带来一些麻烦,尤其当模块需要使用第三方提供的库的时候。下面提供几条比较好的建议:

  • 如果一个类库只有一个模块使用,把该类库的 jar 包放在模块中,在 Bundle-ClassPath中指明即可。
  • 如果一个类库被多个模块共用,可以为这个类库单独的创建一个模块,把其它模块需要用到的 Java 包声明为导出的。其它模块声明导入这些类。
  • 如果类库提供了 SPI 接口,并且利用线程上下文类加载器来加载 SPI 实现的 Java 类,有可能会找不到 Java 类。如果出现了 NoClassDefFoundError异常,首先检查当前线程的上下文类加载器是否正确。通过 Thread.currentThread().getContextClassLoader()就可以得到该类加载器。该类加载器应该是该模块对应的类加载器。如果不是的话,可以首先通过 class.getClassLoader()来得到模块对应的类加载器,再通过 Thread.currentThread().setContextClassLoader()来设置当前线程的上下文类加载器。

ClassLoader种类

  • BootClassLoader(Java的BootStrap ClassLoader)
    用于加载Android Framework层class文件。
  • PathClassLoader(Java的App ClassLoader)
    用于加载已经安装到系统中的apk中的class文件(要传入系统中apk的存放Path,所以只能加载已经安装的apk文件)。
  • DexClassLoader(Java的Custom ClassLoader)
    用于加载指定目录中的class文件(可以加载jar/apk/dex,可以从SD卡中加载未安装的apk)。
  • BaseDexClassLoader
    是PathClassLoader和DexClassLoader的父类。

为了解决65535这个问题,Google提出了multidex方案,即一个apk文件可以包含多个dex文件。
不过值得注意的是,除了第一个dex文件以外,其他的dex文件都是以资源的形式被加载的, 换句话说就是在Application初始化前将dex文件注入到系统的ClassLoader中的。
根据Android虚拟机的类加载机制,同一个类只会被加载一次,所以热修复也使用了这样的机制,要让修复后的类替换原有的类就必须让补丁包的类被优先加载,也就是插入到原有dex之前。

PathClassLoader加载已安装的apk插件

使用PathClassLoader加载已安装的apk插件。sharedUserId要一致,简单的说,应用从一开始安装在Android系统上时,系统都会给它分配一个linux user id,之后该应用在今后都将运行在独立的一个进程中,其它应用程序不能访问它的资源,那么如果两个应用的sharedUserId相同,那么它们将共同运行在相同的linux进程中,从而便可以数据共享、资源访问了。所以我们在宿主app和插件app的manifest上都定义一个相同的sharedUserId。

下面看一个样例:加载包名为packageName的插件,然后获得插件内名为one.png的图片的资源id,进而供宿主app使用该图片

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 加载已安装的apk
* @param packageName 应用的包名
* @param pluginContext 插件app的上下文
* @return 对应资源的id
*/
private int dynamicLoadApk(String packageName, Context pluginContext) throws Exception {
//第一个参数为包含dex的apk或者jar的路径,第二个参数为父加载器
PathClassLoader pathClassLoader = new PathClassLoader(pluginContext.getPackageResourcePath(),ClassLoader.getSystemClassLoader());
//Class<?> clazz = pathClassLoader.loadClass(packageName + ".R$mipmap");//通过使用自身的加载器反射出mipmap类进而使用该类的功能
//参数:1、类的全名,2、是否初始化类,3、加载时使用的类加载器
Class<?> clazz = Class.forName(packageName + ".R$mipmap", true, pathClassLoader);
//使用上述两种方式都可以,这里我们得到R类中的内部类mipmap,通过它得到对应的图片id,进而给我们使用
Field field = clazz.getDeclaredField("one");
int resourceId = field.getInt(R.mipmap.class);
return resourceId;
}

  • 首先就是new出一个PathClassLoader对象,它的构造方法为:public PathClassLoader(String dexPath, ClassLoader parent)。其中第一个参数是通过插件的上下文来获取插件apk的路径,其实获取到的就是/data/app/apkthemeplugin.apk,那么插件的上下文怎么获取呢?在宿主app中我们只有本app的上下文啊,答案就是为插件app创建一个上下文:Context plugnContext = this.createPackageContext(packageName, CONTEXT_IGNORE_SECURITY | CONTEXT_INCLUDE_CODE。 通过插件的包名来创建上下文,不过这种方法只适合获取已安装的app上下文。或者不需要通过反射直接通过插件上下文getResource().getxxx(R..);也行,而这里用的是反射方法。第二个参数是父加载器,都是ClassLoader.getSystemClassLoader()。

DexClassLoader加载未安装的apk插件

关于动态加载未安装的apk,先描述下思路:首先我们得到事先知道我们的插件apk存放在哪个目录下,然后分别得到插件apk的信息(名称、包名等),然后显示可用的插件,最后动态加载apk获得资源。

按照上面这个思路,我们需要解决几个问题:
1、怎么得到未安装的apk的信息
2、怎么得到插件的context或者Resource,因为它是未安装的不可能通过createPackageContext(…);方法来构建出一个context,所以这时只有在Resource上下功夫。

现在我们就一一来解答这些问题吧:
1、得到未安装的apk信息可以通过mPackageManager.getPackageArchiveInfo()方法获得

/**
 * 获取未安装apk的信息
 * @param context
 * @param archiveFilePath apk文件的path
 * @return
 */
private String[] getUninstallApkInfo(Context context, String archiveFilePath) {
    String[] info = new String[2];
    PackageManager pm = context.getPackageManager();
    PackageInfo pkgInfo = pm.getPackageArchiveInfo(archiveFilePath, PackageManager.GET_ACTIVITIES);
    if (pkgInfo != null) {
        ApplicationInfo appInfo = pkgInfo.applicationInfo;
        String versionName = pkgInfo.versionName;//版本号
        Drawable icon = pm.getApplicationIcon(appInfo);//图标
        String appName = pm.getApplicationLabel(appInfo).toString();//app名称
        String pkgName = appInfo.packageName;//包名
        info[0] = appName;
        info[1] = pkgName;
    }
    return info;
}

2、得到对应未安装apk的Resource对象,我们需要通过反射来获得:

/**
 * @param apkName 
 * @return 得到对应插件的Resource对象
 */
private Resources getPluginResources(String apkName) {
    try {
        AssetManager assetManager = AssetManager.class.newInstance();
        Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);//反射调用方法addAssetPath(String path)
        //第二个参数是apk的路径:Environment.getExternalStorageDirectory().getPath()+File.separator+"plugin"+File.separator+"apkplugin.apk"
        addAssetPath.invoke(assetManager, apkDir+File.separator+apkName);//将未安装的Apk文件的添加进AssetManager中,第二个参数为apk文件的路径带apk名
        Resources superRes = this.getResources();
        Resources mResources = new Resources(assetManager, superRes.getDisplayMetrics(),
                superRes.getConfiguration());
        return mResources;
    } catch (Exception e) {
        e.printStackTrace();
    }
    return null;
}

通过得到AssetManager中的内部的方法addAssetPath,将未安装的apk路径传入从而添加进assetManager中,然后通过new Resource把assetManager传入构造方法中,进而得到未安装apk对应的Resource对象。

3、接下来就是加载未安装的apk获得它的内部资源

/**
 * 加载apk获得内部资源
 * @param apkDir apk目录
 * @param apkName apk名字,带.apk
 * @throws Exception
 */
private void dynamicLoadApk(String apkDir, String apkName, String apkPackageName) throws Exception {
    File optimizedDirectoryFile = getDir("dex", Context.MODE_PRIVATE);//在应用安装目录下创建一个名为app_dex文件夹目录,如果已经存在则不创建
    Log.v("zxy", optimizedDirectoryFile.getPath().toString());// /data/data/com.example.dynamicloadapk/app_dex
    //参数:1、包含dex的apk文件或jar文件的路径,2、apk、jar解压缩生成dex存储的目录,3、本地library库目录,一般为null,4、父ClassLoader
    DexClassLoader dexClassLoader = new DexClassLoader(apkDir+File.separator+apkName, optimizedDirectoryFile.getPath(), null, ClassLoader.getSystemClassLoader());
    Class<?> clazz = dexClassLoader.loadClass(apkPackageName + ".R$mipmap");//通过使用apk自己的类加载器,反射出R类中相应的内部类进而获取我们需要的资源id
    Field field = clazz.getDeclaredField("one");//得到名为one的这张图片字段
    int resId = field.getInt(R.id.class);//得到图片id
    Resources mResources = getPluginResources(apkName);//得到插件apk中的Resource
    if (mResources != null) {
        //通过插件apk中的Resource得到resId对应的资源
        findViewById(R.id.background).setBackgroundDrawable(mResources.getDrawable(resId));
    }
}

其中通过new DexClassLoader()来创建未安装apk的类加载器,我们来看看它的参数:

public class DexClassLoader extends BaseDexClassLoader {
    public DexClassLoader(String dexPath, String optimizedDirectory,
            String libraryPath, ClassLoader parent) {
        super(dexPath, new File(optimizedDirectory), libraryPath, parent);
    }
}

可以看到DexClassLoader的源码非常简单,只有一个构造方法。我们来看下其四个参数都是什么含义:

  • dexPath:要加载的dex文件路径。
  • optimizedDirectory:dex文件要被copy到的目录路径。此位置一定要是可读写且仅该应用可读写(安全性考虑),所以只能放在data/data下。看官方文档:
    This class loader requires an application-private, writable directory to cache optimized classes. Use Context.getDir(String, int) to create such a directory: File dexOutputDir = context.getDir(“dex”, 0);
  • libraryPath:apk文件中类要使用的c/c++代码,指向包含本地库(so)的文件夹路径,可以设为null。
  • parent:父装载器,也就是真正loadclass的装载器,一般可以通过Context.getClassLoader获取到,也可以通过ClassLoader.getSystemClassLoader()取到。
    在Android中加载class,其实最终是通过DexPathList的findClass来加载的。

单DexClassLoader与多DexClassLoader

插件和主工程的互相调用涉及到以下两个问题:

1、插件调用主工程
在构造插件的ClassLoader时会传入主工程的ClassLoader作为父加载器,所以插件是可以直接可以通过类名引用主工程的类。

2、主工程调用插件
1)若使用多ClassLoader机制,主工程引用插件中类需要先通过插件的ClassLoader加载该类再通过反射调用其方法。插件化框架一般会通过统一的入口去管理对各个插件中类的访问,并且做一定的限制。
2)若使用单ClassLoader机制,主工程则可以直接通过类名去访问插件中的类。该方式有个弊病,若两个不同的插件工程引用了一个库的不同版本,则程序可能会出错,所以要通过一些规范去避免该情况发生。

Dex文件

定义:能够被DVM或者Art虚拟机执行并且加载的文件格式。

作用:dex文件的作用是记录整个工程(通常是一个Android工程)的所有类文件的信息

Android支持动态加载的两种方式是:DexClassLoader和PathClassLoader。DexClassLoader可加载jar/apk/dex,且支持从SD卡加载;PathClassLoader据说只能加载已经安装在Android系统内APK文件,以下这一段是摘录:PathClassLoader 的限制要更多一些,它只能加载已经安装到 Android 系统中的 apk 文件,也就是 /data/app 目录下的 apk 文件。其它位置的文件加载的时候都会出现 ClassNotFoundException。

dex文件的生成:

先生成class文件(注意执行低版本的JDK版本,否则手机无法运行),然后执行:
dx --dex --output Test.dex Test.class
然后把生成的dex文件拷贝到手机:
adb push C:\Users\Administrator\Desktop\Test.dex /storage/emulated/0
adb shell
dalvikvm -cp /sdcard/Test.dex Test

dex文件的结构:

8位字节的二进制流文件
各个数据紧密排列,无间隙,减少了文件体积,加快加载速度
整个工程的类信息都存放在一个dex文件中(不考虑dex分包的情况下)


注意:
文件头包含了dex文件的信息,所有数据的大致分布情况
链接数据区:主要是指so库

Dex文件头格式



上图和上表就是dex的文件头的结构和各个位置的意思。其中最开始的64 65 78 0A 30 33 3500(dex.035.)表示这是按照dex解析的。

Class文件

定义:能够被JVM识别,加载并执行的文件格式。

作用:记录一个类文件的所有信息,记住所有。例如记住了当前类的引用this、父类super等等。class文件记录的信息往往比java文件多。

class文件的结构:

8位字节的二进制流文件
各个数据紧密排列,无间隙,减少了文件体积,加快加载速度
每个类或者接口单独占据一个class文件,每个类单独管理,没有交叉

class文件中的字段如下所示:

magic 加密字段,虚拟机判断当前的class文件是否被篡改过
minor_version 支持最低版本的jdk
major_version 编译使用的jdk版本
constant_pool_count 常量池的数量,一般为一个
cp_info constant_pool 常量池的结构体,数量不定(类型是cp_info结构体)
access_flags 访问级别,例如public等
this_class 当前类
super_class 父类
interfaces_count 类实现接口的数量
fields_count 类成员变量的数量
methods_count 类方法的数量
method_info methods 类方法的结构体
attributes_count 类属性的数量
attribute_info attributes 类属性的结构体

constant_pool包括:

CONSTANT_Integer_info、CONSTANT_Long_info、CONSTANT_String_info等等 
CONSTANT_Class_info:类的相关信息,包括当前类、引用到的类的信息 
CONSTANT_Fieldref_info:类的域信息 
CONSTANT_Methodref_info:类的方法信息

class文件的弊端:

内存占用大,不适合移动端
堆栈的加栈模式,加载速度慢。
文件IO操作多,类加载慢。

Class文件与Dex文件的比较

本质上都是一样的,都是二进制流文件格式,dex文件是从class文件演变而来的。
class文件存在冗余信息,dex文件则去掉了冗余,并且整合了整个工程的类信息。

参考资料

深入探讨 Java 类加载器
插件化开发—动态加载技术加载已安装和未安装的apk
Android_dex详解
ClassLoader详解
class文件和dex文件