Android 热修复,没你想的那么难



对本文有任何问题,可加我的个人微信询问:kymjs666

《插件化从放弃到捡起》第一章,首先看一张图:
Android, kotlin, Android插件化
这张图是我所理解的 Android 插件化技术的三个技术点以及它们的应用场景。今天以 【Qzone 热修复方案为例】,跟大家讲一讲插件化中 热修复方案 的实现。

原理

ClassLoader

在 Java 中,要加载一个类需要用到ClassLoader
Android 中有三个 ClassLoader, 分别为URLClassLoaderPathClassLoaderDexClassLoader。其中

  • URLClassLoader 只能用于加载jar文件,但是由于 dalvik 不能直接识别jar,所以在 Android 中无法使用这个加载器。
  • PathClassLoader 它只能加载已经安装的apk。因为 PathClassLoader 只会去读取 /data/dalvik-cache 目录下的 dex 文件。例如我们安装一个包名为com.hujiang.xxx的 apk,那么当 apk 安装过程中,就会在/data/dalvik-cache目录下生产一个名为data@app@com.hujiang.xxx-1.apk@classes.dex的 ODEX 文件。在使用 PathClassLoader 加载 apk 时,它就会去这个文件夹中找相应的 ODEX 文件,如果 apk 没有安装,自然会报ClassNotFoundException
  • DexClassLoader 是最理想的加载器。它的构造函数包含四个参数,分别为:
    1. dexPath,指目标类所在的APK或jar文件的路径.类装载器将从该路径中寻找指定的目标类,该类必须是APK或jar的全路径.如果要包含多个路径,路径之间必须使用特定的分割符分隔,特定的分割符可以使用System.getProperty(“path.separtor”)获得.
    2. dexOutputDir,由于dex文件被包含在APK或者Jar文件中,因此在装载目标类之前需要先从APK或Jar文件中解压出dex文件,该参数就是制定解压出的dex 文件存放的路径.在Android系统中,一个应用程序一般对应一个Linux用户id,应用程序仅对属于自己的数据目录路径有写的权限,因此,该参数可以使用该程序的数据路径.
    3. libPath,指目标类中所使用的C/C++库存放的路径
    4. classload,是指该装载器的父装载器,一般为当前执行类的装载器

framework源码中的dalvik.system包下,找到DexClassLoader源码,并没有什么卵用,实际内容是在它的父类BaseDexClassLoader中,顺带一提,这个类最低在API14开始有用。包含了两个变量:

/** originally specified path (just used for {@code toString()}) */
private final String originalPath;

/** structured lists of path elements */
private final DexPathList pathList;

可以看到注释:pathList就是多dex的结构列表,查看其源码

/*package*/ final class DexPathList {
    private static final String DEX_SUFFIX = ".dex";
    private static final String JAR_SUFFIX = ".jar";
    private static final String ZIP_SUFFIX = ".zip";
    private static final String APK_SUFFIX = ".apk";

    /** class definition context */
    private final ClassLoader definingContext;

    /** list of dex/resource (class path) elements */
    private final Element[] dexElements;

    /** list of native library directory elements */
    private final File[] nativeLibraryDirectories;

可以看到 dexElements 注释,dexElements 就是一个dex列表,那么我们就可以把每个 Element 当成是一个 dex。

此时我们整理一下思路,DexClassLoader 包含有一个dex数组Element[] dexElements,其中每个dex文件是一个Element,当需要加载类的时候会遍历 dexElements,如果找到类则加载,如果找不到从下一个 dex 文件继续查找。

那么我们的实现就是把这个插件 dex 插入到 Elements 的最前面,这么做的好处是不仅可以动态的加载一个类,并且由于 DexClassLoader 会优先加载靠前的类,所以我们同时实现了宿主 apk 的热修复功能。

ODEX过程

上文就是整个热修复的原理了,就是向Classloader列表中插入一个dex。但是如果你这儿实现了,会发现一个问题,就是 ODEX 过程中引发的问题。
在讲这个蛋疼的过程之前,有几个问题是要搞懂的。
为什么 Android 不能识别 .class 文件,而只能识别 dex 文件。
因为 dex 是对 class 的优化,它对 class 做了极大的压缩,比如以下是一个 class 文件的结构(摘自邓凡平老师博客)

Android, kotlin, class文件结构

dex 将整个 Android 工程中所有的 class 压缩到一个(或几个) dex 文件中,合并了每个 class 的常量、class 版本信息等,例如每个 class 中都有一个相同的字符串,在 dex 中就只存一份就够了。所以,在Android 上,dalvik 虚拟机是无法识别一个普通 class 文件的,因为无法识别这个 class 文件的结构。
以下是一个 dex 文件的结构

Android, kotlin, dex文件结构

感兴趣的可以阅读《深入理解Android》这本书。

继续往下,其实 dalvik 虚拟机也并不是直接读取 dex 文件的,而是在一个 APK 安装的时候,会首先做一次优化,会生成一个 ODEX 文件,即 Optimized dex。 为什么还要优化,依旧是为了效率。
只不过,Class -> dex 是为了平台无关的优化;
而 dex -> odex 则是针对不同平台,不同手机的硬件配置做针对性的优化。
就是在这一过程中,虚拟机在启动优化的时候,会有一个选项就是 verify 选项,当 verify 选项被打开的时候,就会执行一次校验,校验的目的是为了判断,这个类是否有引用其他 dex 中的类,如果没有,那么这个类会被打上一个 CLASS_ISPREVERIFIED 的标志。一旦被打上这个标志,就无法再从其他 dex 中替换这个类了。而这个选项开启,则是由虚拟机控制的。

字节码操作

那么既然知道了原因,解决的办法自然也有了。你不是没有引用其他 dex 中的类就会被标记吗,那咱们就引用一个其他 dex 中的类。

ClassReader:该类用来解析编译过的class字节码文件。
ClassWriter:该类用来重新构建编译后的类,比如说修改类名、属性以及方法,甚至可以生成新的类的字节码文件。
ClassAdapter:该类也实现了ClassVisitor接口,它将对它的方法调用委托给另一个ClassVisitor对象。

/**
 * 当对象初始化的时候注入Inject类
 *
 * @Note https://www.ibm.com/developerworks/cn/java/j-lo-asm30/
 * @param inputStream 需要注入的Class的文件输入流
 * @return 返回注入以后的Class文件二进制数组
 */
private static byte[] referHackWhenInit(InputStream inputStream) {
    //该类用来解析编译过的class字节码文件。
    ClassReader cr = new ClassReader(inputStream);
    //该类用来重新构建编译后的类,比如说修改类名、属性以及方法,甚至可以生成新的类的字节码文件
    ClassWriter cw = new ClassWriter(cr, 0);
    //类的访问者,可以用来创建对一个Class的改动操作
    ClassVisitor cv = new ClassVisitor(Opcodes.ASM4, cw) {
        @Override
        public MethodVisitor visitMethod(int access, String name, String desc,
                                         String signature, String[] exceptions) {
            MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions);
            //如果方法名是<init>,每个类的构造函数函数名叫<init>
            if ("<init>".equals(name)) {
                //在原本的visitMethod操作中添加自己定义的操作
                mv = new MethodVisitor(Opcodes.ASM4, mv) {
                    @Override
                    void visitInsn(int opcode) {
                        //Opcodes可以看做为关键字
                        if (opcode == Opcodes.RETURN) {
                            //visitLdcInsn() 将一个值写入到栈中,可以是一个Class类名/method方法名/desc方法描述
                            //这里相当于插入了一条语句:Class a = Inject.class;
                            super.visitLdcInsn(Type.getType("Lcom/hujiang/hotfix/Inject;"));
                        }
                        //执行opcode对应的其他操作
                        super.visitInsn(opcode);
                    }
                }
            }
            //责任链完成,返回
            return mv;
        }
    };
    //accept这个方法接受一个实现了 ClassVisitor接口的对象实例作为参数,然后依次调用 ClassVisitor接口的各个方法
    //用户无法控制各个方法调用顺序,但是可以提供不同的 Visitor(访问者) 来对字节码树进行不同的修改
    //在这里,调用这一步的目的是为了让上面的visitMethod方法被调用
    cr.accept(cv, 0);
    return cw.toByteArray();
}

代码实现

可以参考 nuwa 中的实现,首先是 dex 怎样去插入到Classloader列表中,其实就是一段反射:

public static void injectDexAtFirst(String dexPath, String defaultDexOptPath) throws NoSuchFieldException, IllegalAccessException, ClassNotFoundException {
    DexClassLoader dexClassLoader = new DexClassLoader(dexPath, defaultDexOptPath, dexPath, getPathClassLoader());
    Object baseDexElements = getDexElements(getPathList(getPathClassLoader()));
    Object newDexElements = getDexElements(getPathList(dexClassLoader));
    Object allDexElements = combineArray(newDexElements, baseDexElements);
    Object pathList = getPathList(getPathClassLoader());
    ReflectionUtils.setField(pathList, pathList.getClass(), "dexElements", allDexElements);
}

首先分别获取到宿主应用和补丁的 dex 中的PathList.dexElements, 并把两个 dexElements 数组做拼接,将补丁数组放在前面,最后将拼接后生成的数组再赋值回Classloader

nuwa 更主要的是他的 groovy 脚本,完整代码:这里,由于代码很多,就只跟大家讲两个关键的点的实现以及目的,具体的内容可以直接查看源码。

//获得所有输入文件,即preDex的所有jar文件
Set<File> inputFiles = preDexTask.inputs.files.files
inputFiles.each { inputFile ->
    def path = inputFile.absolutePath
    //如果不是support包或者引入的依赖库,则开始生成代码修改部分的hotfix包
    if (HotFixProcessors.shouldProcessPreDexJar(path)) {
        HotFixProcessors.processJar(classHashFile, inputFile, patchDir, classHashMap, includePackage, excludeClass)
    }
}

其中HotFixProcessors.processJar()是脚本的第一个作用,就是找出哪些类是发生了改变,应该生成对应的补丁。
循环遍历工程中的全部类,声明忽略的直接跳过.对每个类计算hash,并写入到hashFile文件中.通过比较hashFile文件与原先host工程的hashFile(即这里的classHashMap参数),得到所有修改过的类生成这些类的class文件,以及所有修改过的class文件的集合jar文件。

Set<File> inputFiles = dexTask.inputs.files.files
inputFiles.each { inputFile ->
    def path = inputFile.absolutePath
    if (path.endsWith(".class") && !path.contains("/R\$") && !path.endsWith("/R.class") && !path.endsWith("/BuildConfig.class")) {
        if (HotFixSetUtils.isIncluded(path, includePackage)) {
            if (!HotFixSetUtils.isExcluded(path, excludeClass)) {
                def bytes = HotFixProcessors.processClass(inputFile)
                path = path.split("${dirName}/")[1]
                def hash = DigestUtils.shaHex(bytes)
                classHashFile.append(HotFixMapUtils.format(path, hash))

                if (HotFixMapUtils.notSame(classHashMap, path, hash)) {
                    HotFixFileUtils.copyBytesToFile(inputFile.bytes, HotFixFileUtils.touchFile(patchDir, path))
                }
            }
        }
    }
}

这一段是脚本的第二个作用,也就是上文字节码操作的目的,为了防止类被虚拟机打上CLASS_ISPREVERIFIED,所以需要执行字节码写入。其中HotFixProcessors.processClass()就是实际写入字节码的代码。

好像差个结尾

同样的方案,除了 nuwa 还有一个开源的实现,HotFix 两者是差不多的,所以看一个就可以了。

5月10日补充
看到有很多朋友问,如果混淆后代码怎么办。在 Gradle 插件编译过程中,有一个proguardTask,看名字应该就知道他是负责 proguard 任务的,我们可以保存首次执行时的混淆规则(也就是线上出BUG的包),这个混淆规则保存在工程目录中的一个mapping文件,当我们需要执行热修复补丁生成的时候,将线上包的mapping规则拿出来应用到本次编译中,就可以生成混淆后的类跟线上混淆后的类相同的类名的补丁了。具体实现可以看 nuwa 项目的applymapping()方法。