Android 简单热修复(下)——基于DexClassLoader的实现

前面Java类加载器的介绍中写过关于ClassLoader的基础知识,包括了双亲委派机制、自定义ClassLoader等内容。但是,前面讲到的都是基于JVM的内容,在这里需要清楚下:Android采用的Dalvik虚拟机(DVM)和ART虚拟机(4.4版本发布)。

简单描述Android采用的虚拟机和JVM的区别

送分题(敲黑板)!!



根据广大网友描述,区别如下:

  • Dalvik基于寄存器,而JVM基于栈。基于寄存器的虚拟机对于编译后变大的程序来说,在它们执行的时候,花费的时间更短。
  • JVM运行java字节码,DVM运行的是其专有的文件格式Dex。
  • ART与Dalvik最大的不同在于,在启用ART模式后,系统在安装应用的时候会进行一次预编译,在安装应用程序时会先将代码转换为机器语言存储在本地,这样在运行程序时就不会每次都进行一次编译了,执行效率也大大提升。
  • ART占用空间比Dalvik大(字节码变为机器码之后,可能会增加10%-20%),这就是“时间换空间大法”。
  • 预编译也可以明显改善电池续航,因为应用程序每次运行时不用重复编译了,从而减少了 CPU 的使用频率,降低了能耗。

如果非要深究为什么上面的一定是对的?我只能说——我也不懂。。作为菜鸡,只能站在巨人的肩膀看世界了(虽然有的的确不靠谱)。


DVM执行Dex文件

上面讲过:JVM运行java字节码,DVM运行的是其专有的文件格式Dex。Dex文件是由java的.class文件通过Android Sdk的build-tools目录下的dx.bat生成,生成命令如下:

dx --dex --output=[outFilePath] [inputDirPath]

举个例子:

package com;

public class Main {

    public static void main(String[] args) {
        System.out.println("hello");
    }
}

将Main.class文件和其目录拷贝到桌面(主要是为了方便),并执行下面的命令:

执行命令

这里面最后的输入路径需要注意下,输入路径需要是.class包名的上一级目录,否则生成Dex文件会报错。执行命令后会生成文件:
dex文件

接着,我们将dex文件放到/mnt/sdcard/目录下:
放到目录

通过命令adb shell dalvikvm -cp [dexFilePath] [className]执行:
执行结果

OK,DVM执行Dex文件的结果已经出来了。

Android的类加载器

上面已经说了DVM可以执行Dex文件,其实我们也可以知道不管采用什么虚拟机,还是需要将执行的代码(字节码)加载到内存,最终执行。我们先看下Android里的ClassLoader:

image.png

Android的ClassLoader是PathClassLoader,需要源码的可以在这里搜索
PathClassLoaderBaseDexClassLoader的子类,下面我们来看下源码:

PathClassLoader.java:
public class PathClassLoader extends BaseDexClassLoader {
    // 调用了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.java:
public class BaseDexClassLoader extends ClassLoader {
    private final DexPathList pathList;

    public BaseDexClassLoader(String dexPath, File optimizedDirectory,
            String librarySearchPath, ClassLoader parent) {
        super(parent);
        // 创建DexPathList对象
        this.pathList = new DexPathList(this, dexPath, librarySearchPath, null);
        ......
    }

    public BaseDexClassLoader(ByteBuffer[] dexFiles, ClassLoader parent) {
        super(parent);
        this.pathList = new DexPathList(this, dexFiles);
    }

    // 重写了findClass方法,遵循了双亲委派机制
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
        // 调用pathList的findClass方法
        Class c = pathList.findClass(name, suppressedExceptions);
        // 找到了Class则return
        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;
    }

    ......
}

这两个类的源码并不是很多,主要逻辑还是在BaseDexClassLoader中。BaseDexClassLoader重写了findClass方法,遵循双亲委派机制,并且这里调用了BaseDexClassLoader的成员变量pathListfindClass方法。如果pathList.findClass方法找到了需要的Class,那么将结果返回。我们需要看下DexPathList的源码:

/*package*/ final class DexPathList {
    private static final String DEX_SUFFIX = ".dex";
    private static final String zipSeparator = "!/";

    private final ClassLoader definingContext;
    // 这个属性很重要,热修复的关键
    private Element[] dexElements;
    private final NativeLibraryElement[] nativeLibraryPathElements;

    private final List<File> nativeLibraryDirectories;

    private final List<File> systemNativeLibraryDirectories;

    private IOException[] dexElementsSuppressedExceptions;

    ......

    public DexPathList(ClassLoader definingContext, String dexPath,
                       String librarySearchPath, File optimizedDirectory) {

        ......
        this.definingContext = definingContext;

        ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>();
        // save dexPath for BaseDexClassLoader
        // 根据传入的dex的路径生成Element数组
        this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory,
                suppressedExceptions, definingContext);

        ......
    }

    ......
    private static Element[] makeDexElements(List<File> files, File optimizedDirectory,
                                             List<IOException> suppressedExceptions, ClassLoader loader) {
        Element[] elements = new Element[files.size()];
        int elementsPos = 0;

        for (File file : files) {
            if (file.isDirectory()) {
                // We support directories for looking up resources. Looking up resources in
                // directories is useful for running libcore tests.
                // 支持目录的形式
                elements[elementsPos++] = new Element(file);
            } else if (file.isFile()) {// 如果是文件的话
                
                String name = file.getName();
                // 文件名以.dex结尾
                if (name.endsWith(DEX_SUFFIX)) {
                    // Raw dex file (not inside a zip/jar).
                    try {
                        // 创建dexFile对象
                        DexFile dex = loadDexFile(file, optimizedDirectory, loader, elements);
                        // 数组赋值
                        if (dex != null) {
                            elements[elementsPos++] = new Element(dex, null);
                        }
                    } catch (IOException suppressed) {
                        System.logE("Unable to load dex file: " + file, suppressed);
                        suppressedExceptions.add(suppressed);
                    }
                } else {
                    DexFile dex = null;
                    try {
                        dex = loadDexFile(file, optimizedDirectory, loader, elements);
                    } catch (IOException suppressed) {
                        suppressedExceptions.add(suppressed);
                    }
                    
                    // 其他情况,根据loadDexFile返回值确定如何创建
                    if (dex == null) {
                        elements[elementsPos++] = new Element(file);
                    } else {
                        elements[elementsPos++] = new Element(dex, file);
                    }
                }
            } else {
                System.logW("ClassLoader referenced unknown path: " + file);
            }
        }
        if (elementsPos != elements.length) {
            elements = Arrays.copyOf(elements, elementsPos);
        }
        return elements;
    }

    private static DexFile loadDexFile(File file, File optimizedDirectory, ClassLoader loader,
                                       Element[] elements)
            throws IOException {
        // 根据是否传入优化的目录来确定DexFile调用哪种构造方法
        if (optimizedDirectory == null) {
            return new DexFile(file, loader, elements);
        } else {
            String optimizedPath = optimizedPathFor(file, optimizedDirectory);
            return DexFile.loadDex(file.getPath(), optimizedPath, 0, loader, elements);
        }
    }

    ......
    // 这里才是重点
    public Class<?> findClass(String name, List<Throwable> suppressed) {
        // 遍历dexElements成员变量,通过Element的findClass方法去查找需要的Class
        // 找到后,直接返回!!
        // 这里是热修复的关键
        for (Element element : dexElements) {
            Class<?> clazz = element.findClass(name, definingContext, suppressed);
            if (clazz != null) {
                return clazz;
            }
        }

        if (dexElementsSuppressedExceptions != null) {
            suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
        }
        return null;
    }
    ......
}

上面代码不少,其实真正有用的我觉得就是findClass方法,DexPathListfindClass方法通过遍历成员变量Element[] dexElements来根据名称查找所需的Class,并将找到的Class返回(如果存在的话),这里非常非常重要!!
写到这里,我想懂的人肯定都懂了,我们需要做的就是将没有问题的代码Dex文件插入到DexPathList的成员变量dexElements前面,这样在读取Class时首先查找的是我们没有问题的Dex文件,当查找成功后直接返回,不会进入后面的循环,从而完成问题代码的“修复”

实现

原理都讲清楚了,剩下的就是实现了。实现代码更加简单,反射修改属性即可。下面请开始我的表演:

public class BugFixUtils {
    private static final String DEX = ".dex";
    // 这个8.1的源码已经无效了
    private static final String OPTIMIZED_DEX_DIR = "newDex";

    public static void doFix(Context context, String newDexPath) {
        File dexFileDir = new File(newDexPath);
        PathClassLoader pathClassLoader = (PathClassLoader) context.getClassLoader();
        if (dexFileDir.exists()) {
            File[] dexFiles = dexFileDir.listFiles();
            if (dexFiles != null) {
                for (File dexFile : dexFiles) {
                    if (dexFile.getName().endsWith(DEX)) {
                        // 创建对象
                        File optimizedDirectory = new File(context.getFilesDir().getAbsolutePath() + File.separator + OPTIMIZED_DEX_DIR);
                        if (!optimizedDirectory.exists()) {
                            optimizedDirectory.mkdirs();
                        }
                        try {
                            BaseDexClassLoader baseDexClassLoader = new BaseDexClassLoader(
                                    dexFile.getAbsolutePath(),
                                    optimizedDirectory,
                                    null,
                                    pathClassLoader);
                            // 反射获得属性
                            Object pathListObj = getFieldObj(Class.forName("dalvik.system.BaseDexClassLoader"), baseDexClassLoader, "pathList");
                            Object dexElementsObj = getFieldObj(Class.forName("dalvik.system.DexPathList"), pathListObj, "dexElements");
                            // 获得现在App dex文件属性
                            Object pathListBugObj = getFieldObj(Class.forName("dalvik.system.BaseDexClassLoader"), pathClassLoader, "pathList");
                            Object dexElementsBugObj = getFieldObj(Class.forName("dalvik.system.DexPathList"), pathListBugObj, "dexElements");
                            // 合并,顺序:新的 有Bug的
                            Object newElements = combineArray(dexElementsObj, dexElementsBugObj);
                            // 重新赋值
                            setFieldObj(Class.forName("dalvik.system.DexPathList"), pathListBugObj, newElements, "dexElements");
                        } catch (ClassNotFoundException e) {
                            e.printStackTrace();
                        }
                    }
                }
            }
        }
    }

    private static void setFieldObj(Class clzz, Object obj, Object value, String field) {
        try {
            Field declaredField = clzz.getDeclaredField(field);
            declaredField.setAccessible(true);
            declaredField.set(obj, value);
        } catch (NoSuchFieldException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }
    }

    private static Object getFieldObj(Class clzz, Object obj, String field) {
        try {
            Field localField = clzz.getDeclaredField(field);
            localField.setAccessible(true);
            return localField.get(obj);
        } catch (NoSuchFieldException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }

        return null;
    }

    private static Object combineArray(Object newDex, Object bugDex) {
        // 获得数组对象的类型
        Class componentType = newDex.getClass().getComponentType();
        // 获得长度
        int i = Array.getLength(newDex);
        int j = Array.getLength(bugDex);
        // 创建新的数组
        Object result = Array.newInstance(componentType, i + j);
        // 把新的dex文件放在前面,有bug的放在后面
        System.arraycopy(newDex, 0, result, 0, i);
        System.arraycopy(bugDex, 0, result, i, j);
        return result;
    }
}

代码已经完成:

  1. 获取新的dex文件的位置,并根据其后缀(.dex)来判断文件是否为所需。
  2. 遍历这些文件,建立BaseDexClassLoader对象。
  3. 通过反射获得BaseDexClassLoader对象的DexPathList pathList成员变量以及pathList中的Element[] dexElements成员变量。
  4. 通过反射获得PathClassLoader对象的DexPathList pathList成员变量以及pathList中的Element[] dexElements成员变量。
  5. 将两个dexElements数组合并,注意新的dexElements数组要放在有bug的dexElements数组前面。
  6. 将合并后的数组赋值给PathClassLoader对象中的DexPathList pathList成员变量中的Element[] dexElements变量,大功告成!

测试

测试前代码:

测试前

测试前的代码只是在打开Activity的时候显示Toast“测试”,在未加载新的dex文件时正常:
测试前结果

修改后的测试代码,这里将Toast文字改编为“测试之后”,并将.class文件打包成dex文件放到sd卡的根目录下:
测试之后

dex文件

这里需要注意下,需要将App完全杀死后重新打开App,结果如下:
测试之后结果

以上源码是Android 26但是测试机是Android 5.1.1,测试可以成功。用Android 模拟器一直不成功,不知道为什么。。

总结

前面也说过,这篇文章的由来,在看源码的过程中有一种恍然大悟的感觉。之前一直听说简单热修复的原理就是把新的dex插入到旧的dex前面,但是真正让我去说个所以然,感觉真的难。不过看完源码后,原理真的很简单,真的是码读百遍,其义自见!!

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

推荐阅读更多精彩内容