剖析ClassLoader深入热修复原理

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

双亲委派模式优势

采用双亲委派模式的是好处是Java类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关可以避免类的重复加载,当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次。其次是考虑到安全因素,java核心api中定义类型不会被随意替换,假设通过网络传递一个名为java.lang.Integer的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心Java API发现这个名字的类,发现该类已被加载,并不会重新加载网络传递的过来的java.lang.Integer,而直接返回已加载过的Integer.class,这样便可以防止核心API库被随意篡改。

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(即so加载路径)层代码存放路径

  • 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:在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是加载已安装的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

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,033评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,725评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,473评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,846评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,848评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,691评论 1 282
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,053评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,700评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,856评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,676评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,787评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,430评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,034评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,990评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,218评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,174评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,526评论 2 343

推荐阅读更多精彩内容