Android热修复之QQ空间与QFix方案

前文介绍了阿里的Hotfix,它的热修复思路是粗暴的底层方法指针的替换,今天我们来看看另一种思路,也就是QQ空间团队提供的热修复方案。要理解这个方案的思想,先要理解dex分包技术,这类文章很多,大家可以自己google研究学习,这里通过简单分析一下Android ClassLoader的源码来说一下这个问题。

我们知道除了BootClassLoader外,Android主要提供了两个ClassLoader,一个是PathClassLoader,一个是DexClassLoader,这两个类的源码里其实除了继承了BaseDexClassLoader覆写了构造方法外,啥也没干,所以类加载的核心逻辑还是在父类中。我们看一下父类中的findClass方法:

@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
}

其实是调用了一个pathList的findClass方法,我们来看这个pathList是一个DexPathList类,里面的findClass方法:

public Class findClass(String name, List<Throwable> suppressed) {  
    for (Element element : dexElements) {  
        DexFile dex = element.dexFile;  
        if (dex != null) {  
            Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);  
            if (clazz != null) {  
                return clazz;  
            }  
        }  
   }  
    if (dexElementsSuppressedExceptions != null) {  
        suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));  
    }  
    return null;  
}  

这个方法中有一个循环,遍历了一个Element数组,每次从element数组中取出一个DexFile并执行了它的loadClassBinaryName方法实现类的加载,继续跟进去看下这个DexFile:

public Class loadClassBinaryName(String name, ClassLoader loader, List<Throwable> suppressed) {
    return defineClass(name, loader, mCookie, this, suppressed);
    }

private static Class defineClass(String name, ClassLoader loader, Object cookie, DexFile dexFile, List<Throwable> suppressed) {
    Class result = null;
    try {
        result = defineClassNative(name, loader, cookie, dexFile);
    } catch (NoClassDefFoundError e) {
        if (suppressed != null) {
            suppressed.add(e);
        }
    } catch (ClassNotFoundException e) {
        if (suppressed != null) {
            suppressed.add(e);
        }
    }
    return result;
}

分析到这里我们知道,Android类加载源码的大致逻辑为:遍历一个装载dex文件(每个dex文件实际上是一个DexFile对象)的数组(Element数组,Element是一个内部类),然后依次去加载所需要的class文件,直到找到为止。而dex分包其实就是一个注入的解决方案,假如我们将第二个dex文件放入Element数组中,那么在加载第二个dex包中的类时,就可以直接找到。我们根据这个逻辑写一段注入的代码在应用的Application里:

public String inject(String libPath) {  
    boolean hasBaseDexClassLoader = true;  
    try {  
        Class.forName("dalvik.system.BaseDexClassLoader");  
    } catch (ClassNotFoundException e) {  
        hasBaseDexClassLoader = false;  
    }  
    if (hasBaseDexClassLoader) {  
        PathClassLoader pathClassLoader = (PathClassLoader)sApplication.getClassLoader();  
        DexClassLoader dexClassLoader = new DexClassLoader(libPath, sApplication.getDir("dex", 0).getAbsolutePath(), libPath, sApplication.getClassLoader());  
        try {  
            Object dexElements = combineArray(getDexElements(getPathList(pathClassLoader)), getDexElements(getPathList(dexClassLoader)));  
            Object pathList = getPathList(pathClassLoader);  
            setField(pathList, pathList.getClass(), "dexElements", dexElements);  
            return "SUCCESS";  
        } catch (Throwable e) {  
            e.printStackTrace();  
            return android.util.Log.getStackTraceString(e);  
        }  
    }  
    return "SUCCESS";  
}   

这段代码通过反射获取PathClassLoader中DexPathList中的Element数组,此时这里面只有apk里的dex。然后让DexClassLoader中去加载了我们新增的dex,并取出其中DexPathList中的Element数组,将两个Element数组合并之后,再将其赋值给PathClassLoader的Element数组,到此,注入完毕。以上就是dex分包的基本思想,概括来说就是可以加载没有打包到我们apk中的代码。那这个和我们的热修复有啥关系呢?

我们继续复习,classLoader有一个核心的加载逻辑叫做双亲委托机制,讲人话就是:爹classLoader加载过某个类后,子classLoader遇到相同的类就不会再加载。对比我们上面的代码,也就是说一个ClassLoader可以包含多个dex文件,每个dex文件是一个Element,多个dex文件排列成一个有序的数组dexElements,当找类的时候,会按顺序遍历dex文件。然后从当前遍历的dex文件中找类,如果找到则返回,如果找不到从下一个dex文件继续查找。所以,如果在不同的dex中有相同的类存在,那么会优先选择排在前面的dex文件的类,而后面的就不会再被加载。也就是说,后面有bug的类被前面的“修复”了。没错,这就是QQ空间的方案,做一个patch.apk进行动态加载,然后优先加载patch中的类。

但这个方案直接用会有一个问题,举例来说:当apk中的某个类A,引用了另一个类B,而我们修复的是B,此时会崩溃,原因就是AB不是由同一个classLoader加载的,到底是哪里抛出了这个异常呢,看代码(/dalvik/vm/oo/Resolve.cpp):
这里写图片描述

从代码上来看,如果两个相关联的类在不同的dex中就会报错,但是拆分dex没有报错这是为什么,原来这个校验的前提是:

这里写图片描述

如果引用者(也就是A)这个类被打上了CLASS_ISPREVERIFIED标志,那么就会进行dex的校验。那么这个标志是什么时候被打上去的?代码在DexPrepare.cpp中,就不贴了,这里直接说结论:我们知道当一个apk在安装的时候,apk中的classes.dex会被虚拟机(dexopt)优化成odex文件,然后才会拿去执行。虚拟机在启动的时候,会有许多的启动参数,其中一项就是verify选项,当verify选项被打开的时候,那么就会执行dvmVerifyClass进行类的校验,如果dvmVerifyClass校验类成功,那么这个类会被打上CLASS_ISPREVERIFIED的标志,概括一下就是如果某个类中直接引用到的类(第一层级关系,不会进行递归搜索)和clazz都在同一个dex中的话,那么这个类就会被打上CLASS_ISPREVERIFIED。

好了,知道了崩溃的原理,解决方案就是要想办法防止类被打上CLASS_ISPREVERIFIED标志就OK了。最终QQ空间的方案是往所有类的构造函数里面插入了一段代码,代码如下:

if (ClassVerifier.PREVENT_VERIFY) {
    System.out.println(AntilazyLoad.class);
}

其中AntilazyLoad类会被打包成单独的hack.dex,这样当安装apk的时候,classes.dex内的类都会引用一个在不相同dex中的AntilazyLoad类,这样就防止了类被打上CLASS_ISPREVERIFIED的标志了,只要没被打上这个标志的类都可以进行打补丁操作。然后在应用启动的时候加载进来。AntilazyLoad类所在的dex包必须被先加载进来,不然AntilazyLoad类会被标记为不存在,即使后续加载了hack.dex包,那么他也是不存在的,这样屏幕就会出现茫茫多的类AntilazyLoad找不到的log。所以Application作为应用的入口不能插入这段代码。(因为载入hack.dex的代码是在Application中onCreate中执行的,如果在Application的构造函数里面插入了这段代码,那么就是在hack.dex加载之前就使用该类,该类一次找不到,会被永远的打上找不到的标志)

综上,大致的流程是:在dx工具执行之前,将B.class文件呢,进行修改,再其构造中添加前面那段System.out.println的代码,然后继续打包的流程。注意:AntilazyLoad.class这个类是独立在hack.dex中。那么,如何去修改一个类的class文件,在dx之前去进行类的修改呢,这里需要用到javassist工具,具体的可以百度去研究一下,我们这篇只讲原理,具体实现就留给各位自己去玩了。

—————–已经不再流行的分割线君————————

看上去很美的方案其实有较大的性能问题,根据手Q团队的报告来看,插桩的解决方案会影响到运行时性能的原因在于:app 内的所有类都预埋引用一个独立 dex 的空类,导致安装 dexopt 阶段的 preverify 失败,运行时将再次 verify+optimize。近期我们通过 ReDex 尝试优化手Q的启动性能时发现:保留手Q现有的插桩,启动性能没有任何优化效果,但去掉插桩,优化手Q启动相关类的 dex 分布,启动性能提升30%。另外即使后期手Q的发布版本实际上无需发布补丁,我们也需要预埋插桩的逻辑,这本身也是不合理的一点,所以确实有必要去探索新的方向,既保留补丁的能力,同时去掉插桩带来的负面影响。继续看前面分析的代码:

这里写图片描述

其实QQ空间的方案是从1处的&&后的判断入手解决了dex验证的问题,那1处有没有什么办法呢,如果fromUnverifiedConstant直接为true的话,下面不就都不会走了吗,没错,QFix的方案就是搞定了这个,我们看上面代码的前面一段代码:
这里写图片描述

dvmResolveClass 在最开始会优先从当前dex已解析类的缓存里找被引用类,找到了直接返回,找不到时说明被引用类还没有被加载,接着加载成功后,会往当前dex缓存里设置上这个类的引用,后续所有对补丁类的解析引用都不会走到后面的“unexpected DEX”异常逻辑里。也就是说,补丁安装后,预先以 const-class/instance-of 方式主动引用补丁类,这次引用会触发加载补丁类并将引用放入dex的已解析类缓存里,后续app实际业务逻辑引用到补丁类时,直接从已解析缓存里就能取到,这样很简单地就绕开了“unexpected DEX”异常,而且这里只是很简单地执行了一条轻量级的语句,并没有其它额外的影响。

下面最重要的问题就是这个引用放哪里,demo中我们可以预先在Application中进行引用,但在实际运用中我们是无法预先设定哪些类要打补丁的,dex里对补丁类const-class/instance-of方式的引用指令是编译时确定的,但具体是哪些类又需要在运行时动态确定,所以这种动态方式行不通,QFix团队最初想到的还是类似插桩的做法,预先把 app 里所有类都以 const-class 方式引用一遍,但很明显有以下问题:1)由于 app 里类的数量很多,所有类的预先引用统一放在一个地方肯定不现实,需要分散在多个区,只对补丁类所在的少数几个区执行预先引用的操作,但这里如何划分的粒度不好把握,而且 app 里的类及数量一直变化。2)预先引用解析所有类,会增加引用类的加载耗时和引用语句本身的执行耗时,对于执行耗时,可以通过添加条件判断来优化,如果要解析的类在补丁类名列表里就执行该语句,否则就不执行,对于加载耗时,经过测试发现,加载的耗时较长,而且补丁类不可预期,如果不巧分布在多个区里,累计耗时的影响将会严重得多。3)该方案实现起来特别繁琐,不实用。

这里的关键是能获取到前两个参数的值,第一个参数引用类的 ClassObject,用到了dvmFindLoadedClass:
这里写图片描述

这个方法只用传入类的描述符即可,但必须是已经加载成功的类,在补丁注入成功后,在每个 dex 里找一个固定的已经加载成功的引用类并不难。对于主dex,直接用 XXXApplication 类就行,对于其它分 dex,手Q的分 dex 方案有这样的逻辑:每当一个分 dex 完成注入,手Q都会尝试加载该 dex 里的一个固定空类来验证分 dex 是否注入成功了,所以这个固定的空类可以作为补丁的引用类使用。第二个参数classIdx,可以通过 dexdump -h 获取。因为该方案没有开源,有兴趣的童鞋可以自己尝试实现。

再次感慨,所谓的黑科技都不过是源码理解后的hack罢了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值