Qzone 超级补丁热修复方案原理

介绍

Qzone 超级补丁技术基于dex分包方案,使用了多dex加载(multidex)的原理,大致的过程就是:把BUG方法修复以后,放到一个单独的dex文件,然后插入到dexElements数组的最前面,让虚拟机去加载修复完后的方法。

该方案的灵感来源?
没错就是类加载机制,相信大部分同学都对它有所了解吧。

ClassLoader 类加载机制

Android应用程序本质上使用的是java开发,使用标准的java编译器编译出Class文件,和普通的java开发不同的地方是把class文件再重新打包成dex类型的文件,这种重新打包会对Class文件内部的各种函数表、变量表等进行优化,最终产生了odex文件。odex文件是一种经过android打包工具优化后的Class文件,因此加载这样特殊的Class文件就需要特殊的类装载器,所以android中提供了DexClassLoader类。

类图:
clipboard_mh1534131797938.jpg

Android使用的是Dalvik虚拟机装载class文件,所以classloader不同于java默认类库rt.jar包中java.lang.ClassLoader, 可以看到android中的classloader做了些修改,但是原理还是差不多的。
学过java的同学都知道, 类加载器是采用双亲委派机制来进行类加载的。

双亲委托模式是什么?

某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父类加载器,依次递归,如果父类加载器可以完成类加载任务,就成功返回;只有父类加载器无法完成此加载任务时,才自己去加载。

为什么要用双亲委托模式?

  1. 可以避免重复加载,当父加载器已经加载了该类的时候,就没有必要子ClassLoader再加载一次。
  2. 安全性考虑,防止核心API库被随意篡改。我们试想一下,如果不使用这种委托模式,那我们就可以随时使用自定义的String来动态替代java核心api中的定义类型,这样会存在非常大的安全隐患。

双亲委派机制 从ClassLoader.java 源代码可以清晰的看出来:
ClassLoader.java
classloader.png
流程大概如下:
1.判断类是否已经加载过;
2.父类加载器优先加载;
3.parent为null,则调用BootstrapClassLoader进行加载 ;
4.如果class依旧没有找到,则调用当前类加载器的findClass方法进行加载;

BaseDexClassLoader.java
baseDexClassLoader.png

DexPathList.java
dexpathllist.png

DexFile.java(\dalvik\dx\src\com\android\dx\dex\file\DexFile.java)
dexfile.png

defineClassNative(android4.4版本,区分ART 和Dalvik两种情况)
1.ART 环境 [art\runtime\native\dalvik_system_DexFile.cc]
defineclassnative.png

2.Dalvik 环境 [\dalvik\vm\native\dalvik_system_DexFile.cpp ]
dvmdefine.png

(注:dvmDefineClass函数则是类加载机制中最为核心的逻辑,由于和本文深入探索的方向关联性不强,就不作深究了。源码在 dalvik2/vm/oo/Class.cpp中,有兴趣可自行研究。)

从以上类加载机制的源码中我们可以分析出,当找类的时候,会按顺序遍历dex文件,然后从当前遍历的dex文件中找类,如果找类则返回,如果找不到继续从下一个dex文件查找。理论上,如果在不同的dex中有相同的类存在,那么会优先选择排在前面的dex文件的类,Qzone方案的灵感就是从上述的DexPathList类中的for循环体而来。
qzone1.png

在此基础上,Qzone 团队构想了热补丁的方案,把有问题的类打包到一个dex(patch.dex)中去,然后把这个dex插入到Elements的最前面,如下图:
qzone2.png

如果懂拆分dex的原理的话,大家应该很快就会实现该方案。如果没有拆分dex的项目的话,可以参考一下谷歌的multidex方案实现,然后在插入数组的时候,把补丁包插入到最前面去。

当patch.dex中包含Main.class时就会优先加载,在后续的DEX中遇到Main.class的话就会直接返回而不去加载,这样就达到了修复的目的。看似问题很简单,轻松的搞定了,Qzone一开始按照以上思路进行了实践,但在实际操作中,出现了 unexpected DEX 的异常。这个问题是因为在Dalvik环境中,类被打上CLASS_ISPREVERIFIED的标志,主动抛出异常报错。

为什么系统要给类打上CLASS_ISPREVERIFIED的标志?

我们知道,在APK安装时,虚拟机需要将classes.dex优化成odex文件,然后才会执行。在这个过程中,会进行类的verify操作,为了提高运行性能,如果调用关系的类都在同一个DEX中的话,就会被打上CLASS_ISPREVERIFIED的标志,然后写入odex文件,表明它没有被其他Dex的类引用。

规避 Dalvik 下 “unexpected DEX” 的异常。
如下是 dalvik 的一段源码,当补丁安装后,首次使用到补丁里的类时会调用到这里, 源代码如下:

[dalvik/vm/oo/Resolve.cpp]
resolve.png

从代码逻辑我们可以看出,需要同时满足代码中标出来的三个条件,才会出现异常,这三个条件的含义如下:
threeCase.png

因此,想要避免补丁类加载时发生 “unexpected DEX ” 的异常,则需要从以上三个地方来入手。

Qzone 的超级补丁方案采用的是通过绕过这里的第二个判断来避免报错的。如果一个类被打上了CLASS_ISPREVERIFIED标志,那么就会进行dex的校验。而要避免报错,首先得弄清楚它是什么条件下才会被打上。继续搜索源代码,发现在DexPrepare.cpp找到了如下代码:

[dalvik\vm\analysis\DexPrepare.cpp]
dexprepare.png

这段代码是dex转化成odex(dexopt)的代码中的一段,我们知道当一个apk在安装的时候,apk中的classes.dex会被虚拟机(dexopt)优化成odex文件,然后才会拿去执行。
虚拟机在启动的时候,会有许多的启动参数,其中一项就是verify选项,当verify选项被打开的时候,上面doVerify变量为true,那么就会执行dvmVerifyClass进行类的校验,如果dvmVerifyClass函数校验类成功,那么这个类会被打上CLASS_ISPREVERIFIED的标志。

DexClassDef 结构体代码:
struct DexClassDef {
u4 classIdx; //类的类型, DexTypeId中的索引下标
u4 accessFlags; //类的访问标志
u4 superclassIdx; //父类类型, DexTypeId中的索引下标
u4 interfacesOff; //接口偏移, 指向DexTypeList的结构
u4 sourceFileIdx; //源文件名, DexStringId中的索引下标
u4 annotationsOff; //注解偏移, 指向DexAnnotationsDirectoryItem的结构
u4 classDataOff; //类数据偏移, 指向DexClassData的结构
u4 staticValuesOff; //类静态数据偏移, 指向DexEncodedArray的结构
};

而具体的校验过程,即dvmVerifyClass函数是什么样子的呢?我们继续往下探索。
代码在DexVerify.cpp中,如下:
[dalvik\vm\analysis\DexPrepare.cpp 的 dvmverifyclass 函数]
dvmverifyclass.png

该方法做了三件事情:
1. 是否已被校验过?
2. 验证clazz->directMethods方法,directMethods包含了以下方法:
● static方法
● private方法
● 构造方法
3. clazz->virtualMethods。虚函数=override方法?

概括一下就是,只要在static方法,private方法,构造方法,override方法中直接引用了其他dex中的类,那么这个类就不会被打上CLASS_ISPREVERIFIED标记。也就是说如果以上方法中直接引用到的类(第一层级关系,不会进行递归搜索)和clazz都在同一个dex中的话,那么这个类就会被打上CLASS_ISPREVERIFIED。

搞清了来龙去脉,所以就可以从这些地方入手。最终Qzone的方案是往所有补丁类的构造函数里面插入了一段代码,来引用另外一个dex的类,防止类被打上CLASS_ISPREVERIFIED标志。代码如下:

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

Qzone方案的大致实现流程如下:

打补丁包:
1.在正式版本发布的时候,会生成一份缓存文件,里面记录了所有class文件的md5,还有一份mapping混淆文件。
2. 在后续的版本中使用-applymapping选项,应用正式版本的mapping文件,然后计算编译完成后的class文件的md5和正式版本进行比较,把不相同的class文件打包成补丁包。

Hook及加载patch操作:
1. 打包过程中,会往所有补丁类的构造函数里面插一段代码。
2. 其中AntilazyLoad类会被打包成单独的hack.dex,当安装apk的时候,patch.dex内的类都会引用一个在不相同dex中的AntilazyLoad类,这样就防止了类被打上CLASS_ISPREVERIFIED的标志,只要没被打上这个标志的类都可以进行打补丁操作。
3. 先加载进来AntilazyLoad类,不然AntilazyLoad类会被标记为不存在,即使后续加载了hack.dex包也于事无补。
4. 获取到当前应用的Classloader,即为BaseDexClassloader。
5. 通过反射获取到它的DexPathList属性对象pathList。
6. 通过反射调用pathList的dexElements方法把补丁包patch.dex转化为Element[]。
7. 两个Element[]进行合并,把patch.dex放到最前面去。
8. 加载Element[],达到修复目的。

该方案之所以选择构造函数进行插入代码,是因为它不增加方法数,一个类即使没有显式的构造函数,也会有一个隐式的默认构造函数。
细节:Qzone使用的是在字节码插入代码,而不是源代码插入,使用的是java assist库来进行字节码插入的。

互动问题:思考一下,除了通过防止补丁类被打上CLASS_ISPREVERIFIED标志,我们还可以想到有哪些方式来解决Dalvik下的 “unexpected DEX” 异常问题?

Qzone方案总结

优势:
1.没有合成整包(和微信Tinker比起来),输出产物比较小,比较灵活。
2.可以实现类替换,兼容性较高。(某些三星手机不起作用)

不足:
1.不支持即时生效,必须通过重启才能生效。
2.为了实现修复这个过程,必须在应用中加入两个dex, dalvikhack.dex中只有一个类,对性能影响不大,但是对于patch.dex来说,修复的类到了一定数量,就需要花不少的时间加载。对大型应用来说,启动耗时增加2s以上是很难接受的事。
3.在ART模式下,如果类修改了结构,就会出现内存错乱的问题。为了解决这个问题,就必须把所有相关的调用类、父类子类等等全部加载到patch.dex中,这会导致ART下的补丁包异常的大,进一步增加应用启动加载的时候,耗时更加严重。

其中第二点不足,即性能无法提升的原因:
插桩的解决方案会影响到运行时性能的原因在于:app 内的所有补丁类都预埋引用一个独立 dex 的空类,导致安装 dexopt 阶段的 preverify 失败,运行时将再次 verify+optimize。
另外即使后期发布版本实际上无需发布补丁,我们也需要预埋插桩的逻辑,这本身也是不合理的一点,所以确实有必要去探索新的方向,既保留补丁的能力,同时去掉插桩带来的负面影响。

第三点不足,即ART模式下补丁包异常大的原因:
ART(Android Runtime)是Android在4.4版本中引入的新虚拟机环境,在5.0版本正式取代了Dalvik VM。ART环境下,App安装时其包含的Dex文件将被dex2oat预编译成目标平台的机器码,从而提高了App的运行效率。在这个预编译过程中,dex2oat对目标代码的优化过程与Dalvik VM下的dexopt有较大区别,尤其是在5.0版本以后ART环境下新增的方法内联优化,由于方法内联改变了原本的方法分布和调用流程。
方法内联之所以会导致优先加载补丁Dex的方案出现问题,本质上是因为补丁Dex只覆盖了旧Dex里的一部分类,一旦被覆盖的类的方法被内联到了调用者里,则加载类的过程还是正常的,即从补丁Dex里加载了新版本的类。但由于内联,执行流程并未跳转到新的方法里,于是所有关于新版本的类的方法、成员、字符串的查找用的就都是旧方法里的索引了。因此,在ART模式下,如果类修改了结构,就会出现内存错乱的问题。为了解决这个问题,就必须把所有相关的调用类、父类子类等等全部加载到patch.dex中,这会导致ART下的补丁包异常的大,进一步增加应用启动加载的时候,耗时更加严重。

官方介绍文章:《安卓App热补丁动态修复技术介绍》 - QQ空间开发团队
https://mp.weixin.qq.com/s__biz=MzI1MTA1MzM2Nw==&mid=400118620&idx=1&sn=b4fdd5055731290eef12ad0d17f39d4a

猜你喜欢

转载自blog.csdn.net/qq_22393017/article/details/81811101