ClassLoader
我们知道一个java程序来是由多个class
类组成的,我们在运行程序的过程中需要通过ClassLoader
将class
类载入到JVM
中才可以正常运行。
而Android程序需要正常运行,也同样需要有ClassLoader
机制将class类加载到Android 的 Dalvik
(5.0之前版本)/ART
(5.0增加的)中,只不过它和java
中的ClassLoader
不一样在于Android的apk
打包,是将class
文件打包成一个或者多个 dex
文件(由于Android 65k
问题,使用 MultiDex
就会生成多个 dex
文件),再由BaseDexClassLoader
来进行处理。
在安装apk
的过程中,会有一个验证优化dex
的机制,叫做DexOpt
,这个过程会生成一个odex
文件( odex 文件也属于dex文件
),即Optimised Dex
。执行odex
的效率会比直接执行dex
文件的效率要高很多。运行Apk的时候,直接加载odex
文件,从而避免重复验证和优化,加快了Apk的响应时间。
注意:Dalvik/ART 无法像 JVM 那样直接加载 class 文件和 jar 文件中的 class,需要通过工具来优化转换成 Dalvik byte code 才行,只能通过 dex 或者包含 dex 的jar、apk 文件来加载
dex生成方法
你可以直接在编译工程后,在app/build/intermediates/classes
中拿到你需要的class
,然后再通过dx
命令生成dex
文件
dx --dex --output=/Users/test/test.dex multi/shengyuan/com/mytestdemo/test.class
双亲委派机制
类加载器双亲委派模型的工作过程是:如果一个类加载器收到一个类加载的请求,它首先将这个请求委派给父类加载器去完成,每一个层次类加载器都是如此,则所有的类加载请求都会传送到顶层的启动类加载器,只有父加载器无法完成这个加载请求(即它的搜索范围中没有找到所要的类),子类才尝试加载。
双亲委派模式优势
采用双亲委派模式的是好处是Java类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关可以避免类的重复加载,当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次。其次是考虑到安全因素,java核心api中定义类型不会被随意替换,假设通过网络传递一个名为java.lang.Integer的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心Java API发现这个名字的类,发现该类已被加载,并不会重新加载网络传递的过来的java.lang.Integer,而直接返回已加载过的Integer.class,这样便可以防止核心API库被随意篡改。
Android中的ClassLoader根据用途可分为一下几种:
- BootClassLoader:主要用于加载系统的类,包括
java
和android
系统的类库,和JVM
中不同,BootClassLoader是ClassLoader
内部类,是由Java
实现的,它也是所有系统ClassLoader
的父ClassLoader - PathClassLoader:用于加载Android系统类和开发编写应用的类,只能加载已经安装应用的
dex
或apk
文件,也是getSystemClassLoader
的返回对象 - DexClassLoader:可以用于加载任意路径的
zip
、dex
、jar
或者apk
文件,也是进行安卓动态加载的基础
DexClassLoader类
public class DexClassLoader extends BaseDexClassLoader {
public DexClassLoader(String dexPath, String optimizedDirectory,
String librarySearchPath, ClassLoader parent) {
super(dexPath, new File(optimizedDirectory), librarySearchPath, parent);
}
}
PathClassLoader类
public class PathClassLoader extends BaseDexClassLoader {
public PathClassLoader(String dexPath, ClassLoader parent) {
super(dexPath, null, null, parent);
}
public PathClassLoader(String dexPath, String librarySearchPath, ClassLoader parent) {
super(dexPath, null, librarySearchPath, parent);
}
}
BaseDexClassLoader#BaseDexClassLoader方法
PathClassLoader
、DexClassLoader
均继承BaseDexClassLoader
,所以其super
方法均调用到了BaseDexClassLoader
构造方法
参数详解:
dexPath
待解析文件所在的全路径,classloader
将在该路径中指定的dex
文件寻找指定目标类optimzedDirectory
优化路径,指的是虚拟机对于apk中的dex文件进行优化后生成文件存放的路径,如dalvik虚拟机生成的ODEX
文件路径和ART
虚拟机生成的OAT
文件路径。
这个路径必须是当前app的内部存储路径,Google认为如果放在公有的路径下,存在被恶意注入的危险
注意:PathClassLoader没有将optimizedDirectory置为Null,也就是没设置优化后的存放路径。其实optimizedDirectory为null时的默认路径就是/data/dalvik-cache 目录。 PathClassLoader是用来加载Android系统类和应用的类,并且不建议开发者使用。
libraryPath
指定native
(即so
加载路径)层代码存放路径parent
当前ClassLoader的parent
,和java
中classloader
的parent
含义一样
public BaseDexClassLoader(String dexPath, File optimizedDirectory, String libraryPath, ClassLoader parent) {
super(parent);
this.originalPath = dexPath;
this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
}
ClassLoader#loadClass方法
通过该方法你就能发现双亲委派机制的妙处了
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
// 1 通过调用c层findLoadedClass检查该类是否被加载过,若加载过则返回class对象(缓存机制)
Class c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
//2 各种类型的类加载器在构造时都会传入一个parent类加载器
//2 若parent类不为空,则调用parent类的loadClass方法
c = parent.loadClass(name, false);
} else {
//3 查阅了PathClassLoader、DexClassLoader并没有重写该方法,默认是返回null
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
}
if (c == null) {
//4 如果父ClassLoader不能加载该类才由自己去加载,这个方法从本ClassLoader的搜索路径中查找该类
long t1 = System.nanoTime();
c = findClass(name);
}
}
return c;
}
BaseDexClassLoader#findClass方法
DexClassLoader
、PathClassLoader
通过继承BaseDexClassLoader
从而使用其父类findClass
方法,在ClassLoader#loadClass
方法中第3
步进入
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
Class c = pathList.findClass(name, suppressedExceptions);
if (c == null) {
ClassNotFoundException cnfe = new ClassNotFoundException("Didn't find class \"" + name + "\" on path: " + pathList);
for (Throwable t : suppressedExceptions) {
cnfe.addSuppressed(t);
}
throw cnfe;
}
return c;
}
DexPathList#findClass方法
从ClassLoader#loadClass
方法中我们可以知道,当走到第2
步即会走到如下方法,通过对已构建好的dexElements
进行遍历,通过dex.loadClassBinaryName
方法load
对应的class
类,所以这里是一个热修复的点,你可以将需要热修复的dex
文件插入到dexElements
数组前面,这样遍历的时候查到你最新插入的则返回,从而实现动态替换有问题类
public Class findClass(String name, List<Throwable> suppressed) {
for (Element element : dexElements) {
DexFile dex = element.dexFile;
if (dex != null) {
//调用到c层defineClassNative方法进行查找
Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
if (clazz != null) {
return clazz;
}
}
}
if (dexElementsSuppressedExceptions != null) {
suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
}
return null;
}
DexPathList#makeElements
BaseDexClassLoader
的构造方法中对DexPathList
进行实例化,在DexPathList
构造方法中调用makeElements
生成dexElements
数组,首先会根据传入的dexPath
,生成一个file类型的list容器,然后传入后进行遍历加载,通过调用DexFile
中的loadDexFile
对dexFile
文件进行加载
private static Element[] makeElements(List<File> files, File optimizedDirectory,
List<IOException> suppressedExceptions,
boolean ignoreDexFiles,
ClassLoader loader) {
Element[] elements = new Element[files.size()];
int elementsPos = 0;
//遍历所有文件,并提取 dex 文件。
for (File file : files) {
File zip = null;
File dir = new File("");
DexFile dex = null;
String path = file.getPath();
String name = file.getName();
if (path.contains(zipSeparator)) {
String split[] = path.split(zipSeparator, 2);
zip = new File(split[0]);
dir = new File(split[1]);
} else if (file.isDirectory()) {
//为文件夹时,直接存储
elements[elementsPos++] = new Element(file, true, null, null);
} else if (file.isFile()) {
if (!ignoreDexFiles && name.endsWith(DEX_SUFFIX)) {
// loadDexFile 的作用是:根据 file 获取对应的 DexFile 对象。
try {
dex = loadDexFile(file, optimizedDirectory, loader, elements);
} catch (IOException suppressed) {
System.logE("Unable to load dex file: " + file, suppressed);
suppressedExceptions.add(suppressed);
}
} else {
// 非 dex 文件,那么 zip 表示包含 dex 文件的压缩文件,如 .apk,.jar 文件等
zip = file;
if (!ignoreDexFiles) {
try {
dex = loadDexFile(file, optimizedDirectory, loader, elements);
} catch (IOException suppressed) {
suppressedExceptions.add(suppressed);
}
}
}
} else {
System.logW("ClassLoader referenced unknown path: " + file);
}
if ((zip != null) || (dex != null)) {
elements[elementsPos++] = new Element(dir, false, zip, dex);
}
}
if (elementsPos != elements.length) {
elements = Arrays.copyOf(elements, elementsPos);
}
return elements;
}
插件化
看到这里,你应该大概理解了classloader
加载流程,其实java
这层的classloader
代码量并不多,主要集中在c
层,但是我们在java
层进行hook
便可实现热修复。
结合网上的资料及源码的阅读一共有三种方案
方案1:向dexElements
进行插入新的dex
(目前最常见的方式)
从上面的ClassLoader#loadClass
方法你就会知道,初始化的时候会进入BaseDexClassLoader#findClass
方法中通过遍历dexElements
进行查找dex
文件,因为dexElements
是一个数组,所以我们可以通过反射的形式,将需要热修复的dex
文件插入到数组首部
,这样遍历数组的时候就会优先读取你插入的dex
,从而实现热修复。
DexClassLoader不是允许你加载外部dex吗?用DexClassLoader#loadClass不就行了
我们知道DexClassLoader
是允许你加载外部dex
文件的,所以网上有一些例子介绍通过DexClassLoader#loadClass
可以加载到你的dex
文件中的方法,那么有一些网友就会有疑问,我直接通过调用DexClassLoader#loadClass
去获取我传入的外部dex
文件中的class
,不就行了,这样确实是可以的,但是它仅适用于新增的类,而不能去替换旧的类,因为通过上面的dexElements
数组的生成以及委派双亲机制
,你就会知道它的父类是先去把你应用类组装进来,当你调用DexClassLoader
去loadClass
时,是先委派父类去loadClass
,如果查找不到才会到子类自行查找,也就是说应用中本来就已经存在B.class
了,那么父类loadClass
会直接返回,而你真正需要返回的其实是子类中的B.class
,所以才说只适用于新增的类,你不通过一些手段修改源码层,是无法实现替换类的。
方案2:在ActivityThread中替换LoadedApk的mClassLoader对象
小编在开发MPlugin的时候,使用了下面的方法,但发现当你插件apk中进行跳转的下一个页面的时候,若引了第三方的库,会抛出无法载入该第三方库控件异常。
实现代码如下:
public static void loadApkClassLoader(Context context,DexClassLoader dLoader){
try{
// 配置动态加载环境
Object currentActivityThread = RefInvoke.invokeStaticMethod(
"android.app.ActivityThread", "currentActivityThread",
new Class[] {}, new Object[] {});//获取主线程对象 http://blog.csdn.net/myarrow/article/details/14223493
String packageName = context.getPackageName();//当前apk的包名
ArrayMap mPackages = (ArrayMap) RefInvoke.getFieldOjbect(
"android.app.ActivityThread", currentActivityThread,
"mPackages");
WeakReference wr = (WeakReference) mPackages.get(packageName);
RefInvoke.setFieldOjbect("android.app.LoadedApk", "mClassLoader",
wr.get(), dLoader);
}catch(Exception e){
e.printStackTrace();
}
}
方案3:通过自定义ClassLoader实现class拦截替换
我们知道PathClassLoader
是加载已安装的apk
的dex
,那我们可以
在 PathClassLoader
和 BootClassLoader
之间插入一个 自定义的MyClassLoader
,而我们通过ClassLoader#loadClass
方法中的第2
步知道,若parent
不为空,会调用parent.loadClass
方法,固我们可以在MyClassLoader
中重写loadClass
方法,在这个里面做一个判断去拦截替换掉我们需要修复的class
。
如何拿到我们需要修复的class呢?
我当时首先想到的是通过DexClassLoader
直接去loadClass
来获得需要热修复的Class
,但是通过ClassLoader#loadClass
方法分析,可以知道加载查找class
的第1
步是调用findLoadedClass
,这个方法主要作用是检查该类是否被加载过,如果加载过则直接返回,所以如果你想通过DexClassLoader
直接去loadClass
来获得你需要热修复的Class
,是不可能完成替换的(热修复),因为你调用DexClassLoader.loadClass
已经属于首次加载了,那么意味着下次加载就直接在findLoadedClass
方法中返回class
了,是不会再往下走,从而MyClassLoader#loadClass
方法也不可能会被回调,也就无法实现修复。
通过BaseDexClassLoader#findClass
方法你就会知道,这个方法在父ClassLoader
不能加载该类的时候才由自己去加载,我们可以通过这个方法来获得我们的class
,因为你调用这个方法的话,是不会被缓存起来。也就不存在ClassLoader#loadClass
中的第1
步就查找到就被返回。
方案3代码:
public class HookUtil {
/**
* 在 PathClassLoader 和 BootClassLoader 之间插入一个 自定义的MyClassLoader
* @param classLoader
* @param newParent
*/
public static void injectParent(ClassLoader classLoader, ClassLoader newParent) {
try {
Field parentField = ClassLoader.class.getDeclaredField("parent");
parentField.setAccessible(true);
parentField.set(classLoader, newParent);
} catch (IllegalArgumentException e) {
throw new RuntimeException(e);
} catch (NoSuchFieldException e) {
throw new RuntimeException(e);
} catch (IllegalAccessException e) {
e.printStackTrace();
}
}
/**
* 反射调用findClass方法获取dex中的class类
* @param context
* @param dexPath
* @param className
*/
public static void hookFindClass(Context context,String dexPath,String className){
DexClassLoader dexClassLoader = new DexClassLoader(dexPath, context.getDir("dex",context.MODE_PRIVATE).getAbsolutePath(),null, context.getClassLoader());
try {
Class<?> herosClass = dexClassLoader.getClass().getSuperclass();
Method m1 = herosClass.getDeclaredMethod("findClass", String.class);
m1.setAccessible(true);
Class newClass = (Class) m1.invoke(dexClassLoader, className);
ClassLoader pathClassLoader = MyApplication.getContext().getClassLoader();
MyClassLoader myClassLoader = new MyClassLoader(pathClassLoader.getParent());
myClassLoader.registerClass(className, newClass);
injectParent(pathClassLoader, myClassLoader);
} catch (NoSuchMethodException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
}
}
}
public class MyClassLoader extends ClassLoader {
public Map<String,Class> myclassMap;
public MyClassLoader(ClassLoader parent) {
super(parent);
myclassMap = new HashMap<>();
}
/**
* 注册类名以及对应的类
* @param className
* @param myclass
*/
public void registerClass(String className,Class myclass){
myclassMap.put(className,myclass);
}
/**
* 移除对应的类
* @param className
*/
public void removeClass(String className){
myclassMap.remove(className);
}
@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
Class myclass = myclassMap.get(name);
//重写父类loadClass方法,实现拦截
if(myclass!=null){
return myclass;
}else{
return super.loadClass(name, resolve);
}
}
}
关于CLASS_ISPREVERIFIED标记
因为在 Dalvik
虚拟机下,执行 dexopt
时,会对类进行扫描,如果类里面所有直接依赖的类都在同一个 dex 文件中,那么这个类就会被打上 CLASS_ISPREVERIFIED
标记,如果一个类有 CLASS_ISPREVERIFIED
标记,那么在热修复时,它加载了其他 dex 文件中的类,会报经典的Class ref in pre-verified class resolved to unexpected implementation
异常
通过源码搜索并没有找到CLASS_ISPREVERIFIED
标记这个关键词,通过在android7.0、8.0
上进行热修复,也没有遇到这个异常,猜测这个问题只属于android5.0以前(关于解决方法网上有很多,本文就不讲述了),因为android5.0后新增了art
。
最后
看到这里相信java层的ClassLoader机制你已经熟悉得差不多了,相对于插件化而言你已经前进了一步,但仍有一些问题需要去思考解决的,比如解决资源加载、混淆、加壳等问题,为了更好的完善热修复机制,你也可以去阅读下c层的逻辑,尽管热修复带来了很多便利,但个人也并不是太认同热修复的使用,毕竟是通过hook去修改源码层,因为android的碎片化问题,很难确保你的hook能正常使用且不引发别的问题。
注意:本文源码阅读及案例测试是基于android7.0、8.0编写的,案例经过实测是可行的