市面上热门热修复框架对比
方案对比 | Sophix | Tinker |
---|---|---|
Dex修复 | 同时支持及时生效和冷启动修复 | 冷启动修复 |
资源更新 | 差量包,不用合成 | 差量包,需要合成 |
SO库更新 | 插桩实现,开发透明 | 替换接口,开发不透明 |
代码修复
底层替换方案(热部署 )
这个方案由阿里的Anfix首先提出,在已经加载了的类中直接在native层替换原有的方法,是在原来的类上做修改的。
每个Java方法在Android虚拟机(4.4以下用的是dalvik虚拟机,4.4以上用的是art虚拟机)中都对应着一个ArtMethod指针,ArtMethod记录了这个方法的所有信息,包括所属类、访问权限、代码执行地址等等。通过Method对象得到底层Java函数对应ArtMethod的真实地址,替换ArtMethod结构体中的字段。
因此,当把一个旧方法的所有成员字段都替换成新方法后,执行时所有数据就可以保持和旧方法的一致。这样在所有执行到旧方法的地方,会取得新方法的执行入口、所属class、方案索引以及dex信心,然后像调用旧方法一样顺滑地执行到新方法的逻辑。
兼容问题
但是由于Android代码是开源的,所有的厂商都可以对代码进行改造,而Andfix里ArtMethod的结构是根据公开的Android源码写死的。如果厂商对这个ArtMethod结构体进行修改,就有兼容问题了。
突破底层结构差异
把ArtMethod作为整体进行替换,忽略底层ArtMethod结构的差异,对于所有的Android版本都不再需要区分。
同包名下的权限问题
补丁中的类在访问同包名下的类时,会报出访问权限异常。
虽然补丁中替换的类与app中原来的类是在同一个包名,但是替换的类是补丁包的Classloader加载的,与原先的包不在同一个Classloader,这样就导致两个类无法被判断为同包名。
知道了原因,只需要利用反射把新类的classLoader设置为原来类的即可。
及时生效所带来的限制
这方案只适合方法的替换。而对补丁类里面存在方法增加和减少,以及成员变量字段的增加和减少情况都不适用。
原因是这样的,一旦补丁类中出现了方法的增加和减少,就会导致这个类以及整个Dex的方法数的变化。方法数的变化伴随着方法索引的变化,这样在访问方法时就无法正常的索引到正确的方法了。如果字段发生了增加和减少,和方法变化的情况一样。
不过新增一个完整的、原来类中没有的新类是可以的,这个不受限制。
类加载方案(冷启动修复)
原理
利用Android的ClassLoader——DexClassLoader的加载过程,其中一个环节就是调用DexPathList的findClass方法。
public class<?> findClass(String name,List<Throwable> suppressed){
for(Element element: dexElements){
Class<?> clazz =element.findClass(name,definingContext,suppressed);
if(class !=null ){
return clazz;
}
}
...
return null;
}
Element内部封装了DexFile,DexFile用于加载dex文件,因此每个dex文件对应一个Element.多个Element组成看有序的Element数组dexElements。
findClass()方法里遍历dexElements数组,调用findClass方法,其方法内部会调用DexFile的loadClassBinaryName方法查找类。如果在Element中查找到该类就返回,如果没有就在下一个Element中进行查找。根据上面的查找过程,我们将有bug的类A.class进行修改,再将A.class打包成为dex的补丁包Patch.jar,放在Element数组dexElements的第一个元素,这样会首先找到Patch.dex中的A.class去替换之前存在bug的A.class,排在数组后面的dex文件中存在bug的A.class根据ClassLoader的双亲委托模式就不会被加载,这就是类加载方案。
业界方案对比
~ | QQ空间 | Tinker |
---|---|---|
原理 | 为了解决Dalvik下Unexpected dex problem异常而采用的插桩的方式,单独放一个帮助类在独立的dex中让其他类调用,阻止了类被打上 CLASS_ISPREVERIFIED标志从而规避问题的出现。最后加载补丁dex得到dexFile对象作为参数构造一个Element对象插入到dexElements数组的最前面 | 提供差量包,整体替换dex的方案。差量的方式给出patch.dex,然后将patch.dex与应用的classes.dex合并成一个完整dex,完整dex加载得到的dexFile对象作为参数构建一个Element对象然后整体替换掉旧的Elements数组 |
优点 | 没有合成整包,产物比较小,比较灵活 | 自研dex差异算法,补丁包很小,dex merge成完整dex,Dalvik不影响类加载性能,Art下也不存在必须包含父类/引用类的情况 |
缺点 | Dalvik下影响加载性能,Art下类地址写死,必须包含父类/引用,最后补丁包很大 | dex合并内存消耗在vm heap上,容易OOM,最后导致dex合成失败 |
利用google已经开源的dexmerge方案,把补丁dex和原dex合并成一个完整的dex似乎可行,但仅仅这样还是不够的,多dex下如果dexmerge会有65535方法数超过异常,dexmerge会导致内存风暴,内存不足情况下很容易失败。
新的全量Dex方案
我们可以这样考虑,既然补丁中已经有变动的类了,那只要在原先基线包里的dex里面,去掉补丁中也有的class.这样,补丁+去除了补丁类的基线包,不就等于新app中所有类吗?
Google的multi-dex是把一个app里面的所有类拆分到classes.dex、classes2.dex...之中,而每个dex中都只包含了部分的类的定义,但单个dex也是可以加载的,因为只要把所有的dex都加载进去,本dex中不存在的类就可以在运行期间在其他dex中找到。
因此同理,在基线包dex里面在去掉了补丁中class后,原先需要发生变更的旧的class就被消除了,基线包dex里只包含不变的class。而这些不变的class要用到补丁中新的class会自动地找到补丁dex,补丁包中的新class在需要用到不变的class会自动去找基线包dex的class。这样的话,基线包里面不使用补丁类的class仍然可以按照原来的逻辑做odex,最大的保证dexopt的效果。
那么,怎么在基线包里去掉补丁包中包含的所有类?
先看下dex文件结构
数据名称 | 解释 |
---|---|
header | dex文件头部,记录整个dex文件的相关属性 |
string_ids | 字符串数据索引,记录了每个字符串在数据区的偏移量 |
type_ids | 类似数据索引,记录每个类型的字符串索引 |
proto_ids | 原型数据索引,记录了方法声明的字符串,返回类型字符串,参数类型 |
field_ids | 字段数据索引,记录了所属类、类型以及方法名 |
method_ids | 类方法索引,记录方法所属类名,方法声明以及方法名等信息 |
class_defs | 类定义数据索引,记录指定类各类信息,包括接口,超类,类数据偏移量 |
data | 数据区,保存了各个类的真实数据 |
link_data | 连接数据区 |
这里打算去除dex里的Class,因此我们关心的自然是这里面的class_defs。我们要做的只需要移除定义的入口,对于Class的具体内容不进行删除,这样可以最大可能的减少offset的修改。
对于Application的处理
Application是整个app的入口,因此,在进入到替换的完整dex之前,一定会通过application的代码,因此,application必然是加载在原来的老dex里面的。只有在补丁加载后使用的类,会在新的完整的dex里面找到。
因此,在加载补丁后,如果application类使用其他在新dex里的类,由于不在同一个dex里的类,由于不在同一个dex里,如果application被打上了pre-verified标志,这时就会抛异常。解决方法很简单:只要在jni层把CLASS_ISPREVERIFIED标志清除掉就行了。
但是在开发中,这种清除标志的方案并非一帆风顺,如果这个入口Application是没有pre-verified的,反而有更大的问题。
这个问题是Dalvik虚拟机如果发现某个类没有pre-verified,就会在初始化这个类时做verify操作,这将扫描这个类的所有代码,在扫描过程中对这个类代码里使用的类都进行dvmOptResolveClass操作。
而这个dvmOptResolveClass这是罪魁祸首,它会在Resolve的时候对使用到的类进行初始化,而这个逻辑发生在Application类初始化的时候。此时补丁还没进行加载,所以就会提前加载到原始dex中的类。接下来补丁加载完毕后,这些已经加载的类如果用到了新dex中的类,并且是pre-verified时就会报错。
这里最大的原因在于我们无法把补丁加载提前到dvmOptResolveClass之前,因为在一个app的生命周期里,没有可能到达比入口Application初始化更早的时期了。
而这个问题常见于多dex情形,当存在多dex时,无法保证Applicatiion用到的类和它处于同一个dex中,一般就不会有这个问题。
多dex情况下要想解决这个问题,有两个办法:
第一,让Application用到的所有非系统类都和application位于同一个dex,这样就可以保证pre-verified标志被打上,避免进入dvmOptResolveClass,而在补丁加载完之后,我们再清除pre-verified标志,使得接下来使用其他类也不会报错。
第二,把Application里面除了热修复框架以外的其他代码都剥离开,单独提出放到一个其他类中,这样使得Application不会直接用到过多非系统类,这样,保证这个单独拿出来的类和Application处于同一个类dex的概率比较大。如果想更保险,Application可以采用反射的方式访问这个单独类,这样就彻底把Application和其他类隔离。
资源修复
Instant Run 方案
创建新的AssetManager,通过反射调用addAssetPath方法加载外部的资源,这样新创建的AssetManager就含有所有外部资源
找到所有之前引用到原有AssetManager的地方,通过反射,把引用处替换为新创建的AssetManager。
so库修复
so的修复主要是重新加载so,所以so库修复的基本原理就是加载so.
加载so主要是用到System类的load和loadLibrary方法。System的load方法传入的参数是so在磁盘的完整路径,用于加载指定路径的so.System的loadLibrary方法传入的是so的名称。
上面两种方式加载一个so库,实际上最后都调用nativeLoad这个native方法去加载so库,这个方法的参数 fileName: so 库在磁盘的完整路径名。
接口调用替换方案
sdk提供接口代换System.loadLibrary(String name),优先尝试加载指定目录下的补丁so,加载策略如下:
- 如果存在则加载补丁so库而不是加载安装apk目录下的so库
- 如果不存在补丁so,那么调用System.loadLibrary去加载安装apk目录下的so库。
优点:不需要对不同sdk进行兼容,因为所有的sdk版本都有System.loadLibrary这个接口
缺点:没法修复第三方包的so库
反射注入方案
利用反射注入方式,把补丁so路径插入到nativeLibraryDirectories(sdk>23 换成nativeLibraryPathElements)数组最前面就能达到so库的时候是补丁so库而不是原来so库的目录,从而达到修复的目的。