QQ空间热修复原理和注意点

以下内容摘录自Android热修复学习之旅开篇——热修复概述
参考:
从Java类加载初始化到Android热修复

QQ空间热修复补丁技术

原理

QQ空间的热修复方案是基于dex分包的基础之上的,简单来说就是把bug方法修复之后,然后重新生成一个dex,从服务器下发以后,将其插入到dexElements前面,让虚拟机去加载修复后的方法。(关于dex分包相关内容请参考Android Dex分包方案和热补丁原理)如下图所示:

QQ空间热修复原理和注意点_第1张图片
image.png

这里涉及到到ClassLoad的原理,当一个类被加载以后,如果后面再出现相同的类就不会再加载了。这就是补丁热修复最基本的原理。
但是采用这种方案,有一个明显的问题,那就是当两个调用关系的类不在同一个dex里时,就会产生异常报错。发生异常的原因是,在apk安装时,虚拟机会对classes.dex进行优化,变成odex文件,然后才会执行。在这个过程中,会进行类的verify的验证工作,如果调用关系的类都在同一个dex中的话就会被打上CLASS_ISPREVERIFIED的标志,然后才会写入odex文件。

如果使用这种方案时,必须要避免类被打上CLASS_ISPREVERIFIED标记,具体的做法就是在每一个类的构造函数中单独引用一个在另外dex中的类。

我们通过Android类加载基础之ClassLoder的分析已经知道,无论是PathClassLoader还是DexClassLoader最终调用的都是BaseDexClassLoader里的方法,而且我们加载完外部的n个dex以后会被转换为Element[]数组存储在DexPathList中。PathClassLoader和DexClassLoader的差别就是DexClassLoader可以加载外部的dex而PathClassLoader只能加载已经安装了的内部dex,其中PathClassLoader 在应用启动时创建,从 data/app/… 安装目录下加载 apk 文件
一个ClassLoader可以包含多个dex文件,每个dex文件就是一个Element,多个dex文件排列成一个有序的数组dexElements,当查找某个类的时候,会按顺序遍历dex文件,然后从当前遍历的dex文件中找类,如果找到则直接返回,如果找不到则从下一个dex文件继续查找。
理论上,如果在不同的dex中有相同的类存在,那么会优先选择排在前面的dex文件的类。

QQ空间热修复原理和注意点_第2张图片
image.png

所以QQ空间热修复方案正是基于ClassLoader的这个原理,把修复后的类打包到一个dex(path.dex)中去,然后把这个dex插入到Elements的最前面去。


QQ空间热修复原理和注意点_第3张图片
image.png

步骤

1.获取到当前引用的ClassLoader
2.通过反射获取到它的DexPathList属性对象pathList
3.通过反射调用pathList的dexElements方法把path.dex转化为Element数组
4.两个Element数组进行合并,把path.dex放到数组的最前面去
5.加载合并后行的Element数组,达到修复bug的目的

QQ空间热修复原理和注意点_第4张图片
image.png

缺点

1.不支持即时生效,必须通过重启才能生效
2.path.dex是用来存储修复的类,应用启动时,就要加载path.dex,当修复的类到了一定的数量的时候,就会出现加载时间过长,造成应用启动卡顿
3.在ART模式下,如果类结构发生了改变,就会出现内存错乱。为了解决这个问题,就必须把所有相关的调用类、父类、子类等等全部加载到path.dex中,导致补丁包异常的大,进一步增加了应用的启动时间

CLASS_ISPREVERIFIED的问题

采用dex分包方案会遇到的问题,也就是CLASS_ISPREVERIFIED,简单来说就是:在虚拟机启动的时候,当verify选项被打开的时候,如果static方法、private方法、构造方法等,其中的直接引用(第一层关系)到的类都在同一个dex文件中,那么该类就会被打上CLASS_ISPREVERIFIED标志。
注意:这里避免被打上CLASS_ISPREVERIFIED标志的类似引用者类,而不是被引用者。也就是说假设你的app里面有个类叫做AClass,在其内部引用了BClass。发布过程中发现BClass有编写错误,那么想要发布一个新的BClass类,那么你就要阻止AClass这个类被打上CLASS_ISPREVERIFIED标志。也就是说,你在生成apk之前,就需要阻止相关类打上CLASS_ISPREVERIFIED的标志了。如何阻止,简单来说,就是让AClass在构造方法中,去直接引用别的dex文件中的类即可,比如:C.dex中的CClass。
总结:
1.动态改变BaseDexClassLoader对象间接引用的 dexElements
2.在app打包的时候,阻止相关类去打上CLASS_ISPREVERIFIED标志。

关键代码实现

采用QQ空间的热修复方案而实现的开源热修复框架就是HotFix,说到了使用dex分包方案会遇到CLASS_ISPREVERIFIED问题,而解决方案就是在dx工具执行之前,将所有的class文件,进行修改,再其构造中添加System.out.println(dodola.hackdex.AntilazyLoad.class),然后继续打包的流程。注意:AntilazyLoad.class这个类是独立在hack.dex中。

dex分包方案实现需要关注以下问题:

1.如何解决CLASS_ISPREVERIFIED问题
2.如何将修复的.dex文件插入到dexElements的最前面

CLASS_ISPREVERIFIED问题

在老版的Gradle中我们通过以下代码关联task并执行插件来动态插入代码

image.png

QQ空间热修复原理和注意点_第5张图片
image.png

PatchClass中的代码比较简单我就不分析了,主要用到了javassist技术,感兴趣的朋友可以去查找相关的资料。
Gradle的更新速度很快,当我们的AndroidStudio升级以后,系统已经提供了更好的api来操作代码在编译过程中的回调,这就是Transform api,该兴趣的小伙伴可以参考我以前写的文章: 编写最基本的Gradle插件
经过上面的代码,我们已经解决了CLASS_ISPREVERIFIED的问题

将修复的.dex文件插入dexElements

寻找class其实就是遍历dexElements,然后我们的AntilazyLoad.class其实并不包含在apk的classes.dex中,并且根据上面描述的需要,我们需要将AntilazyLoad.class这个类打成独立的hack_dex.jar,注意不是普通的jar,而是经过dx工具进行转化后的。具体做法如下:

jar cvf hack.jar dodola/hackdex/*
dx --dex --output hack_dex.jar hack.jar

还记得之前我们将所有的类的构造方法中都引用了AntilazyLoad.class,所以我们需要把hack_dex.jar插入到dexElements,而在hotfix中,就是在Application中完成这个操作的。

        File dexPath = new File(getDir("dex", Context.MODE_PRIVATE), "hackdex_dex.jar");
        //从assets中读取文件被写入到指定文件中去
        Utils.prepareDex(this.getApplicationContext(), dexPath, "hackdex_dex.jar");
        //通过反射去修改dexElements数组
        HotFix.patch(this, dexPath.getAbsolutePath(), "dodola.hackdex.AntilazyLoad");
        try {
            this.getClassLoader().loadClass("dodola.hackdex.AntilazyLoad");
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }

HotFix#patch

public static void patch(Context context, String patchDexFile, String patchClassName) {
        if (patchDexFile != null && new File(patchDexFile).exists()) {
            try {
                if (hasLexClassLoader()) {
                    injectInAliyunOs(context, patchDexFile, patchClassName);
                } else if (hasDexClassLoader()) {
                    injectAboveEqualApiLevel14(context, patchDexFile, patchClassName);
                } else {
                    injectBelowApiLevel14(context, patchDexFile, patchClassName);
                }
            } catch (Throwable th) {
            }
        }
    }

根据不同的平台然后分别注入到不同平台下的dexElements中。这里我们只分析api
14 以上的平台,其他平台大家自己去分析。其实原理都是差不多的。

private static void injectAboveEqualApiLevel14(Context context, String dexPath, String dexClassName)
        throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
        //得到当前的PathClassLoader
        PathClassLoader pathClassLoader = (PathClassLoader) context.getClassLoader();
        //将老的dexElements和pathDexElements进行组合生出新的dexElements
        Object a = combineArray(getDexElements(getPathList(pathClassLoader)),
            getDexElements(getPathList(
                new DexClassLoader(dexPath, context.getDir("dex", 0).getAbsolutePath(), dexPath, context.getClassLoader()))));
        //拿到DexPathList对象
        Object a2 = getPathList(pathClassLoader);
        //将DexPathList实例中的dexElements成员替换为合并后的dexElements
        setField(a2, a2.getClass(), "dexElements", a);
        //加载指定的类
        pathClassLoader.loadClass(dexClassName);
    }

好了,经过上面的分析,我们已经将hack_dex.jar成功的插入到dexElements的最前面了,而补丁插入的过程也和hack_dex.jar的插入流程是一致。

总结

QQ热修复步骤

1.发现某个类中存在bug
2.创建一个相同的类解决bug,并通过javassist技术解决CLASS_ISPREVERIFIED的问题,然后下发到指定的客户端
3.app启动时会创建PathClassLoader,扫描指定目录下的dex文件并保存到DexPathList的dexElements数组中。
4.Application#onCreate中查找是否有path.dex文件,如果没有则通过网络下载,保存到assets中,然后拷贝到app的指定目录下。如果存在path.dex,则创建DexClassLoader加载它,然后得到它的dexElements数组,与PathClassloader中的dexElements数组进行合并(插入到头部),通过反射将新生成的数组注入到原来的dexElements中,从而完成bug类的替换。
5.当我们在初始化原先的bug类的时候,会从新生成dexElements中查找,由于双亲委托,当我们已经找到了新类的时候,他就不会再去查找原先老的bug类,所以此时的对象旧已经完成了bug的修复。

你可能感兴趣的:(QQ空间热修复原理和注意点)