热修复原理初窥(一)

热修复已经出来很久了,正好公司的项目最近接入的腾讯的 Tinker 热修复,所以研究了一下热修复的原理,现在将其梳理成一系列文章,总共大概会有5~6篇文章,现在带来第一篇文章,简单介绍一下热修复的原理:



1. 热修复

1.1 背景介绍

在 App 发版之后,如果发现线上有严重 Bug,需要紧急修复,按照传统的做法就是:修复 Bug、重新打包 App、测试、向各个应用市场和渠道换包、提示用户升级、用户下载、覆盖安装,有时候可能只是为了修改一两行代码,却需要如此复杂的一个过程,效率低且周期长,非常影响用户的体验。

针对这样的问题,目前已经有了相对完善成熟的解决办法 —- 热修复。热修复就是通过动态下载补丁的方式,修复线上紧急 Bug,不需要用户下载新版 App 重新安装。

2015 年 QQ 空间开发团队首先提出了热补丁动态修复技术的思想:安卓App热补丁动态修复技术介绍。之后也出现了其他热修复框架,现在比较完善的有:

1.2 dex 分包方案

当一个 Android 应用非常复杂庞大的时候,会引入很多的依赖包,同时本身应用的代码也很多,这个时候就会出现两个问题:

  • 编译失败,会报 com.android.dex.DexIndexOverflowException:method ID not in[0,0xffff]:65536 这样的错误
  • 编译器正常完成编译工作,但是在低版本系统的手机上无法安装,会提示 Optimization failed 异常信息

这两个问题都和一个叫做 DexOpt 应用程序有关。DexOpt 会在 Android 系统第一次安装应用的时候,对 Dex 文件做优化生成一个 ODex(Optimised Dex) 文件,执行 ODex 文件的效率会比直接执行 Dex 文件的效率高很多。

  • Dalvik 中调用各类方法的指令 invoke-kind {vC, vD, vE, vF, vG}, meth@BBBB 中,方法引用索引数是 16 位的,也就是最多调用 2^16 = 65536 个方法
  • 在 DexOpt 优化 Dex 的过程中,DexOpt 会采用一个固定大小的缓冲区来存储应用中所有方法的信息,这个缓冲区就是 LinearAlloc。LinearAlloc 在新版本的 Android 系统中其大小是 8MB 或者 16MB,但是在 Android 2.2 或 Android 2.3 中却只有 5MB,当待安装的 apk 中的方法比较多的时候,尽管它还没有达到 65536 的限制,但是它的存储空间仍然有可能超过 5MB,这种情况就会报 Optimization failed 的异常

针对上面的两种问题,Google 在 2014 年提出了 multidex 的解决方案,通过 multidex 可以很好地解决方法数越界的问题。

假设在 Apk 中有两个 dex 文件,主 dex 文件从 dex 文件。在 Applicaion 实例化之后,在 onCreate() 方法中将 从 dex 文件 通过反射注入到当前的 Classloader
中。

2. 热修复原理

2.1 类加载器

  • PathClassLoader && DexClassLoader

    Android 采用的也是 Java 语言,和 Java 应用不同的是,Java 应用是基于 JVM 的,而 Android 应用是基于 Dalvik/ART VM 的。不论是哪种虚拟机都是用 ClassLoader 加载类的,JVM 加载的是 .class 文件,而 Dalvik/ART VM 加载的是 .dex 文件。

    在 Android 中,ClassLoader 分为两种:PathClassLoader 和 DexClassLoader,两者都是继承自 BaseDexClassLoader 的,关系图如下所示:



    看下两个类的源码及注释:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    public class PathClassLoader extends BaseDexClassLoader {
    /**
    * Creates a {@code PathClassLoader} that operates on a given list of files
    * and directories. This method is equivalent to calling
    * {@link #PathClassLoader(String, String, ClassLoader)} with a
    * {@code null} value for the second argument (see description there).
    *
    * @param dexPath the list of jar/apk files containing classes and
    * resources, delimited by {@code File.pathSeparator}, which
    * defaults to {@code ":"} on Android
    * @param parent the parent class loader
    */
    public PathClassLoader(String dexPath, ClassLoader parent) {
    super(dexPath, null, null, parent);
    }
    /**
    * Creates a {@code PathClassLoader} that operates on two given
    * lists of files and directories. The entries of the first list
    * should be one of the following:
    *
    * <ul>
    * <li>JAR/ZIP/APK files, possibly containing a "classes.dex" file as
    * well as arbitrary resources.
    * <li>Raw ".dex" files (not inside a zip file).
    * </ul>
    *
    * The entries of the second list should be directories containing
    * native library files.
    *
    * @param dexPath the list of jar/apk files containing classes and
    * resources, delimited by {@code File.pathSeparator}, which
    * defaults to {@code ":"} on Android
    * @param libraryPath the list of directories containing native
    * libraries, delimited by {@code File.pathSeparator}; may be
    * {@code null}
    * @param parent the parent class loader
    */
    public PathClassLoader(String dexPath, String libraryPath,
    ClassLoader parent) {
    super(dexPath, null, libraryPath, parent);
    }
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    public class DexClassLoader extends BaseDexClassLoader {
    /**
    * Creates a {@code DexClassLoader} that finds interpreted and native
    * code. Interpreted classes are found in a set of DEX files contained
    * in Jar or APK files.
    *
    * <p>The path lists are separated using the character specified by the
    * {@code path.separator} system property, which defaults to {@code :}.
    *
    * @param dexPath the list of jar/apk files containing classes and
    * resources, delimited by {@code File.pathSeparator}, which
    * defaults to {@code ":"} on Android
    * @param optimizedDirectory directory where optimized dex files
    * should be written; must not be {@code null}
    * @param libraryPath the list of directories containing native
    * libraries, delimited by {@code File.pathSeparator}; may be
    * {@code null}
    * @param parent the parent class loader
    */
    public DexClassLoader(String dexPath, String optimizedDirectory,
    String libraryPath, ClassLoader parent) {
    super(dexPath, new File(optimizedDirectory), libraryPath, parent);
    }
    }

    从注释中可以了解到,PathClassLoader 和 DexClassLoader 的区别主要有:

    • PathClassLoader:只能加载已经安装到 Android 系统中的 apk 文件(/data/app目录),是 Android 默认使用的类加载器。
    • DexClassLoader:可以加载任意目录下的 dex/jar/apk/zip 文件,比 PathClassLoader 更灵活,是实现热修复的重点。

    从代码中可见,PathClassLoader 和 DexClassLoader 都是继承自 BaseDexClassLoader,并且在它们的构造方法中都调用的父类的构造方法。不同的是,在 DexClassLoader 中调用父类构造方法时传入了optimizedDirectory 参数。

  • BaseDexClassLoader

    在 PathClassLoader 和 DexClassLoader 中都调用了父类(即:BaseDexClassLoader) 的构造方法,BaseDexClassLoader 的构造方法如下所示:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    public class BaseDexClassLoader extends ClassLoader {
    private final DexPathList pathList;
    /**
    * Constructs an instance.
    *
    * @param dexPath 要加载的程序文件(一般是dex文件,也可以是jar/apk/zip文件)所在目录
    * @param optimizedDirectory dex文件的输出目录(因为在加载jar/apk/zip等压缩格式的程序文件时会解压出其中的dex文件,该目录就是专门用于存放这些被解压出来的dex文件的)
    * @param 加载程序文件时需要用到的库路径。
    * @param 父加载器
    */
    public BaseDexClassLoader(String dexPath, File optimizedDirectory,
    String libraryPath, ClassLoader parent) {
    super(parent);
    this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
    }
    ......
    }

在 BaseDexClassLoader 中有一个非常重要的方法 findClass(String name),此方法供外部类调用以找到所加载的 class, 代码如下所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class BaseDexClassLoader extends ClassLoader {
private final DexPathList pathList;
......
@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;
}

分析 findClass(String name) 可以知道,在 findClass(String name) 中主要通过调用 DexPathList.findClass(String name, List suppressed) 方法查找 Class 对象,那接下来分析一下 DexPathList 类的源码

  • DexPathList

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    final class DexPathList {
    private static final String DEX_SUFFIX = ".dex";
    private final ClassLoader definingContext;
    private final Element[] dexElements;
    /**
    *
    * 在构造方法中,保存当前类的类加载器,并且通过调用 makeDexElements(ArrayList<File> files, File optimizedDirectory,ArrayList<IOException> suppressedExceptions)
    * 生成 Element[] 数组
    **/
    public DexPathList(ClassLoader definingContext, String dexPath,
    String libraryPath, File optimizedDirectory) {
    ......
    this.definingContext = definingContext;
    this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory, suppressedExceptions);
    ......
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    /**
    * Makes an array of dex/resource path elements, one per element of
    * the given array.
    */
    private static Element[] makeDexElements(ArrayList<File> files, File optimizedDirectory,
    ArrayList<IOException> suppressedExceptions) {
    ArrayList<Element> elements = new ArrayList<Element>();
    // 打开所有的文件并加载 dex 文件
    for (File file : files) {
    File zip = null;
    DexFile dex = null;
    String name = file.getName();
    ......
    if (name.endsWith(DEX_SUFFIX)) {
    // 如果是以 .dex 结尾的文件则加载为一个 DexFile 对象
    try {
    dex = loadDexFile(file, optimizedDirectory);
    } catch (IOException ex) {
    System.logE("Unable to load dex file: " + file, ex);
    }
    } else {
    // 如果是apk、jar、zip文件,则将文件赋予 File 对象,并尝试去加载它
    zip = file;
    try {
    dex = loadDexFile(file, optimizedDirectory);
    } catch (IOException suppressed) {
    suppressedExceptions.add(suppressed);
    }
    }
    ......
    // 如果 zip 或者 dex 不为空,则用该对象生成一个 Element 对象并加入到 elements 数组中
    if ((zip != null) || (dex != null)) {
    elements.add(new Element(file, false, zip, dex));
    }
    }
    return elements.toArray(new Element[elements.size()]);
    }
    public Class findClass(String name, List<Throwable> suppressed) {
    // 遍历每一个 dexElements 中的每一个 Element 对象
    for (Element element : dexElements) {
    DexFile dex = element.dexFile;
    if (dex != null) {
    // 在 dex 文件中,通过 name 查找该类名对应的 Class 对象并返回
    Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
    if (clazz != null) {
    return clazz;
    }
    }
    }
    if (dexElementsSuppressedExceptions != null) {
    suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
    }
    return null;
    }

2.2 热修复原理

可以注意到,在 DexPathList 中的 findClass(String name, List<Throwable> suppressed) 中,是通过遍历 dexElements 数组,调用 DexFile.loadClassBinaryName(String name,ClassLoader loader,List<Throwable> suppressed) 方法寻找某个 Class 对象,如果找到则直接返回,遍历结束。

那么,试想一下,如果在 dexElements 数组中的两个 DexFile 对象中存在两个相同的 Class 实例,首先会从第一个 DexFile 文件中找到第一个 Class 实例之后就会将其返回,遍历结束,而第二个 DexFile 中相同的另一个 Class 实例则永远不会被找到了,如下图所示:



图片来源:安卓App热补丁动态修复技术介绍

热修复技术的原理也是基于此特性的。假如线上包出现 bug,可以将出现 bug 的那个类修复,并重新编译打包成 dex 补丁文件,并将该 dex 补丁文件插入到 dexElements 数组的前面,那么 ClassLoader 加载到虚拟机中的就是修复 bug 之后的类,有 bug 的类则永远不会被加载进来了,如下图所示:



图片来源:安卓App热补丁动态修复技术介绍


参考资料:

Android热补丁动态修复技术(一):从Dex分包原理到热补丁Altsuki的博客

Android分包MultiDex原理详解

配置方法数超过 64K 的应用

安卓App热补丁动态修复技术介绍

Android 开发艺术探索