android热修复方案

热补丁方案有很多,其中比较出名的有腾讯Tinker、阿里的AndFix、美团的Robust以及QZone的超级补丁方案。他们的优劣如下:

一、Tinker 热修复

Tinker通过 Dexdiff 算法将原apk和修复后的apk中的dex文件进行对比,生成差分包,运行时将差分包中的dex和原包中的dex进行合并,从而加载差分包中修复好的类。因为是运行时加载的dex文件,所以修复完成后不能即时生效,需要重启app。

二、Qzone热修复
QQ空间的热修复原理和tinker有异曲同工之处,它基于dex分包方案,把bug类修复完成之后,单独生成一个dex文件,运行期间加载dex补丁,运行的是修复后的类。在Android中所有我们运行期间需要的类都是由ClassLoader(类加载器)进行加载,因此让ClassLoader加载全新的类替换掉出现Bug的类即可完成热修复。所以也需要重启才能生效。

三、AndFix热修复

在native动态替换java层的方法,通过native层hook java层的代码。执行方法时,会直接将修复后的方法再native层进行替换,达到修复的效果,这种方式修复后直接会生效,不需要重启。

四、Robust美团热修复方案

方法运行时会在方法内插入一段代码,如果有修复内容,会将执行的代码重定向到其他方法中。

参考了Instans Run的原理。这种方案也是不需要重启的

五、我们基于QQ空间的热修复方案进行研究

 1. ART与Dalvik

 什么是Dalvik:

​ Dalvik是Google公司自己设计用于Android平台的Java虚拟机。支持已转换为.dex(Dalvik Executable)格式的Java应用程序的运行,.dex格式是专为Dalvik应用设计的一种压缩格式,适合内存和处理器速度有限的系统。

什么是ART:

Android Runtime, Android 4.4 中引入的一个开发者选项,也是 Android 5.0 及更高版本的默认模式。在应用安装的时候Ahead-Of-Time(AOT)预编译字节码到机器语言,这一机制叫Ahead-Of-Time(AOT)预编译。应用程序安装会变慢,但是执行将更有效率,启动更快。

在Dalvik下,应用运行需要解释执行,常用热点代码通过即时编译器(JIT)将字节码转换为机器码,运行效率低。而在ART 环境中,应用在安装时,字节码预编译(AOT)成机器码,安装慢了,但运行效率会提高。
ART占用空间比Dalvik大(字节码变为机器码), “空间换时间"。
预编译也可以明显改善电池续航,因为应用程序每次运行时不用重复编译了,从而减少了 CPU 的使用频率,降低了能耗。
Dexopt与DexAot

这两个操作是Art架构安装时的操作, ART会执行AOT,但针对Dalvik 开发的应用也能在 ART 环境中运作。

dexopt:对dex文件进行验证和优化,优化后的格式为odex(Optimized dex) 文件
dexAot:在安装时对 dex 文件执行dexopt优化之后,再将odex进行 AOT 提前编译操作,编译为OAT可执行文件(机器码)

 2. ClassLoader

 Java 类加载器

BootClassLoader , 用于加载Android Framework层class文件。

PathClassLoader ,用于Android应用程序类加载器。可以加载指定的dex,以及jar、zip、apk中的classes.dex

DexClassLoader,加载指定的,以及jar、zip、apk 中的classes.dex。

我们可以在activity中打印来进行验证:

	@Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        /*
         * 测试classLoader的一些使用情况
         */
        // 我们外部的类都是用的PathClassLoader
        ClassLoader classLoader1 = this.getClassLoader();
        LogUtils.i("loader1 === " + classLoader1);

        // 父加载器就是BootClassLoader,所以这个类先从framework中去查找,找不到就从我们本地中查找
        LogUtils.i("loader1 parent === " + classLoader1.getParent());

        // framework层的类加载都是用的BootClassLoader
        ClassLoader classLoader2 = Activity.class.getClassLoader();
        LogUtils.i("loader  === " + classLoader2);
    }

打印结果:
  

3. 源码跟踪

在虚拟机中,加载一个类时,使用的时ClassLoader中的loadClass方法进行加载的,看一下源码:

protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
            // First, check if the class has already been loaded
            // 一个类加载后会加入到缓存中,以后加载时从缓存中读取就可以了
            // 如果找不到就从父亲classLoader中查找
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                try {
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }

                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    c = findClass(name);
                }
            }
            return c;
    }

代码中可以看到,虚拟机加载一个类时,会从父ClassLoader中查找这个类,父ClassLoade找不到会递归到父亲的父亲,如果祖辈都找不到时,才会使用当前的ClassLoader进行查找。这就是传说中的双亲委托机制。为什么这样做呢?

1、避免重复加载,当父加载器已经加载了该类的时候,就没有必要子ClassLoader再加载一次。

2、安全性考虑,防止核心API库被随意篡改。

显而易见,这样不管是父加载器还是自己,都会走到findClass()方法:

 @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;
    }

 ClassLoader中findCliss方法只抛出了一个异常?那肯定是它的子类重写并实现它了~, 在android中,ClassLoader都是继承了BaseDexClassLoader(可以看PathClassLoader和DexClassLoader, 往上有些人说PathClassLoader可以加载内部类,DexClassLoader才可以加载外部存储卡的文件,其实这两者都可以加载, 没有任何区别),以上代码就是在BaseDexClassLoader中实现了类的查找。里面是通过pathList来进行查找的。继续看pathList(DexPathList.java)中的实现: 

public Class findClass(String name, List<Throwable> suppressed) {
        for (Element element : dexElements) {
            DexFile dex = element.dexFile;

            if (dex != null) {
                Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
                if (clazz != null) {
                    return clazz;
                }
            }
        }
        if (dexElementsSuppressedExceptions != null) {
            suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
        }
        return null;
    }

 可以看到,dexElements是一个数组, 这里遍历了dexElements,DexFile对象可以看作是dex文件, 如果找到了类直接返回了,这里验证了我们上面所说的。QQ空间热修复就是将修复包patch.dex加入到dexElements开始的位置,当虚拟机加载类时,会先从patch.dex中查找,找到了直接返回,找不到还使用原来的,这样就达到了热修复的效果。

dexElements是一个Element类型的数组,源码中这个Element是私有的,如何创建新的Element并加入到dexElements中呢? 先来看看源码中的dexElements是怎么创建的:

// save dexPath for BaseDexClassLoader
this.dexElements = makePathElements(splitDexPath(dexPath), optimizedDirectory,
                                            suppressedExceptions);

 在pathList的构造方法中可以看到,通过makePathElements可以创建一个element数组,所以我们通过反射来调用makePathElements方法创建一个新的数组,再获取到原数组,将两个数组合并到dexElements就可以了。

拿SDK23举例:

首先通过classloader找到pathList对象,

再执行pathList中的makePathElements方法创建补丁包的Element数组

反射拿到原来的dexElements数组

将两个数组进行合并,放到一个新的数组中

再反射修改dexElements,将新数组覆盖调原来的数组,完成热修复。

代码如下:

public static void install(ClassLoader classLoader,
                                   File patch) {


            List<File> patchs = new ArrayList<>();
            patchs.add(patch);


            // 查找pathList字段
            Field pathListField = ReflectUtils.findField(classLoader, "pathList");

            // 1. 获取pathList对象
            try {
                Object pathList = pathListField.get(classLoader);

                if (pathList == null) {
                    throw new RuntimeException("pathList对象为空");
                }

                Method method = ReflectUtils.findMethod(pathList, "makePathElements", List.class, File.class, List.class);


                ArrayList<IOException> suppressedExceptions = new ArrayList<>();

                // 2. 补丁包的elements数组
                Object[] patchElements = (Object[]) method.invoke(null, patchs, null, suppressedExceptions);

                Field dexElementsField = ReflectUtils.findField(pathList, "dexElements");
                // 3. 原来的dex数组
                Object[] oldElements = (Object[]) dexElementsField.get(pathList);

                // 进行合并
                // 4. 首先利用反射创建一个盛放两个数组的新数组
                Object[] newElements = (Object[]) Array.newInstance(oldElements.getClass().getComponentType(),
                        oldElements.length + patchElements.length);

                // 5. 将两个数组放到新数组中,补丁包的要放在前面
                System.arraycopy(patchElements, 0, newElements, 0, patchElements.length);
                System.arraycopy(oldElements, 0, newElements, patchElements.length, oldElements.length);

                // 6. 将原来的dexElement数组用新数组替换掉
                dexElementsField.set(pathList, newElements);

            } catch (IllegalAccessException e) {
                e.printStackTrace();
            } catch (InvocationTargetException e) {
                e.printStackTrace();
                LogUtils.i("error == " + e.getTargetException().getMessage());
            }


        }

sdk23 , 19 , 14, 4 这些版本创建dexElement数组的方式不一样,或许是方法名不同,或许是参数不同,需要对这几个版本单独做适配,这里只列举了sdk23的反射方法,其他版本原理相同。同时,这一部分内容可参考Tinker热修复方案来进行适配:tinker方案 

猜你喜欢

转载自blog.csdn.net/fengyeNom1/article/details/105552146