【Android 修炼手册】经常使用技术篇 -- Android 热修复解析

这是【Android 修炼手册】第 8 篇文章,若是尚未看过前面系列文章,欢迎点击 这里 查看~html

预备知识

  1. 了解 android 基本开发
  2. 了解 ClassLoader 相关知识

看完本文能够达到什么程度

  1. 了解热修复常见的实现原理

阅读前准备工做

  1. clone CommonTec 项目,其中 hotfix 和 patch 是热修复代码 示例代码基于 AndFix,NuWa,Robust 进行了调整,抽取主要部分用来说解原理。

文章概览

summary

1、热修复和插件化

插件化和热修复的原理,都是动态加载 dex/apk 中的类/资源,二者的目的不一样。插件化目标在于加载 activity 等组件,达到动态下发组件的功能,热修复目标在修复已有的问题。目标不一样,也就致使其实现方式上的差异。因为目标是动态加载组件,因此插件化重在解决组件的生命周期,以及资源的问题。而热修复重在解决替换已有的有问题的类/方法/资源等。 关于插件化,能够看前面分享的文章Android 插件化分析java

2、使用 gradle 简化插件开发流程

若是看过Android 插件化分析里的 gradle 简化插件开发流程,这里能够略过~android

在学习和开发热修复的时候,咱们须要动态去加载补丁 apk,因此开发过程当中通常须要有两个 apk,一个是宿主 apk,一个是补丁 apk,对应的就须要有宿主项目和补丁项目。
CommonTec 这里建立了 app 做为宿主项目,plugin 为插件项目。为了方便,咱们直接把生成的插件 apk 放到宿主 apk 中的 assets 中,apk 启动时直接放到内部存储空间中方便加载。
这样的项目结构,咱们调试问题时的流程就是下面这样:
修改插件项目 -> 编译生成插件 apk -> 拷贝插件 apk 到宿主 assets -> 修改宿主项目 -> 编译生成宿主 apk -> 安装宿主 apk -> 验证问题
若是每次咱们修改一个很小的问题,都经历这么长的流程,那么耐心很快就耗尽了。最好是能够直接编译宿主 apk 的时候自动打包插件 apk 并拷贝到宿主 assets 目录下,这样咱们无论修改什么,都直接编译宿主项目就行了。如何实现呢?还记得咱们以前讲解过的 gradle 系列么?如今就是学以至用的时候了。
首先在 plugin 项目的 build.gradle 添加下面的代码:c++

project.afterEvaluate {
    project.tasks.each {
        if (it.name == "assembleDebug") {
            it.doLast {
                copy {
                    from new File(project.getBuildDir(), 'outputs/patch/debug/patch-debug.apk').absolutePath
                    into new File(project.getRootProject().getProjectDir(), 'hotfix/src/main/assets')
                    rename 'patch-debug.apk', 'patch.apk'
                }
            }
        }
    }
}
复制代码

这段代码是在 afterEvaluate 的时候,遍历项目的 task,找到打包 task 也就是 assembleDebug,而后在打包以后,把生成的 apk 拷贝到宿主项目的 assets 目录下,而且重命名为 plugin.apk。git

而后在 app 项目的 build.gradle 添加下面的代码:github

project.afterEvaluate {
    project.tasks.each {
        if (it.name == 'mergeDebugAssets') {
            it.dependsOn ':patch:assembleDebug'
        }
    }
}
复制代码

找到宿主打包的 mergeDebugAssets 任务,依赖插件项目的打包,这样每次编译宿主项目的时候,会先编译插件项目,而后拷贝插件 apk 到宿主 apk 的 assets 目录下,之后每次修改,只要编译宿主项目就能够了。数组

3、ClassLoader

若是看过Android 插件化分析里的 ClassLoader 分析,这里能够略过~缓存

ClassLoader 是热修复和插件化中必需要掌握的,由于插件是未安装的 apk,系统不会处理其中的类,因此须要咱们本身来处理。app

3.1 java 中的 ClassLoader

BootstrapClassLoader 负责加载 JVM 运行时的核心类,好比 JAVA_HOME/lib/rt.jar 等等框架

ExtensionClassLoader 负责加载 JVM 的扩展类,好比 JAVA_HOME/lib/ext 下面的 jar 包

AppClassLoader 负责加载 classpath 里的 jar 包和目录

3.2 android 中的 ClassLoader

在这里,咱们统称 dex 文件,包含 dex 的 apk 文件以及 jar 文件为 dex 文件 PathClassLoader 用来加载系统类和应用程序类,用来加载 dex 文件,可是 dex2oat 生成的 odex 文件只能放在系统的默认目录。

DexClassLoader 用来加载 dex 文件,能够从存储空间加载 dex 文件,能够指定 odex 文件的存放目录。

咱们在插件化中通常使用的是 DexClassLoader。

3.3 双亲委派机制

每个 ClassLoader 中都有一个 parent 对象,表明的是父类加载器,在加载一个类的时候,会先使用父类加载器去加载,若是在父类加载器中没有找到,本身再进行加载,若是 parent 为空,那么就用系统类加载器来加载。经过这样的机制能够保证系统类都是由系统类加载器加载的。 下面是 ClassLoader 的 loadClass 方法的具体实现。

protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
            // First, check if the class has already been loaded
            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) {
                    // 没有找到,再本身加载
                    c = findClass(name);
                }
            }
            return c;
    }
复制代码

3.4 如何加载插件中的类

要加载插件中的类,咱们首先要建立一个 DexClassLoader,先看下 DexClassLoader 的构造函数须要那些参数。

public class DexClassLoader extends BaseDexClassLoader {
    public DexClassLoader(String dexPath, String optimizedDirectory, String librarySearchPath, ClassLoader parent) {
        // ...
    }
}
复制代码

构造函数须要四个参数:
dexPath 是须要加载的 dex / apk / jar 文件路径
optimizedDirectory 是 dex 优化后存放的位置,在 ART 上,会执行 oat 对 dex 进行优化,生成机器码,这里就是存放优化后的 odex 文件的位置
librarySearchPath 是 native 依赖的位置
parent 就是父类加载器,默认会先从 parent 加载对应的类

建立出 DexClassLaoder 实例之后,只要调用其 loadClass(className) 方法就能够加载插件中的类了。具体的实如今下面:

// 从 assets 中拿出插件 apk 放到内部存储空间
    private fun extractPlugin() {
        var inputStream = assets.open("plugin.apk")
        File(filesDir.absolutePath, "plugin.apk").writeBytes(inputStream.readBytes())
    }

    private fun init() {
        extractPlugin()
        pluginPath = File(filesDir.absolutePath, "plugin.apk").absolutePath
        nativeLibDir = File(filesDir, "pluginlib").absolutePath
        dexOutPath = File(filesDir, "dexout").absolutePath
        // 生成 DexClassLoader 用来加载插件类
        pluginClassLoader = DexClassLoader(pluginPath, dexOutPath, nativeLibDir, this::class.java.classLoader) } 复制代码

4、热修复须要解决的难点

热修复不一样于插件化,不须要考虑各类组件的生命周期,惟一须要考虑的就是如何能将问题的方法/类/资源/so 替换为补丁中的新方法/类/资源/so。
其中最重要的是方法和类的替换,因此有很多热修复框架只作了方法和类的替换,而没有对资源和 so 进行处理。

5、主流的热修复框架对比

这里选取几个比较主流的热修复框架进行对比

Qzone/Nuwa AndFix Robust Tinker Sophix
dex 修复 y y y y y
so 修复 n n n y y
资源修复 n n n y y
全平台支持 y n y y y
即时生效 n y y n 同时支持
补丁包大小

上面是热修复框架的一些对比,若是按照实现 dex 修复的原理来划分的话,大概能分红下面几种:

native hook
Andfix
dex 插桩
Qzone
Nuwa
InstantRun Robust
Aceso
全量替换 dex
Tinker
混合方案
Sophix

下面对这几种热修复的方案进行详细分析。

6、dex 热修复方案

6.1 native hook 替换 ArtMethod 内容

6.1.1 原理

在解释 native hook 原理以前,先介绍一下虚拟机的一些简单实现。java 中的类,方法,变量,对应到虚拟机里的实现是 ClassArtMethodArtField。以 Android N 为例,简单看一下这几个类的一些结构。

class Class: public Object {
public:
    // ...
    // classloader 指针
	uint32_t class_loader_;
    // 数组的类型表示
	uint32_t component_type_;
    // 解析 dex 生成的缓存
	uint32_t dex_cache_;
    // interface table,保存了实现的接口方法
	uint32_t iftable_;
    // 类描述符,例如:java.lang.Class
	uint32_t name_;
    // 父类
	uint32_t super_class_;
    // virtual method table,虚方法表,指令 invoke-virtual 会用到,保存着父类方法以及子类复写或者覆盖的方法,是 java 多态的基础
	uint32_t vtable_;
    // public private 
	uint32_t access_flags_;
    // 成员变量
	uint64_t ifields_;
    // 保存了全部方法,包括 static,final,virtual 方法
	uint64_t methods_;
    // 静态变量
	uint64_t sfields_;
    // class 当前的状态,加载,解析,初始化等等
	Status status_;
	static uint32_t java_lang_Class_;
};

class ArtField {
public:
	uint32_t declaring_class_;
	uint32_t access_flags_;
	uint32_t field_dex_idx_;
	uint32_t offset_;
};

class ArtMethod {
public:
	uint32_t declaring_class_;
	uint32_t access_flags_;
    // 方法字节码的偏移
	uint32_t dex_code_item_offset_;
    // 方法在 dex 中的 index
	uint32_t dex_method_index_;
    // 在 vtable 或者 iftable 中的 index
	uint16_t method_index_;
    // 方法的调用入口
    struct PACKED(4) PtrSizedFields {
        ArtMethod** dex_cache_resolved_methods_;
        GcRoot<mirror::Class>* dex_cache_resolved_types_;
        void* entry_point_from_jni_;
        void* entry_point_from_quick_compiled_code_;
    } ptr_sized_fields_;
};
复制代码

上面列出了三个结构的一部分变量,其实从这些变量能够比较清楚的看到,Class 中的 iftable_,vtable_,methods_ 里面保存了全部的类方法,sfields_,ifields_ 保存了全部的成员变量。而在 ArtMethod 中,ptr_sized_fields_ 变量指向了方法的调用入口,也就是执行字节码的地方。在虚拟机内部,调用一个方法的时候,能够简单的理解为会找到 ptr_sized_fields_ 指向的位置,跳转过去执行对应的方法字节码或者机器码。简图以下:

class_method

这里也顺便说一下上面三个结构的内容是何时填充的,就是在 ClassLoader 加载类的时候。简图以下:

classloader

其实到这里,咱们就简单理解了虚拟机的内部实现,也就很容易想到 native hook 的原理了。既然每次调用方法的时候,都是经过 ArtMethod 找到方法,而后跳转到其对应的字节码/机器码位置去执行,那么咱们只要更改了跳转的目标位置,那么天然方法的实现也就被改变了。简图以下:

native_hook

因此 native hook 的本质就是把旧方法的 ArtMethod 内容替换成新方法的 ArtMethod 内容。 具体的实现代码在这里(只实现了 Android N 上的修复),下面看一些重点代码。

6.1.2 实现代码
  1. 首先要找到替换的旧方法和新方法,这一步在 java 中进行,直接经过反射获取便可
// 建立补丁的 ClassLoader
pluginClassLoader = DexClassLoader(pluginPath, dexOutPath.absolutePath, nativeLibDir.absolutePath, this::class.java.classLoader) // 经过补丁 ClassLoader 加载新方法 val toMethod = pluginClassLoader.loadClass("com.zy.hotfix.native_hook.PatchNativeHookUtils").getMethod("getMsg")
// 反射获取到须要修改的旧方法
val fromMethod = nativeHookUtils.javaClass.getMethod("getMsg")
复制代码
  1. 以后调用 native 方法替换 ArtMethod 内容
nativeHookUtils.patch(fromMethod, toMethod)
复制代码
Java_com_zy_hotfix_native_1hook_NativeHookUtils_patch(JNIEnv* env, jobject clazz, jobject src, jobject dest) {
    // 获取到 java 方法对应的 ArtMethod
    art::mirror::ArtMethod* smeth =
            (art::mirror::ArtMethod*) env->FromReflectedMethod(src);
    art::mirror::ArtMethod* dmeth =
            (art::mirror::ArtMethod*) env->FromReflectedMethod(dest);

    reinterpret_cast<art::mirror::Class*>(dmeth->declaring_class_)->clinit_thread_id_ =
            reinterpret_cast<art::mirror::Class*>(smeth->declaring_class_)->clinit_thread_id_;
    reinterpret_cast<art::mirror::Class*>(dmeth->declaring_class_)->status_ =
            static_cast<art::mirror::Class::Status>(reinterpret_cast<art::mirror::Class*>(smeth->declaring_class_)->status_ -1);
    //for reflection invoke
    reinterpret_cast<art::mirror::Class*>(dmeth->declaring_class_)->super_class_ = 0;

    // 替换方法中的内容
    smeth->declaring_class_ = dmeth->declaring_class_;
    smeth->access_flags_ = dmeth->access_flags_  | 0x0001;
    smeth->dex_code_item_offset_ = dmeth->dex_code_item_offset_;
    smeth->dex_method_index_ = dmeth->dex_method_index_;
    smeth->method_index_ = dmeth->method_index_;
    smeth->hotness_count_ = dmeth->hotness_count_;
    // 替换方法的入口
    smeth->ptr_sized_fields_.dex_cache_resolved_methods_ =
            dmeth->ptr_sized_fields_.dex_cache_resolved_methods_;
    smeth->ptr_sized_fields_.dex_cache_resolved_types_ =
            dmeth->ptr_sized_fields_.dex_cache_resolved_types_;
    smeth->ptr_sized_fields_.entry_point_from_jni_ =
            dmeth->ptr_sized_fields_.entry_point_from_jni_;
    smeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_ =
            dmeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_;
}
复制代码

经过上述方法的替换,再次调用旧方法,就会跳转到新方法的入口,天然也就执行新方法的逻辑了。

6.1.3 优缺点

优势
补丁能够实时生效
缺点

  1. 兼容性差,因为 Android 系统每一个版本的实现都有差异,因此须要作不少的兼容。(这也就是为何上面提供的 demo 代码只能运行在 Android N 上,由于没有对其余版本作兼容)
  2. 开发须要掌握 jni 相关知识

6.2 dex 插桩

6.2.1 原理

dex 插桩的实现,是 Qzone 团队提出来的,Nuwa 框架采用这种实现而且开源。
系统默认使用的是 PathClassLoader,继承自 BaseDexClassLoader,在 BaseDexClassLoader 里,有一个 DexPathList 变量,在 DexPathList 的实现里,有一个 Element[] dexElements 变量,这里面保存了全部的 dex。在加载 Class 的时候,就遍历 dexElements 成员,依次查找 Class,找到之后就返回。

insert_dex

下面是重点代码。

public class PathClassLoader extends BaseDexClassLoader {
}

public class BaseDexClassLoader extends ClassLoader {
    private final DexPathList pathList;
}

final class DexPathList {
    // 保存了 dex 的列表
    private Element[] dexElements;

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

            if (dex != null) {
                // 从 DexFile 中查找 Class
                Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
                if (clazz != null) {
                    return clazz;
                }
            }
        }
        // ...
        return null;
    }
}
复制代码

从上面 ClassLoader 的实现咱们能够知道,查找 Class 的关键就是遍历 dexElements,那么天然就想到了把补丁 dex 插入到 dexElements 最前面,这样遍历 dexElements 就会优先从补丁 dex 中查找 Class 了。

insert_dex2

具体的实如今这里,下面放一些重点代码。

6.2.2 实现代码
public static void injectDexAtFirst(String dexPath, String defaultDexOptPath) throws NoSuchFieldException, IllegalAccessException, ClassNotFoundException {
        // 建立补丁 dex 的 classloader,目的是使用其中的补丁 dexElements
        DexClassLoader dexClassLoader = new DexClassLoader(dexPath, defaultDexOptPath, dexPath, getPathClassLoader());
        // 获取到旧的 classloader 的 pathlist.dexElements 变量
        Object baseDexElements = getDexElements(getPathList(getPathClassLoader()));
        // 获取到补丁 classloader 的 pathlist.dexElements 变量
        Object newDexElements = getDexElements(getPathList(dexClassLoader));
        // 将补丁 的 dexElements 插入到旧的 classloader.pathlist.dexElements 前面
        Object allDexElements = combineArray(newDexElements, baseDexElements);
    }

    private static PathClassLoader getPathClassLoader() {
        PathClassLoader pathClassLoader = (PathClassLoader) InsertDexUtils.class.getClassLoader();
        return pathClassLoader;
    }

    private static Object getDexElements(Object paramObject) throws IllegalArgumentException, NoSuchFieldException, IllegalAccessException {
        return Reflect.on(paramObject).get("dexElements");
    }

    private static Object getPathList(Object baseDexClassLoader) throws IllegalArgumentException, NoSuchFieldException, IllegalAccessException, ClassNotFoundException {
        return Reflect.on(baseDexClassLoader).get("pathList");
    }

    private static Object combineArray(Object firstArray, Object secondArray) {
        Class<?> localClass = firstArray.getClass().getComponentType();
        int firstArrayLength = Array.getLength(firstArray);
        int allLength = firstArrayLength + Array.getLength(secondArray);
        Object result = Array.newInstance(localClass, allLength);
        for (int k = 0; k < allLength; ++k) {
            if (k < firstArrayLength) {
                Array.set(result, k, Array.get(firstArray, k));
            } else {
                Array.set(result, k, Array.get(secondArray, k - firstArrayLength));
            }
        }
        return result;
    }
复制代码
6.2.3 优缺点

优势

  1. 实现简单
  2. 不须要太多的适配

缺点

  1. 须要从新启动补丁才能生效。由于在插桩以前加载的类是不会再从新加载的,因此须要从新启动,让已经加载过的 Class 从新加载才能应用到补丁
  2. class verify 问题。关于这个问题能够看Qzone 的解释,这里就不详细展开了
  3. Art 虚拟机上因为 oat 致使的地址偏移问题,可能会须要在补丁包中打入补丁无关的类,致使补丁包体积增大

6.3 dex 替换

dex 替换的方案,主要是 tinker 在使用,这里生成的补丁包不仅是须要修改的类,而是包含了整个 app 全部的类,在替换时原理和 dex 插桩相似,也是替换掉 dexElements 中的内容便可,这里就不详细说了。

6.4 InstantRun

6.4.1 原理

InstantRun 是 AndroidStudio 2.0 新增的功能,方便快速的增量编译应用并部署,美团参照其原理实现了 Robust 热修复框架。 其中的原理是,给每一个 Class 中新增一个 changeQuickRedirect 的静态变量,并在每一个方法执行以前,对这个变量进行了判断,若是这个变量被赋值了,就调用补丁类中的方法,若是没有被赋值,仍是调用旧方法。 原理比较简单,下面看看实现。具体实如今这里

instant_run

6.4.2 实现代码
public class InstantRunUtils {
    // 上文中说的 changeQuickRedirect 变量,改了一下名字
    public static PatchRedirect patchRedirect;

    // 须要补丁的方法
    public int getValue() {
        // 判断 patchRedirect 是否为空
        if (patchRedirect != null) {
            // 不为空,说明方法须要打补丁,因为一个类中有不少方法,因此这里须要判断此方法是否须要补丁
            if (patchRedirect.needPatch("getValue")) {
                // 须要补丁,就调用补丁中的方法
                return (String) patchRedirect.invokePatchMethod("getValue");
            }
        }
        return 100;
    }

    // 注入补丁
    public static void inject(ClassLoader classLoader) {
        try {
            // 获取到补丁中的补丁信息
            Class patchInfoClass = classLoader.loadClass("com.zy.hotfix.instant_run.PatchInfo");
            patchInfoClass.getMethod("init").invoke(null);
            // patchMap 中存着 className -> PatchRedirect,即须要补丁的类描述符和对应的 PatchRedirect
            Map<String, Object> patchMap = (Map<String, Object>) patchInfoClass.getField("patchMap").get(null);
            for (String key: patchMap.keySet()) {
                PatchRedirect redirect = (PatchRedirect) patchMap.get(key);
                Class clazz = Class.forName(key);
                // 替换 class 中的 PatchRedirect
                clazz.getField("patchRedirect").set(null, redirect);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
复制代码

而后咱们看看补丁中的 PatchRefirect 是怎么实现的

public class InstantRunUtilsRedirect extends PatchRedirect {
    @Override
    public Object invokePatchMethod(String methodName, Object... params) {
        // 根据方法描述符调用对应的方法
        if (methodName.equals("getValue")) {
            return getValue();
        }
        return null;
    }

    @Override
    public boolean needPatch(String methodName) {
        // 判断方法是否须要补丁
        if ("getValue".equals(methodName)) {
            return true;
        }
        return false;
    }

    // 补丁方法,返回正确的值
    public int getValue() {
        return 200;
    }
}
复制代码
6.4.3 优缺点

优势

  1. 使用 java 实现,开发方便
  2. 兼容性好
  3. 补丁实时生效

缺点

  1. 代码是侵入比较高,须要在原有代码中新增逻辑,并且须要对方法进行插桩,将这里逻辑自动化处理
  2. 增大包体积

7、资源热修复方案

关于资源的修复方案,没有像代码修复同样方法繁多,基本上集中在对 AssetManager 的修改上。

7.1 替换 AssetManager

这个是 InstantRun 采用的方案,就是构造一个新的 AssetManager,反射调用其 addAssetPath 函数,把新的补丁资源包添加到 AssetManager 中,从而获得含有完整补丁资源的 AssetManager,而后找到全部引用 AssetManager 的地方,经过反射将其替换为新的 AssetManager。

7.2 添加修改的资源到 AssetManager 中,并从新初始化

这个是 Sophix 采用的方案,原理是构造一个 package id 为 0x66 的资源包,只含有改变的资源,将其直接添加到原有的 AssetManager 中,这样不会与原来的 package id 0x7f 冲突。而后将原来的 AssetManager 从新进行初始化便可,就不须要进行繁琐的反射替换操做了。

8、so 热修复方案

8.1 对加载过程进行封装,替换 System.loadLibrary

在加载 so 库的时候,系统提供了两个接口

System.loadLibrary(String libName):用来加载已经安装的 apk 中的 so
System.load(String pathName):能够加载自定义路径下的 so
复制代码

经过上面两个方法,咱们能够想到,若是有补丁 so 下发,咱们就调用 System.load 去加载,若是没有补丁 so 没有下发,那么仍是调用 System.loadLibrary 去加载系统目录下的 so,原理比较简单,可是咱们须要再上面进行一层封装,并对调用 System.loadLibrary 的地方都进行替换。

8.2 反射注入补丁 so 路径

还记得上面 dex 插桩的原理么?在 DexPathList 中有 dexElements 变量,表明着全部 dex 文件,其实 DexPathList 中还有另外一个变量就是 Element[] nativeLibraryPathElements,表明的是 so 的路径,在加载 so 的时候也会遍历 nativeLibraryPathElements 进行加载,代码以下:

public String findLibrary(String libraryName) {
        String fileName = System.mapLibraryName(libraryName);
        // 遍历 nativeLibraryPathElements 
        for (Element element : nativeLibraryPathElements) {
            String path = element.findNativeLibrary(fileName);

            if (path != null) {
                return path;
            }
        }
        return null;
    }
复制代码

看到这里咱们就知道如何去作了吧,就像 dex 插桩同样的方法,将 so 的路径插入到 nativeLibraryPathElements 以前便可。

9、总结

summary

参考资料

www.cnblogs.com/popfisher/p…
tech.meituan.com/2016/09/14/…
深刻探索Android热修复技术原理

关于我

about
相关文章
相关标签/搜索