[读书笔记] 深入探索Android热修复技术原理 (手淘技术团队)

读书笔记,写写画画记忆更深刻,如果还能梳理一下的话,那就更好了。

热修复技术介绍

探索之路

最开始,手淘是基于Xposed进行了改进,产生了针对Android Dalvik虚拟机运行时的Java Method Hook技术——Dexposed。

但该方案对于底层Dalvik结构过于依赖,最终无法兼容Android 5.0 以后的ART虚拟机,因此作罢。

后来支付宝提出了新的热修复方案AndFix。

AndFix同样是一种底层替换的方案,也达到了运行时生效即时修复的效果,并且重要的是,做到了Dalvik和ART环境的全版本兼容

阿里百川结合手淘在实际工程中使用AndFix的经验,对相关业务逻辑解耦后,推出了阿里百川HotFix方案,并得到了良好的反响。

此时的百川HotFix已经是一个很不错的产品了,对基本的代码修复需求都可以解决,安全性和易用性都做的比较好。然而,它所依赖基石,AndFix本身是有局限性的。且不说其底层固定结构的替换方案不好,其使用范围也存在着诸多限制,虽然可以通过改造代码绕过限制来达到相同的修复目的,但这种方式即不优雅也不方便。而更大的问题,AndFix只提供了代码层面的修复,对于资源和so的修复都未能实现。

在Android平台上,业界除了阿里系之外,比较著名的修复还有:腾讯QQ空间的超级补丁技术、微信的Tinker、饿了么的Amigo、美团的Robust等等。不过他们各自有自身的局限性,或者不够稳定,或者补丁过大,或者效率低下,或者使用起来过去繁琐,大部分技术上看起来似乎可行,但实际体验并不好。

终于在2017年6月,阿里巴巴手淘技术团队联合阿里云正式发布了新一代的非侵入式的Android热修复方案——Sophix。

Sophix的横空出世,打破了各家热修复技术纷争的局面。因为我们可以满怀信心的说,在Android热修复的三大领域:代码修复、资源修复、so修复方面,以及方案的安全性和易用性方面,Sophix都做到了业界领先。

Sophix的诞生,期初是对原先的阿里百川的HotFix 1.X版本进行升级衍进。

Sophix保留了阿里百川HotFix的服务端整套请求流程,以及安全校验部分。

而原本的热修复方案,主要限制在于AndFix本身。

AndFix自身的限制几乎是无法绕过的,在运行时对原有类机构是已经固化在内存中的,它的一些动态属性很难进行扩展。
并且由于Android系统的碎片化,厂商的虚拟机底层结构都不是确定的,因此直接基于原先机制进行扩展的风险很大。

方案对比

方案对比 Sophix Tinker Amigo
Dex修复 同时支持即时生效和冷启动修复 冷启动修复 冷启动修复
资源更新 差量包,不用合成 差量包,需要合成 全量包,不用合成
SO库更新 插桩实现,开发透明 替换接口,开发不透明 插桩实现,开发透明
性能损耗 低,仅冷启动情况下有些损耗 高,有合成操作 低,全量替换
四大组件 不能增加 不能增加 能增加
生成补丁 直接选择已经编好的新旧包在本地生成 编译新包时设置基线包 上传完整新包到服务端
补丁大小
接入成本 傻瓜式接入 复杂 一般
Android版本 全部支持 全部支持 全部支持
安全机制 加密传输及签名校验 加密传输及签名校验 加密传输及签名校验
服务端支持 支持服务端控制 支持服务端控制 支持服务端控制

可以看到,Sophix在各个指标上都占优势。而其中唯一不支持的地方就是四大组件的修复。

这是因为,如果要修复四大组件,必须在AndroidManifest里面预先插入代理组件,并且尽可能声明所有权限、而这么做就会给原先的app添加很多臃肿的代码,对app运行流程的侵入性很强,所以,本着对开发者透明与代码极简的原则,这里不做多余处理。

设计理念

Sophix的核心设计理念——就是非侵入性。

在Sophix中,唯一需要的就是初始化和请求补丁两行代码,甚至连入口Application类我们都不做任何修改,这样就给了开发者最大的透明度和自由度。

代码修复

代码修复有两大主要方案,一种是阿里系的底层替换方案,另一种是腾讯系的类加载方案。

两种方案各有优劣:

  • 底层替换方案限制颇多,但时效性最好,加载轻快,立即见效。
  • 类加载方案时效性差,需要重新冷启动才能见效,但修复范围广,限制少。
底层替换方案是在已经加载了的类中直接替换掉原有的方法,是在原来类的基础上进行修改。无法实现对原有类方法和字段的增减。
底层替换最为人狗命的地方是底层替换的不稳定性。

类加载方案的原理是app重新启动后让ClassLoader去加载新的类。在app运行过程中,所有需要发生变更的类已经被加载过了,在Android上无法对一个类进行卸载的。

如果不重启,原来的类还在虚拟机中,就无法加载新类。
因此,只有在下次重启的时候,在还没走到业务逻辑之前抢先加载补丁中的新类,这样后续访问这个类时,就会使用新类,从而达到热修复的目的。


说说Tinker:

微信的Tinker方案是完整的全量dex加载,并且可谓是将补丁合成做到了极致,然而我们发现,精密的武器并非适用于所有战场。

Tinker的合成方案,是 从dex的方法和指令维度 进行全量合成,整个过程都是自己研发。

虽然可以很大的节省空间,但对于dex内容的比较粒度过细,实现较为复杂,性能消耗严重。

实际上,dex的大小占整个apk的比例是比价低的,一个app里面的dex文件大小并不是主要部分,而占空间大的主要还是资源文件。

因此,Tinker方案的时空代价转换的性价比不高。

其实,dex比较的最佳粒度,应该是在类的维度。它即不像方法和指令维度那样的细微,也不像bsbiff比较那样的粗糙。在类的维度,可以达到时间和空间平衡的最佳效果。


既然两种方案各有其特点,把他们联合起来就是最好的选择了。

Sophix的代码修复体系正式同时涵盖了两种方案。在补丁生成阶段,补丁工具会根据实际代码的变动情况进行自动选择:

  • 针对小修改,在底层替换方案限制范围内,就直接采用底层替换修复。
  • 对于代码修复超出底层替换限制的,会使用类加载替换,虽然及时性没有那么好,但总归可以达到热修复的目的。

另外,在运行时还会再判断所运行的机型是否支持热修复,这样即使机型底层虚拟机构造不支持,还是会走类加载修复,从而达到最好的兼容性。

资源修复

目前市面上的很多热修复方案基本上都参考了Instant Run的是实现。

简单来说,Instant Run中的资源热修复方案分为两步:

  1. 构造一个新的AssetManager,并通过反射调用addAssetPath,把这个完整的新资源包加入到AssetManager中,这样就得到了一个含有所有新资源的AssetManager。
  2. 找到所有之前引用到原有AssetManager的地方,通过反射,把引用处替换为新的AssetManager。

其实,在该方案中有大量的代码都是在处理兼容性问题和找到所有AssetManager的引用。真正替换的代码其实很简单。

Sophix并没有直接采用Instant Run技术,而是构造了一个package id为0x66的资源包,其实这个资源包里面只有修改了的资源项,直接在原有的AssetManager中addAssetPath就可以了。

由于补丁包的Package Id为0x66,不与目前已经加载的0x7f资源段冲突,因此直接加入到已有的AssetManager中就可以直接使用了。

SO库修复

SO库的修复本质上是对native方法的修复和替换。

我们采用的是类似 类修复反射注入方式 。把补丁so库插入到nativeLibraryDirdetories数组的最前面。就能够达到加载so库的时候是补丁so库,而不是原来so库的目录,从而达到修复的目的。

采用这种方案,完全有Sophix启动期间反射注入pathc中的so库。对开发者透明。

不用像其他方案那样需要手动的替换系统的System.load来实现替换目的。

代码修复技术详解

底层替换原理


AndFix方案引发的思考

在各种Android热修复方案中,AndFix的即时生效令人印象深刻,它稍显另类,并不需要重新启动,而是在加载补丁后直接对方法进行替换就可以完成修复,然而它的使用限制也遭遇到更多的质疑。

怎么做到即时生效?
在app运行到一半的时候,所有需要发生变更的类已经被加载过了,在Android上是无法对一个类进行卸载的。AndFix采用的方法是,在已经加载了的类中直接在native层替换到原有方法,是在原来类的基础上进行修改的。

每一个Java方法在ART中都对应着一个ArtMethod,ArtMethod记录了这个Java方法的所有信息,包括所有类、访问权限、代码执行地址等等。

通过env->FromReflectedMethod,可以由Method对象得到这个方法对应的ArtMethod的真正其实地址。然后把它强转为ArtMethod支持,从而对所有成员进行修改。

这样全部替换完之后就完成了热修复逻辑。以后调用这个方法时就会直接走到新方法中了。


虚拟机调用方法的原理分析

为什么替换ArtMethod数据后可以实现热修复呢?这需要从虚拟机调用方法的原理说起。

以Android6.0实现为例。

ArtMethod结构中最重要的两个字段entry_point_from_interprete_entry_point_from_quick_compiled_code_

ART中可以采用解释模式或者AOT机器码模式执行

解释模式:
就是取出Dex Code,逐条的解释执行就行了。如果方法的调用者是以解释模式运行的,在调用这个方法是,就去取得这个方法的`entry_potin_from_interpreter_`,然后跳转过去执行。

AOT模式:
预先编译好Dex Code对应的机器码,然后运行期直接执行机器码就行了,不需要一条条的解释执行Dex Code。如果方法的调用者是以AOT机器码执行的,在调用这个方法是,就是跳转到`entry_point_from_quick_compiled_code_`执行。

那我们是不是只需要替换这几个字段就可以了呢?

并没有这么简单。以为不论是解释模式还是AOT模式,在运行期间还会需要用到ArtMethod的里面的其他成员字段的。

其实这样正式native替换方式兼容性问题的原因。

Sophix没有选择将Artmethod的所有成员都进行替换,而是把ArtMethod作为整天进行替换。

这里需要强调的是求解ArtMethod结构的内存占用大小。

由于我们是在运行时生效(各家ROM都会有多多少少的改动),且sizeofsizeof()工作原理是在编译期,因此我们无法直接使用该表达式。

Sophix采用了比较聪明的办法:利用现行结构的特定,使用两个ArtMethod之前的偏移量来动态计算ArtMethod的数据结构大小。但这里需要依赖存放ArtMethod的数据结构是线性的。

替换后方法访问权限问题

1、类内部

上述提到,我们整个替换ArtMethod的内容,但新替换的方法的所属类和原来方法的所属类,是不同的类。

被替换的方法有权限访问其他的private方法吗?

通过观察Dex Code和Native Code,可以推测,在dex2oat生成AOT机器码时是有做一些检查和优化的,由于dex2oat编译机器码时确认了两个方法同属一个类,所以机器码中就不存在权限检查相关代码。

2、同包名下

但是并不是所有方法都如同类内部直接访问那样顺利的。

补丁类正在访问同包名下的类时会报出访问异常。

具体的校验逻辑是在虚拟机代码的Class::IsInSamePackage中,关键点在于比较两个Class的所属的ClassLoader。

因此这里还需要将新类的ClassLoader设置为与原来一致。

3、反射调用非静态方法

当一个非静态方法被热替换后,再反射调用这个方法,会抛出异常。

在反射Invoke一个方法时,在底层会掉哦用到InvokeMethod -> VerifyObejctIsClass函数做验证。

由于热替换方案锁替换的非静态方法,在反射调用者,由于VerifyObjectIsCLass时,旧类和新类不匹配,就会导致验证不通过。

如果是静态方法,会有同样的问题吗?

当然没有,静态方法会在类的级别直接进行调用的,不需要接受对象实例作为参数,不会有这方面的检查。

因此,对于这种反射调用非静态方法的问题,Sophix会采用另一种冷启动机制对付,最后会有介绍。

即时生效带来的限制

除了反射的问题,即时生效直接在运行期修改底层结构的热修复方法,都存在着一个限制,那就是只能替换方法。对于补丁里面如果存在方法的增加或者减少,以及成员字段的增加和减少情况都是不适用的。

原因是这样的,一旦补丁类中出现了方法的增减,会导致整个类以及整个dex方法数的变化。方法数的变化伴随着方法索引的变化,这样在访问时就无法所引导正确的方法了。

如果字段发生了增减,和方法变化差不多,所有字段的索引都会发生变化。

并且更加严重的是,如果程序运行中间某个类突然增加了一个字段,那么对于原来已经生成的实例,还是原来的结构,已是无法改变的了,而新方法在使用到老实例时,访问新增字段就会产生不可预期的结果。

因此综合来说,即时生效方案只有在下面两种情况下是不适用的:

  1. 引起了原有类发生结构变化的修改
  2. 修复了的非静态类会被反射调用

虽然有一些使用限制,但一旦满足使用条件,这种热修复方式还是十分出众的,补丁小,加载迅速,能够实时生效,无需重启app,并且有着完美的设备兼容性(整个copy Method结构)。

Java语言的编译实现所带来的挑战

Sophix一直秉承 粒度小、注重快捷热修复、无侵入适合原生工程。因为这个原则,我们在研发过程中遇到很多 编译期 的问题,引用印象深刻。

内部类

问题:有时候会发现,修改外部类某个方法逻辑为访问内部类的某个方法时,最后打包出来的补丁包竟然提示新增了一个方法。

因此我们很有必要了解内部类在编译期间是怎么编译的。

首先需要知道 ** 内部类会在编译期会被编译为跟外部类一样的顶级类。 **


静态内部类和非静态内部类的区别。

它们的区别其实大家都很熟悉,非静态类持有外部类的引用,静态内部类不持有外部类的引用。

既然内部类跟外部类一样都是顶级类,那是不是意味着对方private的method/field是没法被访问到的,事实上外部类为了访问内部类私有的域和方法,编译期会自动外内部类生成access&**相关方法。

因此,如果补丁类中修改的方法中添加了需要访问内部类私有数据或者方法的代码的话,那么编译期间会新增access&**方法,供内部类被访问使用。


如果想通过热部署修复的新方法需要访问内部类的私有域或方法,那么我们应该防止生成access&**相关方法。

Sophix有以下建议:

  • 外部类如果有内部类,把外部类所有的method/fidle的private访问权限修改为projected或者默认访问权限或者public。
  • 同时把内部类的所有的method/field的private访问修改为projected或者模式访问权限或者public。

匿名内部类

匿名内部类其实也是个内部类,自然也会有上一小节中说到的限制和应对策略。

但它还会有其他的限制。


匿名内部类的命名规则

匿名内部类顾名思义就是没有名字。

命名格式一般都是外部类&numble,后面的numble,是编译器根据匿名内部类在外部类中出现的先后关系,一次累加命名。


解决方案

新增/减少匿名内部类,实际上对于热部署来说是无解的,因为补丁工具拿到的已经是编译后的.class文件,所以根本无法区分,所以这种情况下,应极力避免插入一个新的匿名内部类。

当然,如果匿名内部类是插入到外部类的末尾,那么是允许的。(确是很容易犯错的)

静态域/静态代码块

实际上,热部署方案除了不支持method/field的新增,同时也是不支持<clinit>的修复,因为这个方法是dalvik虚拟机中类进行初始化的时候调用。

在Java源码中并没有clinit这个方法,这个方法是android编译器自动合成的方法。

通过测试发现,静态field的初始化和静态代码块实际上都会被编译器便已在<clinit>这个方法。

静态代码块和静态域初始化在clinit中的先后关系就是两者出现在源码中的先后关系。

类加载然后进行类初始化的时候,会去调用clinit方法,一个类仅加载一次。

在下面三种情况下,会尝试加载一个类:(在面试中被问到过)
1. new一个类的对象;
2. 调用类的静态方法;
3. 获取类静态域的值;

首先判断这个类有没有被加载过,如果没有,执行的流程是`dvmResolveClass -> dvmLinkClass -> dvmInitClass `。 
类的初始化是在dvmInitClass。这个函数会首先尝试对父类进行初始化,然后调用本类的clinit方法。

非静态域/非静态代码块

非静态域初始化和非静态代码块被编译器翻译在<init>默认午餐构造函数中。

实际上,如果存在有参构造函数,那么每个有参构造函数都会执行一个非静态域的初始化和非静态代码块。

构造函数会被android编译器自动翻译成<init>方法

前面说过<clinit>方法在类加载初始化的时候被调用,那么<init>构造函数方法肯定是对类对象进行初始化的时候被调用的。

简单来说,new一个对象就会对这个对象进行初始化,并调用这个对象相应的构造函数。


我们查看代码String s = new String("test")编译之后的样子。

new-instance v0, Ljava/lang/String;
invoke-direct {v0}, Ljava/lang/String;-><init>()V

首先会执行new-instance指令,主要为对象分配内存,同时,如果类之前没加载过,尝试加载类;
然后执行invoke-direct指令调用类的init构造函数方法,执行对象初始化。


解决办法
Sophix不支持clinit方法的热部署,任何静态field初始化和静态代码块的变更都会被翻译到clinit方法中,所以最终会导致热部署失败,只能冷启动。

非静态field和非静态代码块的变更被翻译到<init>构造函数中,热部署模式下只是视为一个普通方法的变更,此时对热部署是没有影响的。

final static 域编译

final static域首先是一个静态域,所以我们自然认为由于会被翻译到clinit方法中,所以自然不支持热部署。

但是测试发现,final static修饰的基本类型/String 常量类型,匪夷所思的竟然并没有翻译到clinit方法中。

事实上,类加载初始化dvmInitClass在执行clinit方法之前,首先会执行initSFields,
这个方法的作用主要是给static域赋予默认值。

如果是引用类型,那么默认值为NULL。

final static 修饰的原始类型 和 String 类型域(非引用类型),并不会翻译在clinit方法中,而是在类初始化执行initSFields方法时得到了初始化赋值。
final static 修饰的引用类型,初始化仍然在clinit方法中。

我们在Android性能优化的相关文档中经常看到,如果一个field是常量,那么推荐尽量使用static final作为修饰符。

很明显这句话不大对,得到优化的仅仅是final static原始类型和String类型域(非引用类型),如果是引用类型,实际上是不会得到任何优化的。


final static String类型的变量,编译期间会被有优化成const-string指令,但是该在指令拿到的只是字符串常量在dex文件结构中字符常量区的索引id,所以需要额外的一次字符串查找。

dex文件中有一块区域存储着程序所有的字符串常量,
最终这块区域会被虚拟机完整加载到内存中,这块区域也就是通常所说的“字符串常量区”内存。


因此,我们可以得到以下结论

  • 修改final static基本类型或者String类型域(非引用类型),由于编译器间引用到基本类型的地方会被立即数替换,引用到String类型域的地方会被常量池索引id替换,所以在热部署模式下,最终所有引用到该final static域的方法都会被替换。实际上此时仍然可以走热部署。
  • 修改final static引用类型域,是不允许的,因为这个field的初始化会被翻译到clinit方法中,所以此时没法走热部署。

方法混淆

其实除了上面提到的内部类/匿名内部类可能会造成method新增之后,代码混淆也可能会导致方法的内联和剪裁,那么最后可能也会导致method的新增/减少。

实际上只要混淆配置文件加上-dontoptimize这项就不会去做方法的剪裁和内联。

一般情况下项目的混淆配置都会使用到android sdk默认的混淆配置文件proguard-android-optimize.txt或者 proguard-android.txt,两者的区别就是后者应用了-dontoptimize这一项配置,而前者没有使用。

ProGuard_build_process

实际上,图上的几个步骤都是可以选择的,其中对热部署可能会产生严重影响的主要在optimization阶段。

optimization step: 进一步优化代码,不是入口点的类和方法可以被设置成private、static或final,无用的参数可能会被有移除,并且一些地方可能会被内联。

可以看到optimization阶段,除了会做方法的剪裁和内联可能导致方法的新增/减少之外,还可能把方法的修饰符优化成 private/static/final。热补丁部署模式下,混淆配置最好都加上-dontoptimize配置。

`` : 针对.class文件的预校验,在.class文件中加上StackMa/StackMapTable信息,这样Hotspot VM在类加载时候执行类校验阶段会省去一些步骤,因此类加载将更快。

我们知道android虚拟机执行的dex文件,编译期间dx工具会把所有的.class文件优化成.dex文件,所以混淆库的预校验在android中是没有任何意义的,反而会拖累打包速度。

android虚拟机中有自己的一套代码校验逻辑(dvmVerifyClass)。所以android中混淆配置一般都需要加上-dontpreverify配置。

switch case语句编译

编译规则:

public void testContinue() {
    int temp = 2;
    int result = 0;
    switch (temp) {
        case 1:
          result = 1;
          break;
        case 3:
          result = 3;
          break;
        case 5:
          result = 5;
          break;
    }
}

public void testNotContinue() {
    int temp = 2;
    int result = 0;
    switch (temp) {
        case 1:
          result = 1;
          break;
        case 3:
          result = 3;
          break;
        case 5:
          result = 10;
          break;
    }
}

编译出来的结果:

# virtual methods
.method public testContinue () V
    const/4 v1, 0x2
    .local v1, "temp":I
    const/4 v0, 0x0
    .local v0, "result":I
    packed-switch v1, :pswitch_data_0

    :pswitch_0
    return-void
    :pswitch_1
    const/4 v0, 0x1
    :pswitch_2
    const/4 v0, 0x3
    :pswitch_3
    const/4 v0, 0x5

    :pswitch_data_0
    .packed-switch 0x1
        :pswitch_1
        :pswitch_0
        :pswitch_2
        :pswitch_0
        :pswitch_3
    .end packed-switch
.end method

.method public testNotContinue () V
        const/4 v1, 0x2
        .local v1, "temp":I
        const/4 v0, 0x0
        .local v0, "result":I
        sparse-switch v1, :sswitch_data_0

        :sswitch_0
        const/4 v0, 0x1
        :sswitch_1
        const/4 v0, 0x3
        :sswitch_2
        const/16 v0, 0xa

        :sswitch_data_0
        .sparse-switch
                0x1 -> :sswitch_0
                0x3 -> ::switch_1
                0xa -> :sswitch_2
        .end sparse-switch
.end method

testContinue方法的switch case语句被翻译成packed-switch指令,testNotContinue方法的switch case语句被翻译成sparse-switch指令。

比较下差异:
testContinue的switch的case项是几个比较连续的值,中间的差值用:pswitch_0补齐,:pswitch_0标签处直接return-void
testNotContinue的swtich语句的case项不够连续,所以编译期间编译为sparse-switch指令。

怎么才算比较连续的case是由编译器来决定的。

如何应对热部署

一个资源id肯定是const final static变量,此时如果switch case 语句会被翻译成packed-switch指令,所以补丁包这个时候如果不做处理就无法做到资源id的完全替换。

解决方案其实很暴力,修改smali反编译流程,碰到packed-switch指令强转为sparse-switch指令;
做完资源id的暴力替换,然后再回编译smali为dex;

泛型编译

泛型是java5才开始引入的。泛型的使用也可能会导致method的新增。

Java语言的泛型基本上都是在编译器中实现的。

由编译器执行类型检查和类型推断,然后生成普通的非泛型的字节码,就是虚拟机完全无感知泛型的存在。

这种实现技术成为擦除(erasure)。编译器使用泛型类型信息保证类型安全,然后在生成字节码之前将其清除。由于泛型是在java 5中才引入的,扩展虚拟机指令集来支持泛型是让人无法接受的,因为这会为Java厂商升级其JVM造成难以逾越的障碍,因此才采用了可以完全在编译器中实现的擦除方法。


类型擦除与多态的冲突

class A<T> {
    private T t;
    public T get() {
        return t;
    }
    public void set(T t) {
        this.t = t;
    }
}

class B extends A<Number> {
    private Number n;

    @Override  // 跟父类返回值不一样,为什么重写父类get方法?
    public Number get() {
        return n;
    }

    @Override  // 跟父类方法参数不一样,为什么重写set方法?
    public void set(Number n) {
        this.n = n;
    }
}

class C extends A {
    private Number n;

    @Override  // 跟父类返回值不一样,为什么**重写**父类get方法?
    public Number get() {
        return n;
    }
    @Override  // 跟父类方法参数不一样,为什么**重载**set方法?
    public void set(Number n) {
        this.n = n;
    }
}

为什么类B的set和get方法可以用@Override而不报错。

@Override表明这个方法是重写,我们知道重写的意思是子类中的方法签名和返回类型都必须一致。

但是很明显的,B的方法无法对A的set/get方法进行重写的。其实我们的本意是重写实现多态,可是类型擦除后,只能变成了重载。

这样,类型擦除就和多态有了冲突。

实际上JVM采用了一个特殊的方法,来完成重写这个功能,那就是bridage方法。

.method public get() Ljava/lang/Number;
.method public bridge synthetic get() Ljava/lang/Object;
    invoke-virtual {p0}, Lcom/taobao/test/B;->get()Ljava/lang/Number;
    move-result-object v0
    return-object v0
.end method

.method public set(Ljava/lang/Number;) V
.method public bridge synthetic set(Ljava/lang/Object;) V
    check-cast p1, Ljava/lang/Number;
    invoke-virtual {p0, p1}, Lcom/taobao/test/B;->set(Ljava/lang/Number;)V
    return void
.end method

我们发现编译器会自动生成两个bridage方法来重写父类方法,同时这两个方法实际上调用B.set(Ljava/lang/Number;)B.get()Ljava/lang/Number这两个重载方法。

子类中真正重写基类方法的是编译器自动合成的bridge方法。而类B定义的get和set方法上面的@Override只不过是假象。虚拟机巧妙的使用桥方法的方式来解决了类型擦除和多态的冲突。

也就是说,类B中的字节码中get()Ljava/lang/Number;和get(0Ljava/lang/Object;是同时存在的,这就颠覆了我们的认知,因为在正常代码中他们是无法同时存在的。

因此,虚拟机为了实现泛型的多态做了一个看起来“不合法”的事情,然后交给虚拟机自己去区别处理了。


Lambda表达式编译

Lambda表达式是 java 7才引入的一种表达式,类似于匿名内部类实际上由于匿名内部类有很大的区别。

Lambda表达式的使用也可能导致方法的新增/减少,导致最后走不了热部署模式。

lambda为Java添加了缺失的函数式编程特点,Java现在提供的最接近闭包的概念便是Lambda表达式。

Java编译器将lambda表达式编译成类的私有方法,使用了Java7的invokedynamic字节码来动态绑定这个方法。

在Java 7 JVM中增加了一个新的指令invokedynamic,用于支持动态语言。
即允许方法调用可以在运行时指定类和方法,不必在编译的时候确定。

字节码中每条invokedynamic指令出现的位置称为一个动态联调点,
invokedynamic指令后面都会跟一个指向常量池的调用点限定符(#3, #6),这个限定符会被解析成一个动态调用点。

热部署应对方案

  • 增加/减少一个Lambda表达式会导致类方法比较错乱,所以都会导致热部署失败
  • 修改一个Lambda表达式基于前面的分析,可能会导致新增field,所以此时也会导致热部署失败。

访问权限对热替换的影响

一个类的加载,必须经历resolve->link->init三个阶段,** 父类/实现接口权限控制检查** 主要发生在link阶段。

代码如下:

bool dvmLinkClass(ClassObject* claszz) {
...
    if (clazz->status == CLASS_IDX) {
        ...
        if (clazz->interfaceCount > 0) {
            for (i = 0; i < clazz->interfaceCount: i++) {
                assert(interfaceIdxArray[i] != kDexNoIndex);
                clazz->interfaces[i] = dvmResolveClass(clazz, interfaceIdxArray[i], false);
                ...
                /* are we aoolowed to implement this interface?  */
                if  (!dvmCheckClassAccess(clazz, clazz->interfaces[i])) {
                    dvmLinearReadOnly(clazz->classLoader, clazz->interfaces);
                    ALOGW("Interface '%s' is not accessible to '%s' ", clazz->interfaces[i]->descriptor, clazz->descriptor);
                    dvmThrowIllegalAccessError("interface not accessible");
                    goto bail;
                }
            }
        }
    }
    ...
    if (strcmp(class->descriptor, "Ljava/lang/Object;") == 0){
    ...
    } else {
        if (clazz->super == NULL) {
            dvmThrowLinkageError("no superclass defined");
            goto bail;
        } else if (!dvmCheckClassAccess(clazz, clazz->super)) { // 检查父类的访问权限
            ALOGW("Superclass of '%s' (%s) is not accessible", clazz->descriptor, clazz->super->descriptor);
            dvmThrowIllegalAccessError("superclass not accessible");
            goto bail;
        }
    }
}

在上述代码实例上可以看到,一个类的link阶段,会一次对当前类实现的接口和父类进行访问权限检查。

接下来看一下dvmCheckClassAccess的具体实现:

bool dvmCheckClassAccess(const ClassObject* accessFrom, const ClassObject* clazz) {
    if (dvmIsPublicClass(clazz)) { // 如果父类是public类,直接return true
        return true;
    return dvmInSamePackage(accessFrom, clazz);
}

bool dvmInSamePackage(const ClassObject* class1, const ClassObject* class2) {
    /* quick test for instr-class access */
    if (class1 == class2) {
        return true;
    }

    /* class loaders must match */
    if (class1->classLoader != class2->classLoader) { // classLoader不一致,直接return false
        return false;
    }

    if (dvmIsArrayClass(class1))
        class1 = class1->elementClass;
    if (dvmIsArrayClass(class2))
        class2 = class2->elementClass;
    
    /* check again */ 
    if (class1 == class2)
        return true; 

    int commonLen;

    commonLen = strcmpCount(class1->descriptor, class2->descriptor);
    if (strchr(class1->descriptor + commonLen, '/') != NULL || 
        strchr(class2->descriptor + commonLen, '/') != NULL) {
        return false;
    }

    return true;
}

我们可以看到如果当前类和实现接口/父类是否public,同时负责加载两者的classLoader不一样的情况下,直接return false.

所以如果此时不进行任何处理的话,那么在类的加载阶段就会报错。

而Sophix修复方案是基于新classLoader加载补丁类,所以在patch过程就会报错。

如果补丁类中存在非public类的访问,非public方法/域的调用,那么都会失败。
更为致命的是,在补丁加载是检测不出来的,补丁会被正常加载,但是在运行阶段会直接crash。

由于补丁类在单独的dex中,所以要加载这个dex的话,肯定要进行dexopt的。

dexopt过程中会执行dvmVerifyClass校验dex中的每个类。

方法调用链:
dvmVerifyClass校验类
->verifyMethod校验类中的每个方法
->dvmVerifyCodeFlow
->doCodeVerification对每个方法的逻辑进行校验
->verifyInstruction实际上就是校验每个指令。

<clinit>方法

热部署模式下的特殊性:不允许类结构变更以及不允许变更<clinit>方法

所以补丁工具如果发现了这几种限制,那么此时只能走冷启动重启生效。

冷启动几乎是没有限制的,可以做到任何场景的修复。

可能在有时候在源码层上来看没有增加method/field,但是实际上由于要满足java的各种语法特性的需求,所以编译器会在编译期间为我们自动合成一些method和field,最后就有可能触发了上面提到的几个限制情况。

冷启动类加载原理

概述

对比不同冷启动方案

QQ空间 Tinker
原理 为了解决Dalvik下unexpected dex problem异常而采用插桩的方式,单独放在一个帮助类在独立的dex让其他类调用,阻止了类被打上CLASS_ISPREVERIFIED标志从而规避问题的出现。最后加载补丁dex得到dexFile对象作为参数构建一个Element对象插入到dex-Elements数组的最前面。 提供dex差量包,整体替换dex的方案。差量的方式给出patch.dex,然后将patch.dex与应用的classes.dex合并成一个完整的dex,完整dex加载得到的dexFile对象最为参数构建一个Element对象然后整体替换掉就的dex-Elements数组
优点 没有合成整包,产物比较小,比较灵活 自研dex差异算法,补丁包很小,dex merge成完整dex,Dalvik不影响类加载性能,Art下也不存在包含父类/引用类的情况
缺点 Dalvik下影响类加载性能,Art下类地址写死,导致必须包含父类/引用,最后补丁包很大 dex合并内存并消耗vm heap上,容易OOM,最后导致dex合并失败
dex merge in Tinker:
dex merge操作是在java层面进行,所有对象的分配都是在java heap上。
如果此时进程申请的java heap对象超过了vm heap规定的大小,那么进程发生OOM,
系统memory killer可能会杀掉该进程,导致dex合成失败。

另外一方面,我们知道jni层面`c++ new/malloc`申请的内存,分配在`native heap`,
native heap 的增长并不受vm heap大小的限制,只受限于RAM。
如果RAM不足那么进程也会被杀死导致闪退。

所以如果只是从dex merge方面思考,在jni层面进行dex merge,从而可以避免OOM提高dex合并的成功率。

理论上当然可以,只是jni层实现起来比较复杂而已。

两种方案对Sophix都不适用。它需要的是一种既能无侵入打包。

插桩

众所周知,如果仅仅把补丁类打入补丁包中而不做任何处理的话,那么运行时类加载的时候机会异常退出。


加载一个dex文件到本地内存的时候,如果不存在odex文件,那么首先会执行dexopt,dexopt的入口在dalvik/opt/OptMain.cpp的main方法,最后调用verifyAndOptimizeClass执行真正的verify/optimize操作。

/*
 * Verify and/or optimize a specific class.
 */
static void verifyAndOptimizeClass(DexFile* pDexFile, ClassObject* clazz, 
const DexClassDef* pClassDef, bool doVerify, bool doOpt) {
    const char* classDescriptor;
    bool verified = false;
    classDescriptor = dexStringByTypeIdx(pDexFile, pClassDef->classIdx);    

    if (doVerify) {
        if (dvmVerifyClass(clazz)) {  // 执行类的Verify
            ((DexClassDef*)pClassDef)->accessFlags |= CLASS_ISPREVERIFIED;
            verified = true;
        }
    }
    if (doOpt) {
        bool needVerify = (gDvm.dexOptMode == OPTIMIZE_MODE_VERIFIED || 
                                      gDvm.dexOptMode == OPT-MIZE_MODE_FULL);
        if (!verified && needVerify) {
            ...
        } else {
            dvmOptimizeClass(clazz, false); // 执行类的Optimize
            /* 类被打上CLASS_ISOPTIMIZED标识 */
            ((DexClassDef*)pClassDef)->accessFlags |= CLASS_ISOPTIMIZED; 
        }
    }
}

apk第一次安装的时候,会对原dex执行dexopt。
此时假如apk只存在一个dex,所以dvmVerifyClass(clazz)结果为true。所以apk中所有的类都会被打上CLASS_ISPREVERIFIED 标志,接下来执行dvmOptimizeClass,类接着被打上CLASS_ISOPTIMIZED标志。

  • dvmVerifyClass : 类校验,类校验的目的是为了防止类被篡改校验类的合法性。它会对类的每个方法进行校验,这里我们只需要知道如果类中的所有方法中直接引用到的类(第一层级关系,不会进行递归搜索)和当前类都在同一个dex的话,dvmVerifyClass就返回true。
  • dvmOptimizeClass : 类优化,这个过程会把部分指令优化成虚拟机内部指令,比如方法调用指令:invoke-*指令变成了invoke-*-quick,quick指令会从类的vtable表中直接取,vtable简单来说就是累的所有方法的一张大表(包括集成自父类的方法)。因此加快了方法的执行效率。

加入A类是补丁类,放在单独的dex中。类B中的某个方法引用到补丁类A,所以执行到该方法会尝试解析类A。

ClassObject* dvmResolveClass(const ClassObject* referrer, u4 classIdx, bool fromUnverifiedConstant) {
    ....
    /* 如果类被打上CLASS_ISPREVERIFIED标志 */
    if (!fromUnverifiedConstant && IS_CLASS_FLAG(referrer, CLASS_ISPREVERIFIED)) {
        if (referrer->pDvmDex != resClassCheck->pDvmDex && 
            resClassCheck->classLoader != NULL) {
                dvmThrowIllegalAccessError("Class ref in pre-verified class resolved to unexpected implementation");
        }
    }
    ....
}

类B由于被打上CLASS_ISPREVERIFIED标志,接下来referrer是类B,resClassCheck是补丁类A, 他们属于不同的dex,所以会抛异常。


插桩

为了解决这个问题,一个无关帮助类放到一个单独的dex中,原dex中所有类的构造函数都引用这个类,而一般的方法都需要侵入dex打包流程,利用.class字节码修改技术,在所有.class文件构造函数中引用这个帮助类,插桩由此而来。

插桩缺点

给类加载效率带来比较严重的影响。
由于一个类的加载通常有三个阶段:dvmResolveClass->dvmLinkClass->dvmInitClass。
dvmInitClass阶段在类解析完毕尝试初始化类的时候执行,主要是完成父类的初始化,当前类的初始化,以及static变量的初始化复制等操作。
在初始化操作之外,如果类没被打上CLASS_ISPREVERIFIED/CLASS_ISOPTIMIZED标志,那么类的Verify和Optimize都将会在类的初始化阶段进行。

正常情况下,类的verify和Optimize都仅仅只是在apk第一次安装执行dexopt的时候进行。
类的verify实际上是很重的,因为会对类的所有方法执所有指令都进行校验,单个类的加载看起来并不耗时,但是如果同时时间点加载大量类的情况下,这个耗时就会被放大。

所以这也是插桩给类的加载效率打来比较大影响的后果。

性能影响

由于插桩会导致所有类都非preverify,因此在加载每个类的时候还需要做verify和optimize操作。

微信做过一次测试:

测试场景 不插桩 插桩
700个类 84ms 685ms
启动耗时 4934ms 7240ms

平均每个类verify+optmize(跟类的大小有关系)的耗时并不长,而且这个耗时每个类只有一次。但由于应用刚启动时一般会同时加载大量的类,很容易出现白屏,让人无法容忍。

避免插桩

QFix方案处理办法

手Q轻量级QFix热补丁方案提供了一种不一样的思路。

ClassObject* dvmResolveClass(const ClassObject* referrer, u4 classIdx, bool fromUnverifiedConstant) {
    DvmDex* pDvmDex = referrer -> pDvmDex;
    ClassObject* resClass;
    const char* className;

    /*
     * Check the table first -- this gets called from the other "resolve"
     * methods;
    */
    // 提前把patch类加入到pDvmDex.pResClasses数组,resClass结果不为NULL
    resClass = dvmDexGetResolvedClass(pDvmDex, classIdx);

    if (resClass != NULL) {
        return resClass;
    }

    className = dexStringByTypeIdx(pDvmDex->pDexFile, classIdx);
    if (className[0] != '\0' && className[1] == '\0') {
        /* primitive type */
        resClass = dvmFindPrimitiveClass(className[0]);
    } else {
        resClass = dvmFindClassNoInit(className, referrer->classLoader);
    }

    if (resClass != NULL) {
        // fromUnverifiedConstant变量设为true,绕过dex一致性校验
        if (!fromUnverifiedConstant && IS_CLASS_FLAG_SET(referrer, CLASS_ISPREVERIFIED)) {
            ClassObject* resClassCheck = resClass;
            if (dvmIsArrayClass(resClassCheck)) {
                resClassCheck = resClassCheck->elementClass;
            }

            if (referrer->pDvmDex != resClassCheck->pDvmDex && resClassCheck->ClassLoader != NULL) {
                dvmThrowIllegalAccessError("Class ref in pre-verified class resolved to unexpected", "implementation");
                return NULL;
            }
        }
        // 这里的dvmDexSetResolvedClass与前面的dvmDexGetResolvedClass前后呼应,说白了就是get为null后就去set。
        dvmDexSetResolvedClass(pDvm, classIdx, resClass);
    }
    return resClass;
}

如何让dvmDexGetResolvedClass返回的结果不为null呢?

只需要调用过一次dvmDexSetResolvedClass(pDvmDex, classIdx, resClass);就行了。

举个例子具体说明一下:

public class B{
    public static void test() {
        A.a();
    }
}

我们此时需要patch的类是A,所以类A被打入到一个独立的补丁dex中。那么执行到类B方法中的A.a()代码是就会尝试去解析类A,

此时,dvmResolveClass(const ClassObject* referrer, u4 classIdx, bool fromUnverifiedConstant)各个参数分别是:

  • referrer:这里传入的是类B
  • classIdx:类A在原dex文件结构类区中的索引id
  • fromUnverifiedConstant:是否const-class/instance-of指令。

此时调用的是A的静态a方法,invoke-static指令不属于const-class/instance-of这两个指令中的一个。不做处理的话,dvmDexGetResolvedClass一开始是null的。然后A是从补丁dex中解加载解析,B是在原Dex中,A在补丁Dex中,所以B->pDvmDex!=A->pDvmDex,接下来执行到 dvmThrowIllegalAccessError从而会抛出运行时异常。

所以我们需要做的是,必须要在一开始的时候就把补丁类添加到原来dex的pResClasses数组中。
这样就确保了执行B类test方法的时候,dvmDexGetResolvedClass不为null,就不会执行后面的校验逻辑了。

具体做法:

1、首先通过补丁工具反编译dex为smali文件,拿到:

  • preResolveClz:需要patch的类A的描述符,非必须,为了调试方便加上该参数而已。(比如实例中的类A)
  • refererClz:需要patch的类A所在dex的任何一个类的描述符,注意这里不限定补丁类A的某个依赖类,实际上只要同一个dex中的任何一个类就可以。所以我们拿原dex中的第一个类即可。(一般来说第一个类是Landroid/support/annotation/AnimRes;
  • classIdx:需要patch的类A在原dex文件中的类索引id(这里以2455作为示例)

2、然后通过dlopen拿到libdvm.so库的句柄,通过dlsym拿到该so库的 dvmResolveClass/dvmFindLoadedClass函数指针。

  • 首先预加载引用类android/support/annotation/AnimRes,这样dvmFindLoadedClass("android/support/annotation/AnimRes")才不为null,
  • dvmFindLoadedClass执行结果得到的ClassObject作为第一个参数调用dvmResolveClass(AnimRes, 2455, true)即可。

下面是该方案的JNI代码部分实现,实际上preResolveClz参数是非必须的。

jboolean resolveCodePathClasses(JNIEvn *env, jclass clz, jstring preResolveClz, 
jstring refererClz, jlong classIdx, dexstuff_t *dexstuff) {
    LOGD("start resolveCodePathClasses");
    // 调用dvmFindLoadedClass
    ClassObject* refererObj = dexstuff->dvmFindLoadedClass_fnPtr(Jstring2CStr(env, refererClz));
    if (strlen(refererObj->descriptor) == 0) {
        return JNI_FALSE;
    }
    
    // 调用dvmResolveClass
    /*
     * 这里的调用需要注意:
     * 1. dvmResolveClass的第三个参数必须是true
     * 2. 多dex场景下,dvmResolveClass的第一个参数referrer类必须跟待patch的类在同一个dex中,但是他们不需要存在引用关系,任何一个在同一个dex中的类作为referrer都可以。
    *  3. referrer类必须要提前加载。
    */
    ClassObject* resolveClass = dexstuff->dvmResolveClass_fnPtr(refererObj, classIdx, true);
    LOGD("classIdx ClassObject : %s \n", resolveCLass->descriptor);
    if (strlen(resolveClass->descriptor) == 0) {
        return JNI_FALSE;
    }

    return JNI_TRUE;
}

这个思路与native hook方案处理方式不同,不会去hook某个系统方法。而是从native层直接调用,同时不需要插桩。

但QFix却有它独特的缺陷:

由于是在dexopt后进行绕过的,dexopt会改变原先的很多逻辑,许多odex层面的优化会写死字典和方法的访问偏移,这就会导致比较严重的问题。

多态对冷启动启动类加载的影响


重新认识多态:

实现多态的技术一般叫做动态绑定,是值在执行期间判断所引用对象的实际类型,根据其实际类型调用其相应方法。

举个栗子是最好说明方法:

public class B extends A {
    String name = "B name";

    @Override
    void a_t1() {
        System.out.println("B a_tl...");
    }

    void b_t1() {}

    public static void main(String[] args) {
        A obj = new B();
        System.out.println(obj.name);
        obj.a_tl();
    }
}

class A {
    String name = "A name";

    void a_t1() {
        System.out.println("A a_tl...");
    }

    void a_t2() {}
}

输出结果:

A name
B a_tl...

有次可以看到name是没有多态性的,这里分析下方法多态性的实现:
首先new B()执行会加载类B,方法调用链:dvmResolveClass->dvmLinkClass->createVtable
此时会为类B创建一个vtable。

在虚拟机加载每个类都会为这个类生成一张vtable表,vtable说白了就是当前类的所有virtual方法的一个数组,当前类和所有所有集成父类public/protected/default方法就是virtual方法。

vtable数组生成的代码就不在这里分析了,有兴趣的去原书籍查找。

其过程可以简单用文字类描述一下:

  1. 整个复制父类vtable的vtable
  2. 遍历子类virtual方法集合,如果方法一致,说明是重新,那么相同索引位置处,子类重写方法覆盖掉vtable中父类的方法。
  3. 方法原型不一致,那么把该方法添加到vtable的末尾

那么上述示例中,A和B的vtable分别是:

A -> vtable = {A.a_t1, A.a_t2}
B -> vtable = {B.a_t1, a.a_t2, B.b_t1}

我们来看下obj.a_t1()发生了什么

GOTO_TARGET(invokeVirtual, bool methodCallange, bool) {
    Method* baseMethod;
    Object* thisPtr;

    EXPORT_PC();

    vsrc1 = INST_AA(inst); /* AA (COUNT) OR BA(COUNT + arg 5) */
    ref = FETCH(1); /* method ref */
    vdst = FETCH(2); /* 4 regs -or- first reg */

    /*
     * The object against which we are executing a method is always
     * in the first arguent
     */
    if (methodCallRange) {
        thisPtr = (Object*)  GET_REGISTER(vdst);
    } else {
        thisPtr = (Object*) GET_REGISTER(vdst & 0x0f);  // 当前对象
    }

    /*
     * Resolve the method.  this is the correct method for the static 
     * type of the object. we also verify access permissions here.
     */
    baseMethod = dvmDexGetResolvedMethod(methodClassDex, ref); // 是否已经解析过该方法

    if (baseMethod == NULL) {
        baseMethod = dvmResolveMethod(current->clazz, ref, METHOD_VIRTUAL);
        // 没有解析过该方法调用dvmResolveMethod, baseMethod得到的是当然是A.a_t1方法。
        if (base == NULL) {
            ILOGV("+ unknown method or access denied");
            GOTO_exceptionThrown();
        }
    }
    /*
     * Combine the object we found with the vtable offset in the
     * method
     */
    assert(baseMethod->methodIndex < thisPtr->clazz->vtableCount);
    methodToCall = thisPtr->clazz->vtable[baseMethod->methodIndex];
    // A.a_t1方法在类A的vtable中索引去类B的vtable中查找

    GOTO_invokeMethod(methodCallRange, methodToCall, vsrc1, vdst);
}
GOTO_TARGET_END

首先obj引用类型是基类A,所以上述代码中baseMethod 拿到的A.a_t1baesMethod->methodIndex是该方法在类A中的vtable中的索引0,obj的实际类型是类B,所以thisPtr->clazz就是类B,那么B.vtable[0]就是B.a_t1方法,所以obj.a_t1()实际上调用的就是B.a_t1方法。这样就首先了方法的多态。


多态在冷启动方案中的坑

dex在第一次加载的时候,会执行dexopt,dexopt有两个过程:verify+optimize.

  • dvmVerifyClass:类校验,类校验的目的是防止类被篡改校验类的合法性。此时会对类的每个方法进行校验,这里我们只需要知道如果类的素有方法中直接饮用到的类和当前类都在同一个dex中的话,dvmVerifyClass就返回true。
  • dvmOptimizeClass:类优化,简单来说这个过程会把部分指令优化成虚拟机内部指令,比如说方法调用指令: invoke-virtual-quick + 立即数,quick指令会从类的vtable类中直接取,vtable简单来说就是类的所有方法的一张大表(包括继承自父类的方法)。因此加快了方法的执行效率。

很简单的例子:

我们增加一个virtual方法:

public class Demo {
    public static void test_addMethod() {
        A obj = new A();
        obj.a_t2();
    }
}

class A {
    int a = 0;

    // 新增a_t1方法
    void a_t1() {
        Log.d("Sophix", "A a_t1");
    }

    void a_t2() {
        Log.d("Sophix", "A a_t2");
    }
}

修复后的apk中新增了a_t1()方法,Demo不做任何修复,我们会发现应用补丁后的Demo.test_addMothod()得到的结果是 “A t1”,这表明obj.a_t2执行的竟然是a_t1方法。

这恰恰说明了opt过程对invoke指令优化,原来t2的立即数在补丁类中对应到了t1中。
因此QFix方案要绕过opt过程进行处理是非常危险的,除了多态可能还会有其他坑,而且opt过程不可控可能在不同版本面临适配。


Sophix处理办法

由于QFix无法绕过的缺陷,因此Sophix并没有采纳学习,而是根据google 开源的dexmerge方案而自研了一套完整的DEX方案。

补丁共用

前文中讲热替换的时候,虽然替换的是ArtMethod,但补丁的粒度却是类。
我们的为了减少补丁包的体积,我们不可为热冷替换方案准备两套方案。
因此Sohpix的热部署的补丁是能够降级直接走冷启动的(共用)。

冷启动方案

Sophix的冷启动方案是作为热部署方案的替补或者是说互补方案。 具体实施方案对Dalvik和Art下分别做了处理。

  • Dalvik下采用自行研发的全量的DEX方案
  • Art下虚拟机本身有已经支持多dex加载,该场景下的具体方案就是把补丁dex重命名为classes.dex(主dex)来加载。

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

如果一个类中直接引用到的所有非系统类都和该类在同一个dex里的话,那么这个类就会被打上CLASS_ISPREVERIFIED,具体判定代码可见虚拟机中的verifyAndOptmizeClass函数。
我们来列举一下腾讯的三大修复方案是如何解决这个问题的:

  • QQ空间的处理方式,是在每个类中插入一个来自其他dex的hack.class,由此让所有类里面都无法满足pre-verified条件(侵入打包流程,添加冗余代码,且会影响loadclass性能)
  • QFix的方式就是取得虚拟机中的某些底层函数,提前resolve所有补丁类,以此绕过Pre-verify检查(需要获取底层虚拟机的函数,不够稳定可靠,无法新增public函数)
  • Tinker的方式,是合成全量dex文件,这样所有class都在全量dex中解决,从而消除class重复而带来的冲突(从指令维度进行合成,实现较为复杂,性较比不高)

全量DEX方案

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

然而我们可以逆向思维,既然补丁中已经有需要变动的类,那么原来基线包中dex中的重复的class就可以删掉了,这样更不用全量插桩来解决pre-verfy问题了。

参照Android原生multi-dex的实现再来看这个方案就比较好理解了。

multi-dex方案就是把一个apk中用到的类拆分到多个dex文件中,每个dex中都只包含了部分的类定义,单个dex也可以加载,因为只要把所有dex都load进去,本dex中不存在的类就可以在运行期间在其他的dex中找到。

因此同理,在基线包里面去掉了补丁中的class后,原先需要发生变更的旧的class时就会自动找到补丁dex,补丁中的新class在需要用到不变的class时也会找到基线包dex的class。

这样的话,基线包里面不适用补丁类的class仍旧可以按照原来的逻辑来做odex,最大保证了dexopt的效果。

这么一来,我们不再需要像传统合成的思路那样判断类的增加和修改情况,而且也不需要处理合成时方法数超出的情况批注:只能说一定范围上,不用考虑方法数问题,对于dex的结构也不用进行破坏性重构。

现在,合成完整dex的问题就简化成了——如何在基线包dex里面去掉补丁包中包含的所有类。

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

虽然这样做会把这个被移除类的无用信息残留在dex文件中,但这些信息占不了大多空间,并且对dex的处理速度是提升很大的,这种移除类操作的方式变得十分轻快。

android multidex机制对于Application的处理方式为:
将Application用到的类都打包到主dex中,因此只要把热修复的初始化放到attachBaseContext的最前面就基本上不会出问题了。


DexFile loadDex在Dalvik和Art下的工作细节

DexFile.loadDex尝试把一个dex文件解析加载到native内存都发生了什么?

不管Dalvik或者Art虚拟机,他们都调用了DexFile.openDexFileNative这个native方法。

在Dalvik虚拟机下面:

static void Dalvik_dalvik_system_DexFile_openDexFileNative(const u4* arg, JValue* pResult) {
    if (hasDexExtension(sourceName) && 
dvmRawDexFileOpen(sourceName, outputName, &pRawDexFile, false) == 0) { // 加载一个原始dex文件
         ALOGV("Open DEX file '%s' (DEX)", sourceName);

        pDexOrJar = (DexOrJar*) malloc(sizeof(DexOrJar));
        pDexOrJar -> isDex = true;
        pDexOrJar -> pRawDexFile = pRawDexFile;
        pDexOrJar -> pDexMemory = NULL;
    } else if (dvmJarFileOpen(sourceName, outputName, &pJar) == 0) {
        ALOGV("Open DES file'%s' (Jar)", sourceName);

        pDexOrJar = (DexOrJar*) malloc(sizeof(DexOrJar));
        pDexOrJar -> isDex = false;
        pDexOrJar -> pJarFile = pJarFile;
        pDexOrJar -> mDexMemory = NULL;
    } else {
        ALOGV("Unable to open DEX file '%s'", sourceName);
        dvmThrowIOException("unable to open DEX file");
    }
}

int dvmJarFileOpen(const char* fileName, const char* odexOutputName, JarFile** ppJarFile, bool isBootstrap)
    ...
    else {
        ZipEntry entry;
tryArchive:
        /*
         * Pre-created .odex absent or stale. Look inside the jar for a 
         * "classes.dex".
         */
        entry = dexZipFindEntry(&archive, kDexInJarName); // kDexJarName == "classes.dex", 说明只加载一个dex
        ...
    }

static const char* kDexInJarName = "classes.dex"; 很明显Dalvik尝试加载一个压缩文件的时候只会把classes.dex加载到内存。如果此时压缩文件有多dex,那么其他的dex文件会被直接忽略。

ART虚拟机:
方法调用连:DexFile_oepnDexFileName -> openDexFilesFromOat -> LoadDexFiles

std::vector<std::unique_ptr<const DexFile>> oatFileAssistant::LoadDexFiles(
const OatFile& oat_file, const char* dex_location) {
    // Load the primary dex file.
    const OatFile::OatDexFile* oat_dex_file = oat_file.GetOatDexFile(dex_location, nullptr, false);
    std::unique_ptr<const DexFile> dex_file = oat_dex_file -> OpenDexFile(&error_msg);
    dex_files.push_back(str::move(dex_file));

    // Load secondary multidex files
    for (size_t i = 1; ; i++) {
        std::string secondary_dex_location = DexFile::GetMultiDexLocation(i, dex_location);
        oat_dex_file = oat_file.GetOatDexFile(secondary_dex_location.c_str(), nullptr, false);
        dex_file = oat_dex_file->OpenDexFile(&error_msg);
        dex_file.push_back(std::move(dex_file));
    }

    return dex_files;
}

可以从代码上看的出来,Art下面已经默认支持加载压缩文件中的多个dex,首先肯定要先加载primary dex,其实就是classes.dex,后续会加载其他的dex。

所以补丁类放到classes.dex就可以实现补丁类先加载,后续在其他dex中的补丁类是不会被重复加载的。

对比Tinker方案

在Dalvik
走普通的multidex方案,需要手动加载,补丁包确保要放置到dexElements数组的最前面。

在Art下面:
我们只需要把补丁dex命名为classes.dex。原apk中的apk一次命名为classes(2,3,4...).dex就好了,然后一起打包为一个压缩文件。然后通过DexFile.loadDex得到DexFile对象,最后把该DexFile对象整个替换掉就的dexElements数组就可以了。

方案不同点

推荐阅读更多精彩内容