第3章: 冷启动代码修复
热修复的根本原理是Native层方法替换,所以当类结构变化(如增减method/field)时热部署模式会受限。但冷部署可以突破这种约束,可以作为热部署的补充。
冷启动类加载原理
冷启动方案概述
| 项目 | QQ空间 | Tinker |
|---|---|---|
| 原理 | 为解决Dalvik情况下 unexpected dex problem 异常而采用插桩,单独放一个帮助类在独立的dex让其他类调用,防止类被打上 CLASS_ISPREVERIFIED。 最后加载补丁dex得到一个Element对象插入到 dexElements数组的最前面 | 提供差量包patch.dex,然后将patch.dex与应用的 classes.dex 合并成一个完整的 dex,完整的dex加载构建Element 然后整体替换掉旧的 Elements 数组 |
| 优点 | 产物小,灵活 | 补丁包小,Dalvik情况下不影响性能,ART环境下也不存在必须包含父类/引用类的情况 |
| 缺点 | Dalvik 下影响类加载性能 | dex合并内存消耗在 vm heap 上,容易导致 OOM |
插桩实现的前因后果
如果直接把补丁类打入补丁包中而不做任何处理的话,在类加载的时候会产生异常并退出,来分析下异常产生的原因:
加载一个dex文件时如果不存在 odex 文件,那么就会执行 dexopt 操作,这个操作里面会调用到 verifyAndOptimizeClass 执行真正的 verify/optimize 操作:
1 | if(doVerify) { |
第一次安装 APK 时,会对原dex做 dexopt ,假如APK只有一个dex,dvmVerifyClass(clazz) 返回true,然后 APK 中所有的类都会被打上 CLASS_ISPREVERIFIED 标记。接下来执行 dvmOptimizeClass ,类接着被打上 CLASS_ISOPTIMIZED 标志。
上述的 dvmVerifyClass 目的是防止校验类合法性被篡改,此时会对类的每个方法进行校验,这里只需要知道如果类所引用到的类和当前类都在同一个dex中的话,就会返回为true
dvmOptmizeClass : 类优化,这过程会将部分指令优化成虚拟机内部指令,提升了方法的执行效率。
现在假定 A 是补丁中的类,所以 A 肯定在一个单独的 dex 中,这时候类B 引用到补丁类 A,所以会尝试解析类 A。但是由于 B 是已经被打上了 CLASS_ISPREVERIFIED,调用的类 A 又与 B 不在同一个 dex ,因此会报 dvmThrowIllegalAccessError 异常。
为了解决这个问题,一个单独的无关类被放在一个单独的dex中,原 dex 中所有的类构造函数都引用这个类,一般的解决方案是侵入dex打包流程,利用 .class 字节码修改技术。插桩由此而来,根据前面的介绍,原dex 所有的类都没有 CLASS_ISPREVERIFIED 标志,因此不会报这个异常。
但是,插桩会给类加载效率带来比较严重的影响,如果没有打上 CLASS_CLASS_ISPREVERIFIED / CLASS_ISOPTIMIZED 标志,那么累的校验和优化都将在类初始化阶段进行。正常情况下类的校验和优化都仅在APK 第一次安装执行 dexopt 阶段进行,单个类加载耗时并不多,但是同一时间加载大量类的情况下,这种耗时就会放大。因此启动的时候容易白屏,这是没法容忍的。
避免插桩的 QFix 方案
它的思路是,在dexopt 后,反编译 dex 为 smali 修改代码避免异常,但是由于是在 dexopt 后绕过的,dexopt 会改变原有的很多逻辑,这可能会导致比较严重的bug。因此,最终会采用自研的全量 dex 方案。
Art下冷启动实现
为了解决 Art 下类地址写死的问题,Tinker 通过 dex merge 成一个全新的 dex 整体替换掉旧的 dexElements 数组。
Dalvik 虚拟机尝试加载一个压缩文件的时候,只会加载 classes.dex ,如果压缩文件中有多个 dex, 其他 dex 文件会被直接忽略掉;而 Art 虚拟机则不一样,优先加载 primary dex (也就是 classes.dex),后续会加载其他 dex 。所以,在 Art 环境下,补丁类只需要放到 classes.dex 中即可!后续出现在其他 dex 中的 “补丁类”是不会被重复加载的,所以 Art 下冷启动解决方案:
将补丁 dex 命名为 classes.dex ,原来的dex依次命名为 classes(2,3,4…),然后一起打包为一个压缩文件,通过 DexFile.loadDex 得到 DexFile 对象,最后整体替换老旧的 dexElements 数组即可。
该方案和Tinker 的方案对比如下:

其他方面
DexFile.loadDex 把 dex 文件解析并加载到 native 内存的时候,如果dex 不存在 odex ,则会生成一个优化的 odex (Dalvik 和 Art 都会),所以虚拟机最后执行的是 odex。现在假如补丁 dex 足够大,那么 生成 odex 是很耗时的。不过上面的冷启动方案,在 Dalvik 下影响比较小,因为 loadDex 只是操作补丁包;但是在Art 环境下影响比较大了,因为 loadDex 是补丁 dex 和原有 dex 合并的完整包,所以是比较耗时的。
为了解决这个问题,要把 loadDex 当做一个事务,如果中途被打断,那就删除 odex 文件,重启的时候时候如果发现 odex ,loadDex 完之后 ,反射注入/替换 dexElements 数组,实现打包。如果不存在 odex 文件,那么重启一个子线程 loadDex ,重启后再生效。
为了补丁包安全,要对补丁包进行签名校验,防止整个补丁包被篡改,但是虚拟机执行的是 odex ,因此还需要对 odex 文件进行 MD5 校验,如果匹配,则直接加载,否则,重新生成 odex 文件,防止 odex 文件被篡改。
完整的方案考虑
冷启动修复方案机会可以修复任何场景下的代码缺陷,但是注入前被加载的类(如: Application 类)肯定是不能修复的。所以我们将其作为保底方案,在没法应用热部署或者热部署失败的情况下,最后都会应用代码冷启动方案,所以我们的补丁是同一套的。具体实施方案对 Dalvik 和 Art 下分别做了处理:
在Dalvik 下采用自研的全量 dex 方案
Art 本身支持多 dex,我们仅仅把补丁dex 作为主dex (classes.dex) 加载而已
冷启动方案限制
重新认识多态
实现多态的技术一般叫做动态绑定,是指在执行期间判断所引用对象的实际类型,根据实际类型调用相应方法。多态一般指的是非静态非私有方法的多态,field 和 静态方法都不具有多态性。
虚拟机在加载类时回味其生成一张 vtable 表,里面存放当前类所有 virtual 方法的一个数组,当前类和所有继承父类的 public/protected/default 方法就是 virtual 方法,因为 public/protected/default 方法是可以被继承的, private/static 不再这个范畴,因为不能继承。具体操作:
整体复制父类 vtable 到子类的 vtable
遍历子类的 virtual 方法集合,如果方法圆形一致,说明是重写父类方法,那么在相同索引位置处,子类重写方法覆盖掉 vtable 中父类的方法
若方法原型不一致,那么把该方法添加到 vtable 末尾
所以就有:假如父类A的 vtable[0]=A.a_t1 ,vtable[1]=A.a_t2 方法,那么类B可能是: vtable[0]=B.a_t1, vtable[1]=A.a_t2, vtable[2]=B.b_t1 。
至于为什么field 和 static 方法为什么不具多态性质,主要是因为: 在这场景下,是从当前变量的引用类型而不是实际类型中查找,如果找不到,再去父类递归查找。
限制
预先储备多态知识,来看如下例子:
1 | public class Demo { |
也就是修复之后,A 类中新增了 a_t1 方法,Demo 类不做任何修复,测试发现应用补丁后,test_addMethod() 方法执行输出了 Sophix:A a_t1 ,表明在执行 t2 方法的时候,实际调用了 t1 方法!
前面说了,dex 文件第一次加载的时候会有 verify 和 optimize 过程,优化过程会把 invoke-virtual 重写为 invoke-virtual-quick,后面跟的立即数就是该方法在类 vtable 中的索引值,这样执行更高效。所以我们知道了问题的根源了:打包前类A的 vtable 值为 vtable[0]=A.a_t2,打包后类 A 新增了 ,A.a_t1 方法,那么类A的vtable 变为了: vtable[0]=A.a_t1 ,vtable[1]=A.a_t2,但是 test_addMethod 中执行 obj.a_t2() 已经被优化为了 A.vtable[0],所以修复前调用的是 a_t2方法,修复后变成了调用 a_t1 方法了,导致方法调用错乱!
终极解决方案
由上面可知,由于多态影响,QFix方案会遇到问题,我们最后的希望就只能依靠类似Tinker的完整dex解决方案,并且利用Google 已经开源的 DexMerge 方案,把补丁 dex 和原 dex 合并成一个完整的dex 似乎是可行的。但是,这样还是不够的,多dex下如果DexMerge抛出 65535 方法数异常,DexMerge 会导致内存风暴,内存不足的情况下容易更新失败,完整的 dex 合成需要在移动端进行,实现复杂。
Dalvik 下完整dex方案的新探索
冷启动类加载修复
冷启动类加载修复最早实现方案是 QQ空间提出的 dex 插入方案:把心dex插入到 ClassLoader 索引最前端,这样在加载一个类时,会优先查找补丁中的类。但是,这类插入dex的方案都会在 Dalvik 虚拟机下遇到 pre-verify问题。腾讯三大热修复方案解决该问题的思路如下:
QQ空间插桩,每个类中插入一个来自其他 dex 的 hack.class ,使得所有类都无法满足 pre-verified 条件
Tinker 的方案是合成全量的 dex ,这样所有类都在全量 dex 中解决,从而消除类重复带来的冲突
QFix 的方式是获取虚拟机中的某些底层函数,提前解析所有补丁类,以此绕过 pre-verify 检查
以上三种方案,插桩会侵入打包流程,并且插桩添加臃肿的代码,不优雅;QFix 方案需要获取底层虚拟机的函数,不稳定可靠。并且,插桩和 QFix 方案都不能新增 public 函数,具体原因后续讲解。 Tinker 需要从 dex 方法和指令维度全量合成dex ,虽然节省空间,但是性能消耗比较严重。
一种全新的全量 Dex 方案
一般来说,合成完整dex,思路就是把原有 dex 和补丁包里的 dex 重新合并成一个,然而我们的思路是反过来的。可以这样考虑,既然补丁中已经有变动类了,那么只要在原先基线包里的dex中,去掉补丁中也有的类,这样,补丁+去除了补丁类的基线包,不就等于新 App 中所有类了吗?
参照Android原生的 multi-dex 理解: multi-dex 是把一个 APK 里用到的所有类拆分到 classes.dex、classes2.dex 、classes3.dex 等,每个dex 只包含了部分类定义,但是单个 dex 也是可以加载的,只要把所有dex 都加载进去,本 dex 中不存在的类就可以在运行期间在其他 dex 中找到。
基线 dex 在去掉了补丁中的类后只包含不变的类了,这些不变的类在用到补丁中的新类时会自动找到补丁 dex ,补丁dex 中的心累在需要用到不变的类时也会找到基线 dex 的类。这样做的好处是: 基线包里面不使用补丁中类的仍然可以按照原来的逻辑做 odex,最大限度地保证了 dexopt 效果。同时,避免了传统dex 合成遇到的 65535 方法数超了的问题、对dex破坏性重构的问题。
现在,问题简化为如何在基线包中去掉补丁包中包含的所有类!。需要注意的是:并不是要把某个类的所有信息都从dex移除,因为这样做的话,会导致dex 的各个部分都要发生变化。我们只需移除定义的入口,解析这个dex的时候找不到这个类的定义即可。最大限度地减少修改。
具体实现方案是:在 dex 的 pHeader->classDefOff 偏移处 ,把相应的 ClassDef 移除即可
对于 Application的处理
Application 是整个App入口,在进入到替换的完整 dex 之前,一定会通过 Application 代码,所以,Application 一定是加载在原来的 dex 里面的,只有在补丁加载后使用的类,会在新的完整 dex 里面找到。在补丁加载后,如果 Application 类使用其他新 dex 里的类,由于不在同一个 dex 里, 而如果 Application 被打上了 pre-verify 标记,这时候就会抛出以前说的异常。
解决方案:在 JNI 层清除掉它的 pre-verify 标记即可
但这样还会引出问题:Dalvik 发现某个类没有 pre-verifid,就会在初始化这个类的时候做 verify 操作,对这个类使用到的类都要进行 opt操作。此时补丁还未进行加载,所以就会导致提前加载到原始dex中的类,当补丁加载完毕之后,当这些已经加载的类用到新dex 中的类,并且又是 pre-verified 时就会报错。这种问题在单 dex 情况下不会出现,但是多 dex 情形就会发生,解决办法如下:
让 Application 用到的所有非系统类都和 Application 位于同一个 dex ,这样可以保证 pre-verified 被打上,而在补丁加载完成后,再清除 pre-verified 标志
把 Application 里面除了热修复框架代码外的其他代码都剥离到一个其他类中,这样使得Application 不会直接用到过多非系统类,这样,保证这个单独拿出来的类和Application 处于同一个 dex 的概率还是很大的。如果要更保险,Application 可以采用反射的方式访问这个单独的类,这样彻底把 Application 和 其他类隔绝。
其他方案针对 Application 的解决办法:
Tinker 方案是在 AndroidManifest.xml 中要求开发者写Tinker 的 Application ,真正的 Application 在 初始化 TinkerApplication 时作为参数传入,这样,TinkerApplication 会接管真正的 Application。如果对 Application 有更多扩展的话,开发者接入成本也是比较高的。
Amigo 的方案是在编译过程中,用 Amigo 自定义的 gradle 插件将 App 的 Application 换成 Amigo 自己的 Application,并且保存真正的 Application 的name,在问题修复完成之后,再反射真正的 Application 之后调用其 attach(context) 方法。虽然开发者无感,但是这种系统反射本身是有一定风险的。
入口类与初始化时机
如果要使热修类之前使用的其他类最少,只能放在Application中。那么,放在 Activity 中是不是也可以呢?当然,如果你没有 Application ,放到Activity中似乎没什么问题。但是,如果AndroidManifest 中注册了ContentProvider ,那么 ContentProvider 的 onCreate 方法是先于 Activity 的onCreate 调用的(事实上比Application的onCreate 都早)。这就可能导致某些类被提前引入了。
如果放在Application中又有2中选择:
放attachBaseContext中,这是 Application 中最早被执行的代码了,当然没问题,但是App申请的权限还没授予完成,会遇到无法访问网络之类的问题,因此无法下载新补丁
放在 onCreate 中,和Activity 一样,也会晚于 ContentProvider,这就需要自己保证没有 ContentProvider 以及第三方库也没有
注意:真实的启动顺序: Application.attachBaseContext->ContentProvider.onCreate->Application.onCreate-> Activity.onCreate