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

972 阅读15分钟

前言

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

冷启动实现方案概述

冷启动重启生效,现在一般有以下两种实现方案, 同时给出他们各自的优缺点:
方案一

原理: 为了解决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操作。

_1

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。

_2

上面的代码很容易看出来, 类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变量的初始化赋值等等操作。

_3

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

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

插桩导致类加载性能影响

_4

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

不插桩插桩
700个类84ms685ms
启动耗时4934ms7240ms

平均每个类verify+optimize(跟类的大小有关系)的耗时并不长,而且这个耗时每个类只有一次(类只会加载一次)。但由于应用刚启动时这种场景下一般会同时加载大量的类,在这个情况影响还是比较大的, 启动的时候就容易白屏, 这点是没法容忍的。

另辟蹊径解决方案

方案1 强制绕过类Verify阶段

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

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

方案2 优雅实现避免插桩

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

_5

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

_6

我们此时需要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参数是非必须的。

_7

完美解决。 这个思路与前面方案一的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虚拟机下面:

_8

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

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

_9

上面代码我们大概可以看出来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数组就可以了。

一张图来看下我们的方案和方案二的不同:

_10

需要注意一点:

  • 补丁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)加载而已。