Android热修复升级探索(三)

对于Android下的冷启动类加载修复,最早的实现方案是QQ空间提出的dex插入方案。该方案的主要思想,就是把插入新dex插入到ClassLoader索引路径的最前面。这样在load一个class时,就会优先找到补丁中的。后来微信的Tinker和手Q的QFix都基于该方案做了改进,而这类插入dex的方案,都会遇到一个主要的问题,就是如何解决Dalvik虚拟机下类的pre-verify问题。

如果一个方法中直接引用到的类和该方法所属类都在同一个dex中的话,那么这个方法的所属类就会被打上CLASS_ISPREVERIFIED,具体判定代码可见虚拟机中的verifyAndOptimizeClass函数。

我们先来看看腾讯的三大热修复方案是如何解决这个问题的:

  • QQ空间的处理方式,是在每个类中插入一个来自其他dex的hack.class,由此让所有类里面都无法满足pre-verified条件。

  • Tinker的方式,是合成全量的dex文件,这样所有class的都在全量dex中解决,从而消除class重复而带来的冲突。

  • QFix的方式,是取得虚拟机中的某些底层函数,提前resolve所有补丁类。以此绕过Pre-verify检查。

以上的三种方案里面,QQ空间方案会侵入打包流程,并且为了hack添加一些臃肿的代码,实现起来很不优雅。而我们一开始采用的QFix的方案,需要获取底层虚拟机的函数,不够稳定可靠。并且,和空间方案一样,有个比较大的问题是无法新增public函数,具体原因后续还将有文章进行详解。

现在看来比较好的方式,就是像Tinker那样全量合成完整新dex。他们的合成方案,是从dex的方法和指令维度进行全量合成,虽然可以很大地节省空间,但由于对dex内容的比较粒度过细,实现较为复杂,性能消耗比较严重。实际上,dex的大小占整个apk的比例是比较低的,而占空间大的主要还是apk中的资源文件。因此,Tinker方案的时空代价转换的性价比不高。

其实,dex比较的最佳粒度,应该是在类的维度。它既不像方法和指令维度那样的细微,也不像bsbiff比较那般的粗糙。在类的维度,可以达到时间和空间平衡的最佳效果。基于这个准则,我们实现了一种完全不同的全量dex替换方案。这套方案目前已经集成进阿里非侵入式热修复方案Sophix[读音:’sɔfiks]中。

一种新的全量Dex方案

一般来说,合成完整dex,思路就是把原来的dex和patch里的dex重新合并成一个。

然而我们的思路是反过来的。

我们可以这样考虑,既然补丁中已经有变动的类了,那只要在原先基线包里的dex里面,去掉补丁中也有的class。这样,补丁+去除了补丁类的基线包,不就等于了新app中的所有类了吗?

参照Android原生multi-dex的实现再来看这个方案,会很好理解。multi-dex是把一个apk里用到的所有类拆分到classes.dexclasses2.dexclasses3.dex、…之中,而每个dex都只包含了部分的类的定义,但单个dex也是可以加载的,因为只要把所有dex都load进去,本dex中不存在的类就可以在运行期间在其他的dex中找到。

因此同理,在基线包dex里面在去掉了补丁中class后,原先需要发生变更的旧的class就被消除了,基线包dex里就只包含不变的class。而这些不变的class要用到补丁中的新class时会自动地找到补丁dex,补丁包中的新class在需要用到不变的class时也会找到基线包dex的class。这样的话,基线包里面不使用补丁类的class仍旧可以按原来的逻辑做odex,最大地保证了dexopt的效果。

这么一来,我们不再需要像传统合成的思路那样判断类的增加和修改情况,而且也不需要处理合成时方法数超过的情况,对于dex的结构也不用进行破坏性重构。

现在,合成完整dex的问题就简化为了——如何在基线包dex里面去掉补丁包中包含的所有类。接下来我们看一下在dex中去除指定类的具体实现。

首先,来看dex文件中header的结构:

0?wx_fmt=png

由dex header就可以取得dex的各个重要属性段,它们在文件中的分布如下所示:

0?wx_fmt=png

这里我们是打算去除dex里的Class,因此我们最关心的自然是这里面的class_defs。

需要注意的是,我们并不是要把某个Class的所有信息都从dex移除,因为如果这么做,可能会导致dex的各个部分都发生变化,从而需要大量调整offset,这样就变得就费时费力了。我们要做的,仅仅是让在解析这个dex的时候找不到这个Class的定义就行了。因此,只需要移除定义的入口,对于Class的具体内容不进行删除,这样可以最大可能地减少offset的修改。这里我们是打算去除dex里的Class,因此我们最关心的自然是这里面的class_defs。

我们来看虚拟机在dexopt的时候是如何找到某个dex的所有类定义的。0?wx_fmt=png正是dexGetClassDef函数返回了类的定义。0?wx_fmt=png而这里pClassDefs是怎么来的呢?

0?wx_fmt=png

由此可以看出,一个类的所有DexClassDef,也就是类定义,是从pHeader->classDefsOff偏移处开始,一个接一个地线性排列着的,一个dex里面一共有pHeader->classDefsSiz个类定义。

由此,我们就可以直接找到pHeader->classDefsOff偏移处,一个个地遍历所有的DexClassDef,如果发现这个DexClassDef的类名包含在我们的补丁中,就把它移除,实现效果如下:

0?wx_fmt=png

接着,只要修改pHeader->classDefsSiz,把dex中类的数目改为去除补丁中类之后的数目即可。

我们只是去除了类的定义,而对于类的方法实体以及其他dex信息不做移除,虽然这样会把这个被移除类的无用信息残留在dex文件中,而这些信息占不了太多空间,但是对dex的处理速度是提升很大的。这样我们就以很小的空间代价换来了高得很多的执行效率。

对于Application的处理

由此,我们实现了完整的dex合成。但仍然有个问题,这个问题所有完整dex替换方案都会遇到,那就是对于Application的处理。

众所周知,Application是整个app的入口,因此,在进入到替换的完整dex之前,一定会通过Application的代码,因此,Application必然是加载在原来的老dex里面的。只有在补丁加载后使用的类,会在新的完整dex里面找到。

因此,在加载补丁后,如果Application类使用其他在新dex里的类,由于不在同一个dex里,如果Application被打上了pre-verified标志,这时就会抛出异常:0?wx_fmt=png对此,我们的解法很简单,既然被设上了pre-verified标志,那么,清除掉它就是了。

类的标志,位于ClassObjectaccessFlags成员。

0?wx_fmt=png

因此,我们只需要在jni层清除掉它即可

clazzObj->accessFlags &= CLASS_ISPREVERIFIED;

这样,在dvmResolveClass找到了新dex里的类后,由于CLASS_ISPREVERIFIED标志被清空,就不会判断所在dex是否相同,从而成功避免抛出异常。

0?wx_fmt=png

接下来,我们来对比一下目前市面上其他完整dex方案是怎么做的。

Tinker的方案,是在AndroidManifest.xml声明中就要求开发者将自己的Application直接换成TinkerApplication。而对于真正app的Application,要在初始化TinkerApplication时作为参数传入。这样TinkerApplication会接管这个传入的Application,在生命周期回调时通过反射的方式调用实际Application的相关回调逻辑。这么做确实很好地将入口Application和用户代码隔离开了,不过需要改造原先存在的Application,如果对Application有更多扩展,接入成本也是比较高的。

Amigo的方案,是在编译过程中,用Amigo自定义的gradle插件将app的Application替换成了Amigo自己的另一个Application,并且将原来的Application的name保存起来,该修复的都修复完了的时候再调用之前保存的的Application 的attach(context),然后将它设回到loadedApk中,最后调用它的onCreate(),执行原有Application中的逻辑。这种方式只是开发者的代码层面无感知,但其实是在编译期间偷偷帮用户做了替换,有点掩耳盗铃的意味,并且这种对系统做反射替换本身也是有一定风险的。

相比之下,我们的Application处理方案既没有侵入编译过程,也不需要进行反射替换,所有的兼容操作都在运行期间都自动做好。接入过程极其顺滑。

dvmOptResolveClass问题与对策

然而我们这种清除标志的方案并非一帆风顺,开发过程中我们发现,如果这个入口Application是没有打上pre-verified标志的,反而有更大的问题。

这个问题是,Dalvik虚拟机如果发现某个类没有pre-verified,就会在初始化这个类时做verify操作,这将扫描这个类的所有代码,在扫描过程中对这个类代码里使用到的类都要进行dvmOptResolveClass操作。

而这个dvmOptResolveClass正是罪魁祸首,它会在resolve的时候对使用到的类进行初始化。

因此,没有打上pre-verified标志的Application类在resolve的时候会对它用到的所有类都进行初始化,从而导致这些类被提前加载。而此时热修复初始化还未开始,补丁也还没应用进去,所以这些被提前加载的类会从原始dex中进行加载,而不是从我们预期的补丁dex中。接下来,当补丁应用完毕后,这些已经加载的类如果是被打上了pre-verified标志,并且它又用到了新dex中的其他类的话,就会在此时报出pre-verified异常。

这里最大的问题在于,我们无法把补丁加载提前到dvmOptResolveClass之前,因为在一个app的生命周期里,没有可能到达比入口Application初始化更早的时期了。

而这个问题常见于多dex情形,因为当存在多dex时,无法保证Application的用到的类和它处于同个dex中。如果只有一个dex,一般就不会有这个问题。

多dex情况下要想解决这个问题,有两种办法:

  • 第一种办法,让Application用到的所有非系统类都和Application位于同一个dex里,这就可以保证Application类的pre-verified标志被打上,避免执行dvmOptResolveClass,而在补丁加载完之后,我们再清除pre-verified标志,这就使得接下来使用其他类也不会报错。

  • 第二种办法,把Application里面除了热修复框架代码以外的其他代码都剥离开,单独提出放到一个其他类里面,这样使得Application不会直接用到过多非系统类。如果想要更保险,Application可以采用反射方式访问这个单独类,这样就彻底把Application和其他类隔绝开了。

第一种方法实现较为简单,因为Android官方multi-dex机制会自动将Application用到的类都打包到主dex中,因此只要把热修复初始化放在attachBaseContext的最前面,大多都没问题。

而第二种方法稍加繁琐,是在代码架构层面进行重新设计,不过可以一劳永逸地解决问题。

总结

总体而言,这套新实现方案更简洁优雅地实现了Dalvik虚拟机下的完整dex的替换,大大降低了完整dex方案的集成成本。现在就可以点击<阅读原文>进行体验,如果你在4.4以下的Android机型确实遇到了本文所说的崩溃,请不要惊慌,仔细按照前面提到的解决方法,调整一下你的Application就好啦。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值