Android 热修复原理篇及几大方案比较

      热修复说白了就是”即时无感打补丁”,比如你们公司上线一个app,用户反应有重大bug,需要紧急修复。2015年以来,Android开发领域里对热修复技术的讨论和分享越来越多,同时也出现了一些不同的解决方案.如果按照通常做法,那就是程序猿加班搞定bug,然后测试,重新打包并发布。这样带来的问题就是成本高,效率低。于是,热修复就应运而生.一般通过事先设定的接口从网上下载无Bug的代码来替换有Bug的代码。这样就省事多了,用 户体验也好。目前热修复尽管有很多坑,做了好多工作,可能吃力不讨好,各种适配可能还是没修复线上的有些Bug。不过呢,对于一个产品有热修复毕竟是件好事。尤其是对于一个有众多用户的app(,一个bug不只是影响到几个几十个用户,一些创业公司的APP,崩溃或者bug可能直接导致用户卸载和永不使用,所以,就冲它有不用发版也可以解决我们线上的bug,我们的app也要适当考虑加入热修复。


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

Android的类加载器分为两种,PathClassLoader和DexClassLoader,两者都继承自BaseDexClassLoader

PathClassLoader代码位于libcore\dalvik\src\main\Java\dalvik\system\PathClassLoader.java 
DexClassLoader代码位于libcore\dalvik\src\main\java\dalvik\system\DexClassLoader.java 
BaseDexClassLoader代码位于libcore\dalvik\src\main\java\dalvik\system\BaseDexClassLoader.java

  • PathClassLoader
  • 用来加载系统类和应用类

  • DexClassLoader

    用来加载jar、apk、dex文件.加载jar、apk也是最终抽取里面的Dex文件进行加载.

    这里写图片描述

2.热修复机制

热修复就是利用dexElements的顺序来做文章,当一个补丁的patch.dex放到了dexElements的第一位,那么当加载一个bug类时,发现在patch.dex中,则直接加载这个类,原来的bug类可能就被覆盖了


看下PathClassLoader代码

public class PathClassLoader extends BaseDexClassLoader {

    public PathClassLoader(String dexPath, ClassLoader parent) {
        super(dexPath, null, null, parent);
    }

    public PathClassLoader(String dexPath, String libraryPath,
            ClassLoader parent) {
        super(dexPath, null, libraryPath, parent);
    }
} 

DexClassLoader代码

public class DexClassLoader extends BaseDexClassLoader {

    public DexClassLoader(String dexPath, String optimizedDirectory, String libraryPath, ClassLoader parent) {
        super(dexPath, new File(optimizedDirectory), libraryPath, parent);
    }
}

两个ClassLoader就两三行代码,只是调用了父类的构造函数.

public class BaseDexClassLoader extends ClassLoader {
    private final DexPathList pathList;

    public BaseDexClassLoader(String dexPath, File optimizedDirectory,
            String libraryPath, ClassLoader parent) {
        super(parent);
        this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
        Class c = pathList.findClass(name, suppressedExceptions);
        if (c == null) {
            ClassNotFoundException cnfe = new ClassNotFoundException("Didn't find class \"" + name + "\" on path: " + pathList);
            for (Throwable t : suppressedExceptions) {
                cnfe.addSuppressed(t);
            }
            throw cnfe;
        }
        return c;
    }

在BaseDexClassLoader 构造函数中创建一个DexPathList类的实例,这个DexPathList的构造函数会创建一个dexElements 数组

public DexPathList(ClassLoader definingContext, String dexPath, String libraryPath, File optimizedDirectory) {
        ... 
        this.definingContext = definingContext;
        ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>();
        //创建一个数组
        this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory, suppressedExceptions);
        ... 
    }

然后BaseDexClassLoader 重写了findClass方法,调用了pathList.findClass,跳到DexPathList类中.

/* package */final class DexPathList {
    ...
    public Class findClass(String name, List<Throwable> suppressed) {
            //遍历该数组
        for (Element element : dexElements) {
            //初始化DexFile
            DexFile dex = element.dexFile;

            if (dex != null) {
                //调用DexFile类的loadClassBinaryName方法返回Class实例
                Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
                if (clazz != null) {
                    return clazz;
                }
            }
        }       
        return null;
    }
    ...
} 

会遍历这个数组,然后初始化DexFile,如果DexFile不为空那么调用DexFile类的loadClassBinaryName方法返回Class实例. 
归纳上面的话就是:ClassLoader会遍历这个数组,然后加载这个数组中的dex文件. 
而ClassLoader在加载到正确的类之后,就不会再去加载有Bug的那个类了,我们把这个正确的类放在Dex文件中,让这个Dex文件排在dexElements数组前面即可.

CLASS_ISPREVERIFIED问题

根据QQ空间谈到的在虚拟机启动的时候,在verify选项被打开的时候,如果static方法、private方法、构造函数等,其中的直接引用(第一层关系)到的类都在同一个dex文件中,那么该类就会被打上CLASS_ISPREVERIFIED标志,且一旦类被打上CLASS_ISPREVERIFIED标志其他dex就不能再去替换这个类。所以一定要想办法去阻止类被打上CLASS_ISPREVERIFIED标志。

为了阻止类被打上CLASS_ISPREVERIFIED标志,QQ空间开发团队提出了一个方法是先将一个预备好的hack.dex加入到dexElements的第一项,让后面的dex的所有类都引用hack.dex其中的一个类,这样原来的class1.dex、class2.dex、class3.dex中的所有类都引用了hack.dex的类,所以其中的都不会打上CLASS_ISPREVERIFIED标志。

比如Qzon团队的 安卓App热补丁动态修复技术介绍  (这个一定要看!!! 他是热修复元老级文章,也是重点抄袭对象)

动态加载class文件,然后调用反射完成修复的原理:

Java程序在运行的时候,JVM通过类加载机制(ClassLoader)把class文件加载到内存中,只有class文件被载入内存,才能被其他class引用,使程序正确运行起来.

Java中的ClassLoader有三种.

1. Bootstrap ClassLoader 

由C++写的,由JVM启动.

启动类加载器,负责加载java基础类,对应的文件是%JRE_HOME/lib/ 目录下的rt.jar、resources.jar、charsets.jar和class等


2.Extension ClassLoader

Java类,继承自URLClassLoader

扩展类加载器,对应的文件是 %JRE_HOME/lib/ext 目录下的jar和class等


3.App ClassLoader

Java类,继承自URLClassLoader

系统类加载器,对应的文件是应用程序classpath目录下的所有jar和class等

这里要注意一点:只有被同一个类加载器实例加载并且文件名相同的class文件才被认为是同一个class.

下面来一个小例子:

因为系统的ClassLoader只会加载指定目录下的class文件,如果你想加载自己的class文件,那么就可以自定义一个ClassLoader.\

如何自定义ClassLoader

新建一个类继承自java.lang.ClassLoader,重写它的findClass方法。--将class字节码数组转换为Class类的实例---调用loadClass方法即可

我先建一个叫Log的类,很简单,只有一句打印

  1. public class Log {  
  2.  
  3.    public static void main(String[] args) {  
  4.         System.out.println("调用成功");  
  5.    }  
  6. }    

  7. 把这个java文件放到D盘根目录,然后打开cmd,用javac命令把java文件转化为class文件

  1. 然后我新建一个MyClassLoader继承自ClassLoader
  2. public class MyClassLoader extends ClassLoader {  
  3.    @Override  
  4.    protected Class<?> findClass(String name) throws ClassNotFoundException {  
  5.        Class log = null;  
  6.        // 获取该class文件字节码数组  
  7.        byte[] classData = getData();  
  8.  
  9.        if (classData != null) {  
  10.            // 将class的字节码数组转换成Class类的实例  
  11.            log = defineClass(name, classData, 0, classData.length);  
  12.        }  
  13.        return log;  
  14.    }  
  15.  
  16.    private byte[] getData() {  
  17.        //指定路径  
  18.        String path = "D:/Log.class";  
  19.          
  20.        File file = new File(path);  
  21.        FileInputStream in = null;  
  22.        ByteArrayOutputStream out = null;  
  23.        try {  
  24.            in = new FileInputStream(file);  
  25.            out = new ByteArrayOutputStream();  
  26.  
  27.            byte[] buffer = new byte[1024];  
  28.            int size = 0;  
  29.            while ((size = in.read(buffer)) != -1) {  
  30.                out.write(buffer, 0, size);  
  31.            }  
  32.  
  33.        } catch (IOException e) {  
  34.            e.printStackTrace();  
  35.        } finally {  
  36.            try {  
  37.                in.close();  
  38.            } catch (IOException e) {  
  39.  
  40.                e.printStackTrace();  
  41.            }  
  42.        }  
  43.        return out.toByteArray();  
  44.    }  
  45. }  
  46. //最后测试一下,输出加载这个Log的class文件的加载器,并且利用反射调用它的mian方法.
  47. public class Test {  
  48.  
  49.    public static void main(String[] args) throws ClassNotFoundException, InstantiationException, IllegalAccessException, NoSuchMethodException, SecurityException, IllegalArgumentException, InvocationTargetException {  
  50.        MyClassLoader myClassLoader = new MyClassLoader();  
  51.        //查找Log这个class文件  
  52.        myClassLoader.findClass("Log");  
  53.        //加载Log这个class文件  
  54.        Class<?> Log = myClassLoader.loadClass("Log");    
  55.          
  56.        System.out.println("类加载器是:"+Log.getClassLoader());    
  57.          
  58.        //利用反射获取main方法  
  59.        Method method=Log.getDeclaredMethod("main", String[].class) ;    
  60.        Object object=Log.newInstance();  
  61.        String [] arg={"ad"};  
  62.        method.invoke(object, (Object)arg);  
  63.    }  
  64. }  


业界内比较著名的有阿里巴巴的AndFix,HotFix(内测)Dexposed,Qzone的超级补丁和tencent的Tinker(将开源)以及大众点评的Nuwa,腾讯Bugly,RocooFix

Dex的热修复总结

Dex的热修复目前来看基本上有四种方案:

此外,微信的方案是多classloader,这种方式可以解决用multidex方式在部分机型上不生效patch的问题,同时还带来一个好处,这种多classloader的方式使用的是instant run的代码,如果存在native library的修复,也会带来极大的方便。

Native Library热修复总结

而native libraray的修复,目前来说,基本上有两种方案。。

  • 类似multidex的dex方式,插入目录到数组最前面,具体文章见Android热更新之so库的热更新,需要处理系统的兼容性问题,系统分隔线是Android 6.0
  • 第二种方式需要依赖多classloader,在构造BaseDexClassLoader的时候,获取原classloader的native library,通过环境变量分隔符(冒号),将patch的native library与原目录进行连接,patch目录在前,这样同样可以达到修复的目的,缺点是需要依赖dex的热修复,优点是应用native library时不需要处理兼容性问题,当然从patch中释放出来的时候也需要处理兼容性问题。

上述方案从原理上可以简单划分为3类:

原理 方案
Native hook方案 AndFix
QQ空间提出的Classloader替换类的方案 Nuwa, HotFix, RocooFix
Instant Run的冷插拔原理的Dex替换 Tinker
优缺点分析


测试模块 AndFix Classloader方案 Tinker
类替换 no yes yes
资源替换 no no yes
是否需要重启 no yes yes
兼容稳定性 不稳定 最好 稳定



 

下面,我们就分别介绍QQ空间超级热补丁技术和微信Tinker以及阿里百川的HotFix技术。


一、Qzone超级补丁技术

超级补丁技术基于DEX分包方案,使用了多DEX加载的原理,大致的过程就是:把BUG方法修复以后,放到一个单独的DEX里,插入到dexElements数组的最前面,让虚拟机去加载修复完后的方法。

 

当patch.dex中包含Test.class时就会优先加载,在后续的DEX中遇到Test.class的话就会直接返回而不去加载,这样就达到了修复的目的。

 

但是有一个问题是,当两个调用关系的类不在同一个DEX时,就会产生异常报错。我们知道,在APK安装时,虚拟机需要将classes.dex优化成odex文件,然后才会执行。在这个过程中,会进行类的verify操作,如果调用关系的类都在同一个DEX中的话就会被打上`CLASS_ISPREVERIFIED`的标志,然后才会写入odex文件。

 

所以,为了可以正常地进行打补丁修复,必须避免类被打上`CLASS_ISPREVERIFIED`标志,具体的做法就是单独放一个类在另外DEX中,让其他类调用。

 

我们来逆向手机QQ空间APK看一下具体的实现:

 

先进入程序入口`QZoneRealApplication`,在`attachBaseContext`中进行了两步操作:修复`CLASS_ISPREVERIFIED`标志导致的unexpected DEX problem异常、加载修复的DEX。

 

 

 1. 修复Unexpected DEX Problem异常

先看代码,

 

可以看到,这里是要加载一个libs目录下的dalvikhack.jar。在项目的assets/libs找到该文件,解压得到’classes.dex’文件,逆向打开该DEX文件,

 

 

通过不同的DEX加载进来,然后在每一个类的构造方法中引用其他DEX中的唯一类AnitLazyLoad,避免类被打上CLASS_ISPREVERIFIED标志。

 

在无修复的情况下,将DO_VERIFY_CLASSES设置为false,以提高性能。只有在需要修复的时候,才设置为true。

 

 

至于如何加载进来,与下面第二个步骤基本相同。

 

2. 加载修复的DEX

从loadPatchDex()方法进入,经过几次跳转,到达核心的代码段,`SystemClassLoaderInjector.c()`。由于进行了混淆和多次方法的跳转,于是将核心代码段做了如下整理:

 

修复的步骤为:

1. 可以看出是通过获取到当前应用的Classloader,即为BaseDexClassloader

2. 通过反射获取到他的DexPathList属性对象pathList

3. 通过反射调用pathList的dexElements方法把patch.dex转化为Element[]

4. 两个Element[]进行合并,把patch.dex放到最前面去

5. 加载Element[],达到修复目的

 

整体的流程图如下:

 

从流程图来看,可以很明显的找到这种方式的特点:

优势:

  1. 没有合成整包(和微信Tinker比起来),产物比较小,比较灵活
  2. 可以实现类替换,兼容性高。(某些三星手机不起作用)

不足:

1. 不支持即时生效,必须通过重启才能生效。

2. 为了实现修复这个过程,必须在应用中加入两个dex!dalvikhack.dex中只有一个类,对性能影响不大,但是对于patch.dex来说,修复的类到了一定数量,就需要花不少的时间加载。对手淘这种航母级应用来说,启动耗时增加2s以上是不能够接受的事。

3. 在ART模式下,如果类修改了结构,就会出现内存错乱的问题。为了解决这个问题,就必须把所有相关的调用类、父类子类等等全部加载到patch.dex中,导致补丁包异常的大,进一步增加应用启动加载的时候,耗时更加严重。


 

二、微信Tinker

微信针对QQ空间超级补丁技术的不足提出了一个提供DEX差量包,整体替换DEX的方案。主要的原理是与QQ空间超级补丁技术基本相同,区别在于不再将patch.dex增加到elements数组中,而是差量的方式给出patch.dex,然后将patch.dex与应用的classes.dex合并,然后整体替换掉旧的DEX文件,以达到修复的目的。

 

我们来逆向微信的APK看一下具体的实现:

先找到应用入口`TinkerApplication`,在`onBaseContextAttached()`调用了`loadTinker()`,

 

进入TinkerLoader的tryLoad()方法中,

 

从方法名可以预见,在tryLoadPatchFilesInternal()中尝试加载本地的补丁,再经过跳转进入核心修复功能类SystemClassLoaderAdder.class中。

 

代码中可以看出,根据Android版本的不同,分别采取具体的修复操作,不过原理都是一样的。我们以V19为例,

 

从代码中可以看到,通过反射操作得到PathClassLoader的DexPatchList,反射调用patchlist的makeDexElements()方法吧本地的dex文件直接替换到Element[]数组中去,达到修复的目的。

 

对于如何进行patch.dex与classes.dex的合并操作,这里微信开启了一个新的进程,开启新进程的服务TinkerPatchService进行合并。

 

整体的流程如下:

 

从流程图来看,同样可以很明显的找到这种方式的特点:

优势:

  1. 合成整包,不用在构造函数插入代码,防止verify,verify和opt在编译期间就已经完成,不会在运行期间进行。
  2. 性能提高。兼容性和稳定性比较高。
  3. 开发者透明,不需要对包进行额外处理。

不足:

1. 与超级补丁技术一样,不支持即时生效,必须通过重启应用的方式才能生效。

2. 需要给应用开启新的进程才能进行合并,并且很容易因为内存消耗等原因合并失败。

3. 合并时占用额外磁盘空间,对于多DEX的应用来说,如果修改了多个DEX文件,就需要下发多个patch.dex与对应的classes.dex进行合并操作时这种情况会更严重,因此合并过程的失败率也会更高。

 


三、阿里百川HotFix

阿里百川推出的热修复HotFix服务,相对于QQ空间超级补丁技术和微信Tinker来说,定位于紧急BUG修复的场景下,能够最及时的修复BUG,下拉补丁立即生效无需等待。

 

1、AndFix实现原理

AndFix不同于QQ空间超级补丁技术和微信Tinker通过增加或替换整个DEX的方案,提供了一种运行时在Native修改Filed指针的方式,实现方法的替换,达到即时生效无需重启,对应用无性能消耗的目的。

原理图如下:

 

2、AndFix实现过程

 

对于实现方法的替换,需要在Native层操作,经过三个步骤:

 

接下来以Dalvik设备为例,来分析具体的实现过程:

 

1、setup()

 

 

对于Dalvik来说,遵循JIT即时编译机制,需要在运行时装载libdvm.so动态库,获取以下内部函数:

1) dvmThreadSelf( ):查询当前的线程;

2)dvmDecodeIndirectRef( ):根据当前线程获得ClassObject对象。

 

2、setFieldFlag

 

该操作的目的:把 private、protected的方法和字段都改为public,这样才可被动态库看见并识别,因为动态库会忽略非public属性的字段和方法。

 

3、replaceMethod

 

 

该步骤是方法替换的核心,替换的流程如下:

 

 

AndFix对ART设备同样支持,具体的过程与Dalvik相似,这里不再赘述。

 

从技术原理,不难看出阿里百川HotFix的几个特点:

 

优势:

  1. BUG修复的即时性
  2. 补丁包同样采用差量技术,生成的PATCH体积小
  3. 对应用无侵入,几乎无性能损耗

不足:

  1. 不支持新增字段,以及修改<init>方法,也不支持对资源的替换。
  2. 由于厂商的自定义ROM,对少数机型暂不支持。


综合分析如下:

 


  




热修复技术的坑与解

——————————————————————————————————————————————————————————————————————

我们可以看到,QQ空间超级补丁技术和微信Tinker的修复原理都基于类加载,在功能上已经支持类、资源的替换和新增,功能非常强大。既然已经有了这么强大的热修复技术,为什么阿里百川还要推出自己的热修复方案HotFix呢?

 

一、多DEX带来的性能影响

我们知道,多DEX方案原来是用于解决应用方法数65k的问题,现在google也官方支持了MultiDex的实现方案。超级补丁技术和Tinker却作为一种热修复的方案,平生给应用增加了多个DEX,而多DEX技术最大的问题在于性能上的坑,因此基于这种方案的补丁技术影响应用的性能是无疑的。

1. 启动加载时间过长

我们可以看到,超级补丁技术和Tinker都选择在Application的attachBaseContext()进行补丁dex的加载,即时这是加载dex的最佳时机,但是依然会带来很大的性能问题,首当其冲的就是启动时间太长。

对于补丁DEX来说,应用启动时虚拟机会进行dexopt操作,将patch.dex文件转换成odex文件,这个过程本身非常耗时。而这个过程又要求在主线程中,以同步的方式执行,否则无法成功进行修复。就DEX的加载时间,大概做了以下的时间测试。

 

通过上表可以看到,随着patch.dex的尺寸增加,在不做任何优化的情况下,启动时间也直线增长。对于一个应用来说,这简直是灾难性的。

 

2. 易造成应用的ANR和Crash

由于多DEX加载导致了启动时间变长,这样更容易引发应用的ANR。我们知道当应用在主线程等待超过5s以后,就会直接导致长时间无响应而退出。超级补丁技术为保证ART不出现地址错乱问题,需要将所有关联的类全部加入到补丁中,而微信Tinker采取一种差量包合并加载的方式,都会使要加载的DEX体积变得很大。这也很大程度上容易导致ANR情况的出现。

 

除了应用ANR以外,多DEX模式也同样很容易导致Crash情况的出现。在ART设备中为了保证不出现地址错乱,需要把修改类的所有相关类全部加入到补丁中,这里会出现一个问题,为了保证补丁包的体积最小,能否保证引入全部的关联类而不引入无关的类呢?一旦没有引入关联的类,就会出现以下的异常:

  • NoClassDefFoundError
  • Could Not Find Class
  • Could Not Find Method

出现这些异常,就会直接导致应用的Crash退出。

 

所以,不难看出如果我们需要修复一个不是Crash的BUG,但是因为未加入相关类而导致了更严重的Crash,就更加的得不偿失。

 

总的来说,热修复本质的目的是为了保证应用更加稳定,而不是为了更强大的功能引入更大的风险和不稳定性。

 

二、 热修复 or 插件化?

 

我们经常提到热修复和插件化,这都是当下热门的新兴技术。在讲述之前,需要对这两个概念进行一下解释。

 

  • 热修复:当线上应用出现紧急BUG,为了避免重新发版,并且保证修复的及时性而进行的一项在线推送补丁的修复方案。
  • 插件化:一个程序划分为不同的部分,以插件的形式加载到应用中去,本质上它使用的技术还是热修复技术,只是加入了更多工程实践,让它支持大规模的代码更新以及资源和SO包的更新。

 

显然,从概念上我们可以看到,插件化使用场景更多是功能上的,热修复强调微小的修复。从这个层面来说,插件化必然功能更加强大,能做的事情也更多。QQ空间超级补丁技术和微信Tinker从类、资源的替换和更新上来看,与其说是热修复,不如说是插件化技术的实践。

QQ空间超级补丁技术和微信Tinker提供了更加强大的功能,但是对应用的性能和稳定有较大的影响,就BUG修复的这个使用场景上还不够明确,并且显得过重。

 

针对应用的性能损耗,我们可以举例做一个对比:

某APP的启动载入时间为3s左右,本身就是基于多DEX模式的实现。

分别接入三种热修复服务,根据腾讯提供超级补丁技术和Tinker的数据,那么会变成以下的场景:

1. 阿里百川HotFix:启动时间几乎无增加,不增加运行期额外的磁盘消耗。

2. QQ空间超级补丁技术:如果应用有700个类,启动耗时增加超过2.5s,达到5.5s以上。

3. 微信Tinker:假设应用有5个DEX文件,分别修改了这5个DEX,产生5个patch.dex文件,就要进行5次的patch合并动作,假设每个补丁1M,那么就要多占用7.5M的磁盘空间。

显然对于修复紧急BUG这个场景,阿里百川HotFix的更为合适,它更加轻量,可以在不重启的情况下生效,且对性能几乎没有影响。


找了很多资料加上看各种文章源码写完这个文章,很多地方的了解不是那么深入,很多东西也是拾人牙慧,希望大家批评指正。

参考自:


发布了78 篇原创文章 · 获赞 245 · 访问量 73万+
展开阅读全文

没有更多推荐了,返回首页

©️2019 CSDN 皮肤主题: 编程工作室 设计师: CSDN官方博客

分享到微信朋友圈

×

扫一扫,手机浏览