Moosphan / Android-Daily-Interview

:pushpin:每工作日更新一道 Android 面试题,小聚成河,大聚成江,共勉之~

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

2019-06-11:简述下热修复的原理?

MoJieBlog opened this issue · comments

2019-06-11:简述下热修复的原理?

热修复 一词最早应该是 安卓App热补丁动态修复技术=》有没有办法以补丁的方式动态修复紧急Bug,不再需要重新发布App,不再需要用户重新下载,覆盖安装??QQ空间Android团队提出了一个方案:把有问题的类打包到一个dex(patch.dex)中去,然后把这个dex插入到Elements的最前面,该方案基于第二个拆分dex的方案,方案实现如果懂拆分dex的原理的话,大家应该很快就会实现该方案,如果没有拆分dex的项目的话,可以参考一下谷歌的multidex方案实现。然后在插入数组的时候,把补丁包插入到最前面去。

热修复比较重要的一个点也就是classLoader

我们知道Android系统也是仿照java搞了一个虚拟机,不过它不叫JVM,它叫Dalvik/ART VM他们还是有很大区别的(这是不是我们的重点)。我们只需要知道,Dalvik/ART VM 虚拟机加载类和资源也是要用到ClassLoader,不过Jvm通过ClassLoader加载的class字节码,而Dalvik/ART VM通过ClassLoader加载则是dex。

代码修复主要有三个方案,分别是底层替换方案、类加载方案和Instant Run方案。3.1 类加载方案类加载方案基于Dex分包方案,什么是Dex分包方案呢?这个得先从65536限制和LinearAlloc限制说起。
65536限制
随着应用功能越来越复杂,代码量不断地增大,引入的库也越来越多,可能会在编译时提示如下异常:com.android.dex.DexIndexOverflowException:methodIDnotin[0, 0xffff]: 65536

这说明应用中引用的方法数超过了最大数65536个。产生这一问题的原因就是系统的65536限制,65536限制的主要原因是DVM Bytecode的限制,DVM指令集的方法调用指令invoke-kind索引为16bits,最多能引用 65535个方法。
LinearAlloc限制
在安装时可能会提示INSTALL_FAILED_DEXOPT。产生的原因就是LinearAlloc限制,DVM中的LinearAlloc是一个固定的缓存区,当方法数过多超出了缓存区的大小时会报错。为了解决65536限制和LinearAlloc限制,从而产生了Dex分包方案。Dex分包方案主要做的是在打包时将应用代码分成多个Dex,将应用启动时必须用到的类和这些类的直接引用类放到主Dex中,其他代码放到次Dex中。当应用启动时先加载主Dex,等到应用启动后再动态的加载次Dex,从而缓解了主Dex的65536限制和LinearAlloc限制。Dex分包方案主要有两种,分别是Google官方方案、Dex自动拆包和动态加载方案。因为Dex分包方案不是本章的重点,这里就不再过多的介绍,我们接着来学习类加载方案。
在Android解析ClassLoader(二)Android中的ClassLoader中讲到了ClassLoader的加载过程,其中一个环节就是调用DexPathList的findClass的方法,如下所示。
libcore/dalvik/src/main/java/dalvik/system/DexPathList.java

publicClass<?> findClass(String name, List suppressed) {

for(Element element : dexElements) {//1

Class<?> clazz = element.findClass(name, definingContext, suppressed);//2

if(clazz !=null) {

returnclazz;

}

}

if(dexElementsSuppressedExceptions !=null) {

suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));

}

returnnull;

}

Element内部封装了DexFile,DexFile用于加载dex文件,因此每个dex文件对应一个Element。
多个Element组成了有序的Element数组dexElements。当要查找类时,会在注释1处遍历Element数组dexElements(相当于遍历dex文件数组),注释2处调用Element的findClass方法,其方法内部会调用DexFile的loadClassBinaryName方法查找类。如果在Element中(dex文件)找到了该类就返回,如果没有找到就接着在下一个Element中进行查找。
根据上面的查找流程,我们将有bug的类Key.class进行修改,再将Key.class打包成包含dex的补丁包Patch.jar,放在Element数组dexElements的第一个元素,这样会首先找到Patch.dex中的Key.class去替换之前存在bug的Key.class,排在数组后面的dex文件中的存在bug的Key.class根据ClassLoader的双亲委托模式就不会被加载,这就是类加载方案,如下图所示。
类加载方案需要重启App后让ClassLoader重新加载新的类,为什么需要重启呢?这是因为类是无法被卸载的,因此要想重新加载新的类就需要重启App,因此采用类加载方案的热修复框架是不能即时生效的。
虽然很多热修复框架采用了类加载方案,但具体的实现细节和步骤还是有一些区别的,比如QQ空间的超级补丁和Nuwa是按照上面说得将补丁包放在Element数组的第一个元素得到优先加载。微信Tinker将新旧apk做了diff,得到patch.dex,然后将patch.dex与手机中apk的classes.dex做合并,生成新的classes.dex,然后在运行时通过反射将classes.dex放在Element数组的第一个元素。饿了么的Amigo则是将补丁包中每个dex 对应的Element取出来,之后组成新的Element数组,在运行时通过反射用新的Element数组替换掉现有的Element 数组。采用类加载方案的主要是以腾讯系为主,包括微信的Tinker、QQ空间的超级补丁、手机QQ的QFix、饿了么的Amigo和Nuwa等等。3.2 底层替换方案与类加载方案不同的是,底层替换方案不会再次加载新类,而是直接在Native层修改原有类,由于是在原有类进行修改限制会比较多,不能够增减原有类的方法和字段,如果我们增加了方法数,那么方法索引数也会增加,这样访问方法时会无法通过索引找到正确的方法,同样的字段也是类似的情况。
底层替换方案和反射的原理有些关联,就拿方法替换来说,方法反射我们可以调用java.lang.Class.getDeclaredMethod,假设我们要反射Key的show方法,会调用如下所示。Key.class.getDeclaredMethod("show").invoke(Key.class.newInstance());

Android 8.0的invoke方法,如下所示。
libcore/ojluni/src/main/java/java/lang/reflect/Method.java

刚好之前研究过热修复,说下自己的观点:
热修复分为三个部分,分别是Java代码部分热修复,Native代码部分热修复,还有资源热修复。

资源部分热更新直接反射更改所有保存的AssetManager和Resources对象就行(可能需要重启应用)

Native代码部分也很简单,系统找到一个so文件的路径是根据ClassLoader找的,修改ClassLoader里保存的路径就行(可能需要重启应用)

Java部分的话目前主流有两种方式,一种是Java派,一种是Native派。

  • java派:通过修改ClassLoader来让系统优先加载补丁包里的类
    代表作有腾讯的tinker,谷歌官方的Instant Run,包括multidex也是采用的这种方案
    优点是稳定性较好,缺点是可能需要重启应用
  • native派:通过内存操作实现,比如方法替换等
    代表作是阿里的SopHix,如果算上hook框架的话,还有dexposed,epic等等
    优点是即时生效无需重启,缺点是稳定性不好:
    如果采用方法替换方式实现,假如这个方法被内联/Sharpening优化了,那么就失效了;inline hook则无法修改超短方法。
    热修复后使用反射调用对应方法时可能发生IllegalArgumentException。

一点不懂 这又是催我学习的节奏

打卡,有点迷茫了

热更新 / 热修复
不安装新版本的软件直接从网络下载新功能模块对软件进行局部更新
热更新和插件化的区别
区别有两点:
1.插件化的内容在原来的App中没有,而热更新在原来 App 做了 改动
2. 插件化在代码中有固定的入口,而热更新则可能改变任何一个位置的代码
热更新的原理

  1. classsLoder 的 dex 文件替换
  2. 直接修改字节码文件

了解热更新就必须了解 loadeClass() 的类加载过程

  • 宏观上: loadClass() 是一个带缓存,自上而下的加载过程(即网上说的[双亲委托机制])

  • 对于一个具体的 ClassLoader

    • 先从自己缓存中取
    • 自己缓存没有,就在 父 ClassLoader 要 (parent.loadClass())
    • 父 View 没有,就自加载(findClass)
  • BaseDexClassLoader 或者 它的子类(DexClassLoader,PathClassLoader) 的 findClass()

    • 通过它的 pathList.findClass()
    • 它的 pathList .loadClass() 通过 pathClassLoader 等
  • 所以热更新关键在于,把补丁 dex 文件加载到 Element 对象插入到 dexElement 前面才行.插入后面会被忽阅掉
    正确的做法是:反射

  1. 自己用补丁创建一个PathClassLoader
    2.把补丁 PathClassLoader 里面的 elments 替换到旧的里面去
    注意:
    1.尽早加载热更新(通用手段把加载过程放到Application.attachBaseContext())
  2. 把 补丁 PathClassLoader 里面的 elements 替换到旧的里面去
  3. 热更新下载完成后在需要时先杀死进程才能补丁生效
  4. 优化:热更新没必要把所有的内容都打过来,只要把类拿过来就行了
    • d8 把 指定 的 class 打包 进 dex
      5.完整化: 从网上加载
  5. 再优化: 把打包过程写一个task

学习一下

我们应用程序中的所有的类在APK打包的时候打包成了classDex文件 classDex文件如果采用分包的形式就会打包成多个classDex 所以需要一个dexElemt[]数组来对classDex做管理 把所有的classDex都存放在dexElemt中 我们应用程序中的类加载都是通过dexElemt去加载的 所以我们做修复时要把修复好的classDex重新存放到dexElemt中
在我们整个应用程序里面我们所有的代码都是被dexElemt[]数组来管理的 因为我们的程序里面有可能会用到分包 把应用程序里面的所有class文件采用分包形式打成多个classDex文件 所以使用dexElemts把所有的classDex文件添加到dexElemts数组中管理
所以热修复的原理就是通过类加载器PathClassLoader获取我们应用的dexElemt 我们应用程序的所有类都是在这个dexElemt中 然后把修复好的类打包成classDex文件 通过类加载器DexClassLoader去加载修复打包好的classDex 得到修复好的dexElemt 然后把修复的dexElemts 和当前应用程序的dexElemt进行合并 在合并的过程把修复好的 dexElemt 放在当前应用程序的dexElemt前面 当应用程序加载类时 如果加载到了修复好的类的话就不会再去加载后面相同的类了 通过这种方式实现了热修复