Android热修复升级探索——代码修复冷启动方案

前言java

前面一篇文档, 咱们提到热部署修复方案有诸多特色(有关热部署修复方案实现, Android热修复升级探索——追寻极致的代码热替换)。其根本原理是基于native层方法的替换, 因此当类结构变化时,如新增减小类method/field在热部署模式下会受到限制。 但冷部署能突破这种约束, 能够更好地达到修复目的, 再加上冷部署在稳定性上具备的独特优点, 所以能够做为热部署的有利补充而存在。android

冷启动实现方案概述c++

冷启动重启生效,如今通常有如下两种实现方案, 同时给出他们各自的优缺点:
方案一算法

原理: 为了解决Dalvik下unexpected dex problem异常而采用插桩的方式, 单独放一个帮助类在独立的dex中让其余类调用, 阻止了类被打上CLASS_ISPREVERIFIED标志从而规避问题的出现。 最后加载补丁dex获得dexFile对象做为参数构建一个Element对象插入到dexElements数组的最前面。
提供dex差量包, 总体替换dex的方案。 差量的方式给出patch.dex, 而后将patch.dex与应用的classes.dex合并成一个完整的dex, 完整dex加载获得的dexFile对象做为参数构建一个Element对象而后总体替换掉旧的dexElements数组。数组

优势:没有合成整包,产物比较小,比较灵活。自研dex差别算法, 补丁包很小, dex merge成完整dex, Dalvik不影响类加载性能, Art下也不存在必须包含父类/ 引用类的状况;安全

缺点:Dalvik下影响类加载性能,Art下类地址写死, 致使必须包含父类/引用, 最后补丁包很大。dex合并内存消耗在vm heap上, 容易OOM, 最后致使dex合并失败。微信

方案二函数

原理:提供dex差量包, 总体替换dex的方案。 差量的方式给出patch.dex, 而后将patch.dex与应用的classes.dex合并成一个完整的dex, 完整dex加载获得的dexFile对象做为参数构建一个Element对象而后总体替换掉旧的dexElements数组。工具

优势:自研dex差别算法, 补丁包很小, dex merge成完整dex, Dalvik不影响类加载性能, Art下也不存在必须包含父类/引用类的状况;性能

缺点:dex合并内存消耗在vm heap上, 容易OOM, 最后致使dex合并失败。

咱们能清晰的看到两个方案的缺点都很明显。 这里对tinker方案dex merge缺陷进行简单说明一下: 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层实现起来比较复杂而已。

文章的开头咱们说过, 咱们的需求是冷启动模式是热部署模式的补充兜底方案, 因此这两个方案使用的应该是同一套补丁, 另一个方面跟代码修复热部署方案同样, 咱们追求的是不侵入打包。 上述两种方案都须要侵入应用打包过程, 同时补丁的结构也不同, 这两套方案对咱们来讲都是不适用。 因此咱们须要另辟蹊径冷启动修复, 寻求一种既能无侵入打包又能作热部署模式下兜底补充的解决方案, 下面将对Dalvik虚拟机和Art虚拟机的冷启动方案分别进行介绍。

Dalvik下冷启动实现

插桩实现的来龙去脉

众所周知, 若是仅仅把补丁类打入补丁包中而不作任何处理的话, 那么运行时类加载的时候就会异常退出, 接下来先来看下抛这个异常的来龙去脉。

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

图片描述

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类是补丁类, 因此补丁A类在单独的dex中。 类B中的某个方法引用到补丁类A, 因此执行到该方法会尝试解析类A。

图片描述

上面的代码很容易看出来, 类B因为被打上了CLASS_ISPREVERIFIED标志, 接下来referrer是类B, resClassCheck是补丁类A, 他们属于不一样的dex, 因此dvmThrowIllegalAccessError。 为了解决这个问题, 一个单独无关帮助类放到一个单独的dex中, 原dex中全部类的构造函数都引用这个类,通常的实现方法都是侵入dex打包流程, 利用.class字节码修改技术, 在全部.class文件的构造函数中引用这个帮助类, 插桩由此而来。 根据前面的介绍, dexopt过程当中dvmVerifyClass类校验返回false, 原dex中全部的类都没有CLASS_ISPREVERIFIED标志, 所以解决运行时这个异常。

可是插桩是会给类加载效率带来比较严重的影响的。 熟悉Dalvik虚拟机的同窗知道, 一个类的加载一般有三个阶段, dvmResolveClass->dvmLinkClass->dvmInitClass, 这个三个阶段不一一详细进行说明。 dvmInitClass阶段在类解析完毕尝试初始化类的时候执行, 这个方法主要完成父类的初始化,当前类的初始化, static变量的初始化赋值等等操做。

能够看到除了上面说的类初始化以外, 若是类没被打上CLASS_ISPREVERIFIED/CLASS_ISOPTIMIZED标志, 那么类的Verify和Optimize都将在类的初始化阶段进行。 正常状况下类的Verify和Optimize都仅仅只是在apk第一次安装执行dexopt的时候进行, 类的Verify其实是很重的, 由于会对类的全部方法中的全部指令都进行校验, 单个类加载来看类Verify并不耗时, 可是若是同一时间点加载大量类的状况下, 这个耗时就会被放大。 因此这也是插桩给类的加载效率带来比较大影响的后果, 接下来来看下具体会给类加载带来多大的影响。

更多有关Dalvik虚拟机的原理, 能够自行下载源码阅读: https://android.googlesource.com 推荐姿式: sublime text + ctags

插桩致使类加载性能影响

图片描述

上一小节的介绍, 咱们知道若采用插桩致使全部类都非preverify,这致使verify与optimize操做会在加载类时触发。 这就会致使类加载有必定的性能损耗,微信作过一次测试, 分别采用优化和不优化两种方式作过两种测试, 分别采用插桩与不插桩两种方式进行两种测试,一是连续加载700个50行左右的类,一是统计应用启动完成的整个耗时。

不插桩 插桩
700个类 84ms 685ms
启动耗时 4934ms 7240ms
平均每一个类verify+optimize(跟类的大小有关系)的耗时并不长,并且这个耗时每一个类只有一次(类只会加载一次)。但因为应用刚启动时这种场景下通常会同时加载大量的类,在这个状况影响仍是比较大的, 启动的时候就容易白屏, 这点是无法容忍的。

另辟蹊径解决方案

方案1 强制绕过类Verify阶段

强制hook Dalvik虚拟机的dvmVerifyClass函数,让其直接返回true,从而绕过加载的时候没必要要的校验机制,从而达到加快应用的启动速度的目的。 实际上集团安所有已经有这样的方案。 具体参考: dalvikUpSpeed技术介绍--加快android移动端低端机的启动性能

可是这种方案也存在明显的缺陷: 此时native hook的是一个涉及dalvik基础功能同时调用很频繁的方法,无疑可能存在比较大的风险。 另一方面这个仍是须要插桩的, 须要侵入打包流程, 打包时修改.class字节码文件, 因为咱们热修复的基调是彻底不侵入打包流程, 因此须要寻求另一种更优雅的解决方案。

方案2 优雅实现避免插桩

手Q热补丁轻量级方案给了咱们实现的思路, 简单来说:

图片描述

怎么让dvmDexGetResolvedClass返回的结果不为null,只要调用过一次dvmDexSetResolvedClass(pDvmDex, classIdx, resClass);就好了,举个例子简单说明下。

图片描述

咱们此时须要patch的类是类A, 因此类A被打入到一个独立的补丁dex中。那么执行到类B的test方法时, 执行到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从而致使运行时异常。 因此咱们要作的是, 必需要在一开始的时候, 就把补丁A类添加到原来dex(pDvmDex)的pResClasses数组中。 这样就确保了执行B类test方法的时候, dvmDexGetResolvedClass不为null, 就不会执行后面类A和类B的dex一致性校验了。

具体实现, 首先咱们经过补丁工具反编译dex为smali文件拿到:

preResolveClz: 须要patch的类A的描述符, 非必须, 为了调试方便加上该参数而已. --> Lcom/taobao/patch/demo/A;
refererClz: 须要patch的类A所在的dex的任何一个类描述符, 注意这里不限定必须是引用补丁类A的某个类, 实际上只要同一个dex中的任何一个类均可以。 因此咱们直接拿原dex中的第一个类便可. --> Landroid/support/annotation/AnimRes;
classIdx: 须要patch的类A在原来dex文件中的类索引id. --> 2425
而后经过dlopen拿到libdvm.so库的句柄, 而后经过dlsym拿到该so库的dvmResolveClass/dvmFindLoadedClass函数指针。 首先须要预加载引用类->android/support/annotation/AnimRes, 这样dvmFindLoadedClass("android/support/annotation/AnimRes")才不为null, dvmFindLoadedClass执行结果获得的ClassObject作为第一个参数执行dvmResolveClass(AnimRes, 2425, true)便可。
简单看下JNI层代码部分实现。 实际上能够看到preResolveClz参数是非必须的。

图片描述

完美解决。 这个思路与前面方案一的native hook方式不一样,不会去hook某个系统方法,而是从native层直接调用, 同时更不须要插桩。 具体实现须要注意如下三点:

dvmResolveClass的第三个参数fromUnverifiedConstant必须为true。
apk多dex状况下,dvmResolveClass第一个参数referrer类必须跟须要patch的类在同一个dex, 可是他们两个类不须要存在任何引用关系,任何一个在同一个dex中的类做为referrer均可以。
referrer类必须提早加载。
Art下冷启动实现

前面说过补丁热部署模式下是一个完整的类, 补丁的粒度是类。 如今咱们的需求是补丁既能走热部署模式也能走冷启动模式, 为了减小补丁包的大小, 并无为热部署和冷启动分别准备一套补丁, 而是同一个热部署模式下的补丁可以降级直接走冷启动, 因此咱们不须要作dex merge。 可是前面咱们知道为了解决Art下类地址写死的问题, tinker经过dex merge成一个全新完整的新dex整个替换掉旧的dexElements数组。 事实上咱们并不须要这样作, Art虚拟机下面默认已经支持多dex压缩文件的加载了。

咱们分别来看下Dalvik下和Art下对DexFile.loadDex尝试把一个dex文件解析加载到native内存都发生了什么,实际上都是调用了DexFile.openDexFileNative这个native方法。 看下Native层对应的c/c++代码具体实现。

Dalvik虚拟机下面:

图片描述

static const char* kDexInJarName = "classes.dex"; 很明显Dalvik尝试加载一个压缩文件的时候只会去把classes.dex加载到内存中... 若是此时压缩文件中有多dex, 那么除了classes.dex以外的其它dex被直接忽略掉。

Art虚拟机下面: 方法调用链DexFile_openDexFileNative-> OpenDexFilesFromOat -> LoadDexFiles

图片描述

上面代码咱们大概能够看出来Art下面默认已经支持加载压缩文件中包含多个dex, 首先确定优先加载primary dex其实就是classes.dex, 后续会加载其它的dex, 因此补丁类只须要放到classes.dex便可。 后续出如今其它dex中的"补丁类"是不会被重复加载的。 因此咱们获得Art下最终的冷启动解决方案: 咱们只要把补丁dex命名为classes.dex. 原apk中的dex依次命名为classes(2,3,4...).dex就行了, 而后一块儿打包为一个压缩文件, 而后DexFile.loadDex获得DexFile对象, 最后把该DexFile对象整个替换旧的dexElements数组就能够了。

一张图来看下咱们的方案和方案二的不一样:

图片描述

须要注意一点:

补丁dex必须命名为classes.dex
loadDex获得的DexFile完整替换掉dexElements数组而不是插入
不得不说的其它点

咱们知道DexFile.loadDex尝试把一个dex文件解析并加载到native内存, 在加载到native内存以前, 若是dex不存在对应的odex, 那么Dalvik下会执行dexopt, Art下会执行dexoat, 最后获得的都是一个优化后的odex。 实际上最后虚拟机执行的是这个odex而不是dex。

如今有这么一个问题,若是dex足够大那么dexopt/dexoat其实是很耗时的,根据上面咱们提到的方案, Dalvik下实际上影响比较小, 由于loadDex仅仅是补丁包。 可是Art下影响是很是大的, 由于loadDex是补丁dex和apk中原dex合并成的一个完整补丁压缩包, 因此dexoat很是耗时。 因此若是优化后的odex文件没生成或者没生成一个完整的odex文件, 那么loadDex便不能在应用启动的时候进行的, 由于会阻塞loadDex线程, 通常是主线程。 因此为了解决这个问题, 咱们把loadDex当作一个事务来看, 若是中途被打断, 那么就删除odex文件, 重启的时候若是发现存在odex文件, loadDex完以后, 反射注入/替换dexElements数组, 实现patch。 若是不存在odex文件, 那么重启另外一个子线程loadDex, 重启以后再生效。

另一方面为了patch补丁的安全性, 虽然对补丁包进行签名校验, 这个时候可以防止整个补丁包被篡改, 可是实际上由于虚拟机执行的是odex而不是dex, 还须要对odex文件进行md5完整性校验, 若是匹配, 则直接加载。 不匹配,则从新生成一遍odex文件, 防止odex文件被篡改。

小结

代码修复冷启动方案因为它的高兼容性, 几乎能够修复任何代码修复的场景, 可是注入前被加载的类(好比:Application类)确定是不能被修复的。 因此咱们把它做为一个兜底的方案, 在无法走热部署或者热部署失败的状况, 最后都会走代码冷启动重启生效, 因此咱们的补丁是同一套的。 具体实施方案对Dalvik下和Art下分别作了处理:

Dalvik下经过巧妙的方式避免插桩, 没有带来任何类加载效率的影响。Art下本质上虚拟机已经支持多dex的加载, 咱们要作的仅仅是把补丁dex做为主dex(classes.dex)加载而已。

相关文章
相关标签/搜索