六.热修复

前言
什么是热修复?

应用在上线后出现bug需要及时修复,不用再发布新的安装包,只需要发布补丁包,在用户无感知情况下修复掉bug。

如何进行热修复?
  • 服务端:补丁包管理
  • 用户端:执行热修复
  • 开发端:生成补丁包
热修复需要解决的问题

开发端

  • 补丁包是什么?

    补丁包就是修复了bug的dex文件和jar包。

  • 如何生成补丁包?

    将修复了bug的java文件通过javac编译成class文件,然后通过dx工具将class文件打包成dex文件或者是jar包,这就是补丁包。

  • 开启混淆后呢?

    开启混淆后,类名和包名会发生改变,为了保证混淆产生的包名和类名不变,我们需要通过mapping文件来解决。

  • 对比改动自动生成补丁包(gradle)?

    gradle生成热修复的插件方式,适合的场景是问题较小、编写的代码不多的情况。
    用户端

  • 什么时候执行热修复?

    越早越好,要在加载bug类之前执行,完成热修复。

  • 如果应用已经在运行,并且这个类已经加载过了,还能进行热修复吗?

    不能进行热修复了,因为如果这个类已经加载过了,就会存在于缓存中,不会再从dex文件中去查找这个类的class文件。

  • 热修复完成后,补丁包dex文件和原来的dex文件,是否需要删除?

    不能删除。
    如果补丁包dex文件被删除,缓存又被清空的情况下,又会执行原来没有bug的流程;
    如果原来的dex文件被删除,那么dex文件中包含的所有逻辑代码都被会删除,我们的app将无法运行。

  • 怎么执行热修复(使用补丁包)?

    将补丁包下载下来,然后通过反射技术,将补丁包通过DexPathList中的makeElement方法生成修复bug的Element[]数组,再通过反射技术,获取到存在bug的Element[]数组,将修复了bug的Element[]和存在bug的Element[]拼接成一个新的数组,在进行类加载的时候,就会优先加载修复了bug的dex文件中的class,从而实现热修复。

  • Android版本兼容性问题
    1. AndroidN(Android7.0)热点代码存在JIT即时编译,解决方案是自定义一个PathClassLoader,去掉缓存代码逻辑,就可以先加载修复了bug的代码。
    2. Dalvik(Android5.0)虚拟机存在不同的dex文件使用兼容性问题,如果没有直接使用别的dex文件中的类,该dex文件中这个类就会被打上标签标识,从而无法加载修复了bug的dex文件。
      解决方案是,通过字节码插桩技术,对存在bug的dex文件中的字节码文件进行代码编写,引用别的dex文件中的类,消除标签标识,从而可以通过热修复加载修复bug的dex文件中的class,完成热修复。
    3. 不同的版本,一些方法的方法名和参数个数和参数类型不尽相同,所以也需要进行适配。
1.Android常用的热修复解决方案
Tinker(腾讯)QZone(腾讯)AndFix(阿里)Robust(美团)
类替换YYNN
so替换YYNN
资源替换YYNN
全平台生效YYYY
及时生效NNYY
性能损耗较小较大较小较小
补丁包大小较小较大一般一般
开发透明YYNN
复杂度较小较小复杂复杂
gradle支持YNNN
rom体积较大较小较小较小
成功率较高较高一般最高

AndFix(补丁包是.dex文件)
是由阿里开发的热修复框架,但是已经废弃,很多年没有维护了。
他的热修复实现原理如下:
在native层动态替换Java层的方法,通过native层hook Java层代码。

Robust(补丁包是.dex文件)
由美团开发,目前抖音就采用这种热修复方案,可以及时生效。
他的热修复实现原理如下:
利用gradle插件,在我们写的类中,编译时生成一个静态接口变量,在方法中会对这个接口变量进行判断。
如果我们需要进行热修复,就写一个接口的实现类,然后通过类加载和反射,找到我们写的类并创建实例,赋值给要修复bug的类中的静态接口变量即可,当我们的方法中判断到这个接口变量非空时,就会去执行实现类中的业务逻辑,从而达到了修复bug的目的,并且是及时生效的。

Tinker(补丁包是差分包)
由腾讯开发,目前微信就采用了这中热修复方案,优点是可以可以修复类、so包和资源文件。
他的热修复实现原理如下:
由有bug的apk文件和修复bug的apk文件共同生成一个差分包patch,这个patch文件就是我们的补丁包,下载这个差分包和有bug的apk,就可以生成修复bug的apk。如果没有资源文件修复,那么就会生成的就是一个.dex文件,可以通过类加载和反射技术,加载修复bug的类,从而实现热修复。

2.ClassLoader类加载机制
2.1 Android类加载器

ClassLoader继承关系:

  • ClassLoader
    • BootClassLoader 用于加载Android Framework层class文件
    • BaseDexClassLoader 包含了DexPathList{dexElement[]、findClass()、makeElement[]}
      • PathClassLoader 额外提供的动态类加载器
        加载指定的dex、以及jar、zip、apk中的classes.dex
      • DexClassLoader Android应用程序类加载器
        加载指定的dex、以及jar、zip、apk中的classes.dex

使用举例:

public class MyApplication extends Application {

  private final String TAG = MyApplication.class.getSimpleName();

  @Override
  public void onCreate() {
    super.onCreate();

    //获取使用ClassLoader的场景
    ClassLoader classLoader1 = TKActivity.class.getClassLoader();
    ClassLoader classLoader2 = AppCompatActivity.class.getClassLoader();
    ClassLoader classLoader3 = Application.class.getClassLoader();
    ClassLoader classLoader4 = getClassLoader();
    Log.e(TAG, "classLoader1:" + classLoader1.toString());
    Log.e(TAG, "classLoader2:" + classLoader2.toString());
    Log.e(TAG, "classLoader3:" + classLoader3.toString());
    Log.e(TAG, "classLoader4:" + classLoader4.toString());
  }
}
 
打印日志:
E/MyApplication: classLoader1:dalvik.system.PathClassLoader[DexPathList[[zip file "/data/app/com.tangkun.xiangxuestudy-4SJ9lfa-q8--dmTGHgBPbA==/base.apk"],nativeLibraryDirectories=[/data/app/com.tangkun.xiangxuestudy-4SJ9lfa-q8--dmTGHgBPbA==/lib/arm64, /system/lib64, /product/lib64]]]
E/MyApplication: classLoader2:dalvik.system.PathClassLoader[DexPathList[[zip file "/data/app/com.tangkun.xiangxuestudy-4SJ9lfa-q8--dmTGHgBPbA==/base.apk"],nativeLibraryDirectories=[/data/app/com.tangkun.xiangxuestudy-4SJ9lfa-q8--dmTGHgBPbA==/lib/arm64, /system/lib64, /product/lib64]]]
E/MyApplication: classLoader3:java.lang.BootClassLoader@4c33cb2
E/MyApplication: classLoader4:dalvik.system.PathClassLoader[DexPathList[[zip file "/data/app/com.tangkun.xiangxuestudy-4SJ9lfa-q8--dmTGHgBPbA==/base.apk"],nativeLibraryDirectories=[/data/app/com.tangkun.xiangxuestudy-4SJ9lfa-q8--dmTGHgBPbA==/lib/arm64, /system/lib64, /product/lib64]]]
2.2 双亲委托机制

当我们在页面中使用PathClassLoader去加载一个类的全路径时,代码如下:
getClassLoader().loadClass("com.tangkun.study.MainActivity");
此时,getClassLoader方法返回的是PathClassLoader,但是该类中没有loadClass方法;所以我们从他的父类BaseDexClassLoader中去寻找这个方法,这个类中同样没有loadClas方法;所以我们再从他的父类ClassLoader中去查找这个方法,代码如下:

//ClassLoader.java
//这一段就是双亲委托机制代码,要找到某个class文件,先委托给父类parent去查找,
//如果父类没有查找到,则通过子类去查找class文件,并且找到的class文件会被存放在缓存中
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException{
        //若class被加载过了,会存到缓存中。所以先从缓存中查找是否存在class
        //缓存中的class是从native层中查找得来
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            try {
                //parent是BootClassLoader
                if (parent != null) {
                    //调用BootClassLoader.loadClass去查找class;
                    //这里会调用到native层的findLoadedClass方法,从缓存中查找class
                    c = parent.loadClass(name, false);
                } else {
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
            }
            //如果父类无法加载到class,则从PathClassLoader中去查找class
            if (c == null) {
                //findClass是一个抽象方法,在子类BaseDexClassLoader中实现了该方法
                c = findClass(name);
            }
        }
        return c;
}

//BaseDexClassLoader.java
private final DexPathList pathList;
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
    List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
    //pathList是传入的DexPathList对象,所以我们从DexPathList类中查看findClass方法
    Class c = pathList.findClass(name, suppressedExceptions);
    //省略非核心代码
    return c;
}

//DexPathList.java
//一个dex文件对应dexElements数组中一个元素Element;
//因为我们打出来的apk包中可能存在多个dex文件,所以是数组。
//DexPathList包含了内部类Element
private Element[] dexElements;
public Class<?> findClass(String name, List<Throwable> suppressed) {
    //从dex文件数组中遍历每个dex文件
    for (Element element : dexElements) {
        //Android中,多个java文件会被编译成多个.class文件,多个class文件会被打包成一个dex文件
        //一个dex文件中包含多个class文件,所以从class文件集合中去查找某一个确定的class文件
        //最后会调用到native层去查找这个class
        Class<?> clazz = element.findClass(name, definingContext, suppressed);
        if (clazz != null) {
            return clazz;
        }
    }
    //省略非核心代码
    return null;
}
2.3 类查找流程

class查找流程:

  • 先从缓存中去查找class是否存在;
  • 如果缓存中不存在,则委托给父类,由父类查找class是否存在;
  • 若父类无法查找到class,则由当前子类去查到该类。
3.插桩式热修复运行期修复落地

制作补丁包流程:
1、把Bug修复掉后,先生成类的class文件。
2、执行命令:
dx --dex --output=patch.jar com/tangkun/study/Utils.class

应用补丁包: patchElment(补丁包生成的) + oldElement(APK原有的) 赋值给oldElement
1、获取程序的PathClassLoader对象
2、反射获得PathClassLoader父类BaseDexClassLoader的pathList对象
3、反射获取pathList的dexElements对象 (oldElement)
4、把补丁包变成Element数组:patchElement(反射执行makePathElements)
5、合并patchElement+oldElement = newElement (Array.newInstance)
6、反射把oldElement赋值成newElement

makePathElements参数:
1、补丁包:List[new File(“/sdcard/patch.jar”)]
2、optimizedDirectory 传一个私有目录就行比如:context.getCacheDir()
3、ArrayList suppressedExceptions = new ArrayList();

3.1 什么是字节码插桩?

所谓字节码插桩,就是在字节码文件中进行代码编写,而我们的class文件就属于字节码文件。

我们编写的java文件在经过编译后,会生成class文件,class文件是2进制格式的,0101这种格式,我们肯定是无法直接在这种格式的文件上进行编码的。所以我们需要借助于别的工具来进行字节码文件代码编写的,比如:第三方框架ASM

如果直接对字节码操作是什么样的一个流程呢?

  • 通过文件输入流将字节码文件读到字节数组(byte[])中;
  • 对字节数组(byte[])中的数据进行修改;
  • 通过文件输出流将字节数组(byte[])写回字节码文件。
3.2 ASM

概念:ASM是用来操作字节码(.java文件生成的.class文件)的框架,按照class文件的格式,解析、修改、生成class,可以动态生成类或者增强现有类的功能。
类似于Gson框架,是用来操作json格式字符串的。

怎么使用ASM?
首先需要在build.gradle依赖ASM的的库,然后让我们需要使用字节码插桩的地方(比如:类、方法、属性),添加上ASM注解并带上插桩的实现类,然后在插桩的实现类中完成相应的功能。
开始和结束的地方,仍然还是需要使用文件输入输出流,用于读取和写入字节码文件;中间就是要我们自己实现借助于ASM来完成插桩代码的编写工作;在手写插桩代码之前,我们可以将需要实现的功能写入我们的java文件中,然后编译生成class文件,然后借助javap命令查看class文件中的代码(也可以通过ASM Bytecode Viewer插件来查看,我的AS由于版本原因,即使安装了这个插件还是看不了java文件的字节码代码;但是可以通过编译后生成的class文件,然后再利用这个插件查看,嘿嘿(*^▽^*)),然后借助于这些代码和ASM的功能,来完成插桩代码的编写。

然后我们就可以得到修复bug后的class文件,然后通过SDK的dx工具,通过执行命令行的命令,可以将class文件转换成dex文件或者是jar文件,我们就直接转换成dex文件,给后面热修复使用。

3.3 热修复落地实现

1、获取程序的PathClassLoader对象
2、反射获得PathClassLoader父类BaseDexClassLoader的pathList对象
3、反射获取pathList的dexElements对象 (oldElement)
4、把补丁包变成Element数组:patchElement(反射执行makePathElements)
5、合并patchElement+oldElement = newElement (Array.newInstance)
6、反射把oldElement赋值成newElement

3.4 热修复存在的版本兼容性问题

AndroidN(也就是Andorid7.0)

会进行JIT即时编译编译,会将app经常使用的一些代码也叫做热点代码,存储到profile文件中,在手机空闲的时候,会执行这些热点代码,从而产生了缓存。
因此,会导致我们热修复失败,因为即使我们通过Hook技术将修复了bug的class放在了有bug的class数组前面;但是由于缓存的原因,不会访问我们这个修复了bug的Element数组;而直接从缓存中获取class进行加载,而这个缓存中的class是存在bug的。所以,热修复无效。
如何解决上面这个问题呢?
采取运行时替换掉PathClassLoader方案,重新创建一个自定义的PathClassLoader,但是不重写缓存的代码,虽然会存在一些性能的影响,但是相较于有bug,这点性能的损耗还是值得的;自定义的PathClassLoader由于没有重写缓存逻辑,因此我们在进行热修复后,会优先执行修复了bug的class,从而问题得到解决。

Android5.0 Dalvik虚拟机

5.0及以下使用的虚拟机是Darvik,存在不同的dex文件之间使用问题,如果有bug的dex文件的类中没有直接引用过别的dex文件中的类时(需要对别的dex文件中类进行导包才行,反射不可以),这个类就会被标记成isVerified;在有这个标记之后,有bug的dex文件就无法使用别的dex文件中的类。
如何解决上面的问题呢?
采用字节码插桩技术,在有bug的类编译成的class文件中,通过字节码插桩技术,将另外一个dex文件中的类通过插桩技术写入并且导包,从而将有bug的类取消isVerified标记。我们就可以通过热修复,将修复bug的dex文件中的class添加到有bug的class数组前面,从而优先执行我们修复bug的class中的代码。

扩展知识:
不同版本还存在方法名称、方法中参数个数以及类型的差异,所以,我们还需要对这些情况做版本适配。
比如:我们DexPathList类中的makePathElements方法,是用来生成Element[]数组的。我们通过热修复,将修复bug的dex文件添加到这个数组的前面,达到优先执行修复bug的目的。但是这个方法在不同的版本中存在一定的差异,举例:
Android9.0.0: makePathElements(List<File> files, File optimizedDirectory,List<IOException> suppressedExceptions)
Android5.1.0: makeDexElements(ArrayList<File> files, File optimizedDirectory, ArrayList<IOException> suppressedExceptions)

4.自动化补丁方案
4.1 自定义插件

自己开发插件有3种方式:

  • Build script脚本
    • 把插件写在build.gradle中,一般用于简单的逻辑,只在该build.grale中文件可见。
  • buildSrc目录
    • 将插件源代码放在buildSrc/src/main/groovy/中,只在该项目中可见。
    • 实现Plugin接口,并重写apply方法。
  • 独立项目
    • 一个独立的Java项目/模块,可以将文件发布到仓库(Jcenter),使其他项目方便引入。
4.2 判断哪些文件需要打包进补丁包

可以采用缓存机制,将之前的class文件和这个class文件生成的md5值存起来;

  • 如果缓存中没有这个class文件,则表示我们的这个class文件是新建的,所以需要打包进入补丁包
  • 如果缓存中有这个class文件,那么就去比较缓存中class文件的md5值与我们重新生成的class文件的md5值是否一致,不一致表示新的class文件有代码更改,同样需要打包进入补丁包;
  • 如果缓存文件和新生成的class文件的md5值相同,则无需打包进入补丁包。

扩展知识:
可以通过Java代码,执行命令行,然后进行打包操作:

  1. 把.java编译为.class
    Runtime.getRuntime().exe("javac -bootclasspath android.jar路径 java源码和R.java路径");
  2. 把.class编译为.dex
    Runtime.getRuntime().exe("dx --dex classes路径");
4.3 代码混淆问题的解决方案

需要保证这一次混淆后的类名和上一次混淆后的类名相同,通过-applyMapping配置

代码混淆的时候会生成一份mapping文件,通过将mapping文件缓存下来,然后找到没有混淆的文件名、方法名等,然后进行处理。

面试题
1.说一说热修复的方案有哪些,并说说他们的区别?
Tinker(腾讯)QZone(腾讯)AndFix(阿里)Robust(美团)
类替换YYNN
so替换YYNN
资源替换YYNN
全平台生效YYYY
及时生效NNYY
性能损耗较小较大较小较小
补丁包大小较小较大一搬一搬
开发透明YYNN
复杂度较小较小复杂复杂
gradle支持YNNN
rom体积较大较小较小较小
成功率较高较高一搬最高

AndFix(补丁包是.dex文件)
是由阿里开发的热修复框架,但是已经废弃,很多年没有维护了。
他的热修复实现原理如下:
在native层动态替换Java层的方法,通过native层hook Java层代码。

Robust(补丁包是.dex文件)
由美团开发,目前抖音就采用这种热修复方案,可以及时生效。
他的热修复实现原理如下:
利用gradle插件,在我们写的类中,编译时生成一个静态接口变量,在方法中会对这个接口变量进行判断。
如果我们需要进行热修复,就写一个接口的实现类,然后通过类加载和反射,找到我们写的类并创建实例,赋值给要修复bug的类中的静态接口变量即可,当我们的方法中判断到这个接口变量非空时,就会去执行实现类中的业务逻辑,从而达到了修复bug的目的,并且是及时生效的。

Tinker(补丁包是差分包)
由腾讯开发,目前微信就采用了这中热修复方案,优点是可以可以修复类、so包和资源文件。
他的热修复实现原理如下:
由有bug的apk文件和修复bug的apk文件共同生成一个差分包patch,这个patch文件就是我们的补丁包,下载这个差分包和有bug的apk,就可以生成修复bug的apk。如果没有资源文件修复,那么就会生成的就是一个.dex文件,可以通过类加载和反射技术,加载修复bug的类,从而实现热修复。

2.热修复用到了哪些技术?说一说双亲委托机制?说说如何通过类加载和反射实现热修复的?

热修复用到了类加载和反射技术。

双亲委托机制,就是在通过PathClassLoader加载某个类的时候,首先委托给他的父类去加载这个类,当父类没有加载成功时,才由子类去加载这个类。

要实现热修复功能,就需要用到Hook技术。
就是在加载有bug的class之前,先去加载我们修复了bug的class类,即可实现热修复技术。
class类是包含在dex文件中的,我们的app打包成apk的时候,就会生成一个.dex文件。如果我们要进行热修复,就需要生成修复了bug的dex文件,然后利用反射技术,在获取到PathClassLoader的前提下,然后通过反射去获取他的父类BaseDexClassLoader,然后再获取里面的属性(DexPathList)pathList,再获取这个属性中的Element[]数组,这个Element[]数组就是有bug的数组。
数组中的每个Element元素就对应一个dex文件,所以我们只需要再通过反射技术,将修复了bug的dex文件下载下来,然后通过makeElement方法生成修复了bug的Element[]数组。
最终将修复了bug的Element[]数组与有bug的Element[]数组组成一个新的数组,修复了bug的数组元素在前,存在bug的数组元素在后;在类加载加载class的时候,就会先加载到修复了bug的Element数组元素中的class类,从而修复了bug。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值