剖析ClassLoader深入热修复原理

作者: 马小鹏marco | 来源:发表于2018-08-07 19:37 被阅读86次

ClassLoader

我们知道一个java程序来是由多个class类组成的,我们在运行程序的过程中需要通过ClassLoaderclass类载入到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

双亲委派机制

类加载器双亲委派模型的工作过程是:如果一个类加载器收到一个类加载的请求,它首先将这个请求委派给父类加载器去完成,每一个层次类加载器都是如此,则所有的类加载请求都会传送到顶层的启动类加载器,只有父加载器无法完成这个加载请求(即它的搜索范围中没有找到所要的类),子类才尝试加载。


图一.png
Android中的ClassLoader根据用途可分为一下几种:
  • BootClassLoader:主要用于加载系统的类,包括javaandroid系统的类库,和JVM中不同,BootClassLoader是ClassLoader内部类,是由Java实现的,它也是所有系统ClassLoader的父ClassLoader
  • PathClassLoader:用于加载Android系统类和开发编写应用的类,只能加载已经安装应用的 dexapk 文件,也是getSystemClassLoader的返回对象
  • DexClassLoader:可以用于加载任意路径的zipdexjar或者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方法

PathClassLoaderDexClassLoader均继承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层代码存放路径

  • parent
    当前ClassLoader的parent,和javaclassloaderparent含义一样

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方法

DexClassLoaderPathClassLoader通过继承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中的loadDexFiledexFile文件进行加载

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,从而实现热修复。

图二.jpeg
DexClassLoader不是允许你加载外部dex吗?用DexClassLoader#loadClass不就行了

我们知道DexClassLoader是允许你加载外部dex文件的,所以网上有一些例子介绍通过DexClassLoader#loadClass可以加载到你的dex文件中的方法,那么有一些网友就会有疑问,我直接通过调用DexClassLoader#loadClass去获取我传入的外部dex文件中的class,不就行了,这样确实是可以的,但是它仅适用于新增的类,而不能去替换旧的类,因为通过上面的dexElements数组的生成以及委派双亲机制,你就会知道它的父类是先去把你应用类组装进来,当你调用DexClassLoaderloadClass时,是先委派父类去loadClass,如果查找不到才会到子类自行查找,也就是说应用中本来就已经存在B.class了,那么父类loadClass会直接返回,而你真正需要返回的其实是子类中的B.class,所以才说只适用于新增的类,你不通过一些手段修改源码层,是无法实现替换类的。

方案2:通过自定义ClassLoader实现class拦截替换

我们知道PathClassLoader是加载已安装的apkdex,那我们可以
PathClassLoaderBootClassLoader 之间插入一个 自定义的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步就查找到就被返回。

图三.jpeg

方案2代码:

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编写的,案例经过实测是可行的

相关文章

网友评论

    本文标题:剖析ClassLoader深入热修复原理

    本文链接:https://www.haomeiwen.com/subject/fmuvvftx.html