Android热修复升级探索——Dalvik下冷启动修复的新探索

冷启动类加载修复

对于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中。

一种新的全量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的结构:

/*

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

NameFormatDescription
headerheader_itemthe header
string_idsstring_id_item[]string identifiers list. These are identifiers for all the strings used by this file, either for internal naming (e.g., type descriptors) or as constant objects referred to by code. This list must be sorted by string contents, using UTF-16 code point values (not in a locale-sensitive manner), and it must not contain any duplicate entries.
type_idstype_id_item[]type identifiers list. These are identifiers for all types (classes, arrays, or primitive types) referred to by this file, whether defined in the file or not. This list must be sorted by string_id index, and it must not contain any duplicate entries.
proto_idsproto_id_item[]method prototype identifiers list. These are identifiers for all prototypes referred to by this file. This list must be sorted in return-type (by type_id index) major order, and then by argument list (lexicographic ordering, individual arguments ordered by type_id index). The list must not contain any duplicate entries.
field_idsfield_id_item[]field identifiers list. These are identifiers for all fields referred to by this file, whether defined in the file or not. This list must be sorted, where the defining type (by type_id index) is the major order, field name (by string_id index) is the intermediate order, and type (by type_idindex) is the minor order. The list must not contain any duplicate entries.
method_idsmethod_id_item[]method identifiers list. These are identifiers for all methods referred to by this file, whether defined in the file or not. This list must be sorted, where the defining type (by type_id index) is the major order, method name (by string_id index) is the intermediate order, and method prototype (by proto_id index) is the minor order. The list must not contain any duplicate entries.
class_defsclass_def_item[]class definitions list. The classes must be ordered such that a given class's superclass and implemented interfaces appear in the list earlier than the referring class. Furthermore, it is invalid for a definition for the same-named class to appear more than once in the list.
call_site_idscall_site_id_item[]call site identifiers list. These are identifiers for all call sites referred to by this file, whether defined in the file or not. This list must be sorted in ascending order of call_site_off. This list must not contain any duplicate entries.
method_handlesmethod_handle_item[]method handles list. A list of all method handles referred to by this file, whether defined in the file or not. This list is not sorted and may contain duplicates which will logically correspond to different method handle instances.
dataubyte[]data area, containing all the support data for the tables listed above. Different items have different alignment requirements, and padding bytes are inserted before each item if necessary to achieve proper alignment.
link_dataubyte[]data used in statically linked files. The format of the data in this section is left unspecified by this document. This section is empty in unlinked files, and runtime implementations may use it as they see fit.

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

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

我们来看虚拟机在dexopt的时候是如何找到某个dex的所有类定义的。

@android-4.4.4_r2/dalvik/vm/analysis/DexPrepare.cpp/*

正是dexGetClassDef函数返回了类的定义。

@android-4.4.4_r2/dalvik/libdex/DexFile.h/* return the ClassDef with the specified index */

而这里pClassDefs是怎么来的呢?

/*

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

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

Android热修复升级探索——Dalvik下冷启动修复的新探索

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

我们只是去除了类的定义,而对于类的方法实体以及其他dex信息不做移除,虽然这样会把这个被移除类的无用信息残留在dex文件中,但这些信息占不了太多空间,并且对dex的处理速度是提升很大的,这种移除类操作的方式就变得十分轻快。

对于Application的处理

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

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

因此,在加载补丁后,如果Application类使用其他在新dex里的类,由于不在同一个dex里,如果Application被打上了pre-verified标志,这时就会抛出异常:

FATAL EXCEPTION: main

对此,我们的解法很简单,既然被设上了pre-verified标志,那么,清除掉它就是了。

类的标志,位于ClassObjectaccessFlags成员。

struct ClassObject : Object {

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

clazzObj->accessFlags &= ~CLASS_ISPREVERIFIED;

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

ClassObject* dvmResolveClass(const ClassObject* referrer, u4 classIdx,

接下来,我们来对比一下目前市面上其他完整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的时候对使用到的类进行初始化,而这个逻辑是发生在Application类初始化的时候。此时补丁还没进行加载,所以就会提前加载到原始dex中的类。接下来当补丁加载完毕后,这些已经加载的类如果用到了新dex中的类,并且又是pre-verified时就会报错。

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

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

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

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

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

第一种方法实现较为简单,因为Android官方multi-dex机制会自动将Application用到的类都打包到主dex中,因此只要把热修复初始化放在attachBaseContext的最前面,大多都没问题。而第二种方法稍加繁琐,是在代码架构层面进行重新设计,不过可以一劳永逸地解决问题。

总结

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

相关推荐