预备知识

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

看完本文可以达到什么程度

了解插件化常见的实现原理

一丶文章概览

一、热修复和插件化

插件化和热修复的原理,都是动态加载 dex/apk 中的类/资源,两者的目的不同。插件化目标在于加载 activity 等组件,达到动态下发组件的功能,热修复目标在修复已有的问题。目标不同,也就导致其实现方式上的差别。由于目标是动态加载组件,所以插件化重在解决组件的生命周期,以及资源的问题。而热修复重在解决替换已有的有问题的类/方法/资源等。

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

在学习和开发热修复的时候,我们需要动态去加载补丁 apk,所以开发过程中一般需要有两个 apk,一个是宿主 apk,一个是补丁 apk,对应的就需要有宿主项目和补丁项目。

为了方便,我们直接把生成的插件 apk 放到宿主 apk 中的 assets 中,apk 启动时直接放到内部存储空间中方便加载。
这样的项目结构,我们调试问题时的流程就是下面这样:
修改插件项目 -> 编译生成插件 apk -> 拷贝插件 apk 到宿主 assets -> 修改宿主项目 -> 编译生成宿主 apk -> 安装宿主 apk -> 验证问题

如果每次我们修改一个很小的问题,都经历这么长的流程,那么耐心很快就耗尽了。最好是可以直接编译宿主 apk 的时候自动打包插件 apk 并拷贝到宿主 assets 目录下,这样我们不管修改什么,都直接编译宿主项目就好了。如何实现呢?还记得我们之前讲解过的 gradle 系列么?现在就是学以致用的时候了。

首先在 plugin 项目的 build.gradle 添加下面的代码:

  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

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

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

三、ClassLoader

ClassLoader 是热修复和插件化中必须要掌握的,因为插件是未安装的 apk,系统不会处理其中的类,所以需要我们自己来处理。

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 为空,那么就用系统类加载器来加载。通过这样的机制可以保证系统类都是由系统类加载器加载的。

下面是 ClassLoaderloadClass 方法的具体实现。

      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 文件路径
optimizedDirectorydex 优化后存放的位置,在 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)      }

四、热修复需要解决的难点

热修复不同于插件化,不需要考虑各种组件的生命周期,唯一需要考虑的就是如何能将问题的方法/类/资源/so 替换为补丁中的新方法/类/资源/so。
其中最重要的是方法和类的替换,所以有不少热修复框架只做了方法和类的替换,而没有对资源和 so 进行处理。

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

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

上面是热修复框架的一些对比,如果按照实现 dex 修复的原理来划分的话,大概能分成下面几种:
native hook
Andfix
dex 插桩
Qzone
Nuwa
InstantRun
Robust
Aceso
全量替换 dex
Tinker
混合方案
Sophix

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

六、dex 热修复方案

6.1 native hook 替换 ArtMethod 内容

6.1.1 原理

在解释 native hook 原理之前,先介绍一下虚拟机的一些简单实现。java 中的类,方法,变量,对应到虚拟机里的实现是 Class,ArtMethod,ArtField。以 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* 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_ 指向的位置,跳转过去执行对应的方法字节码或者机器码。简图如下

所以 native hook 的本质就是把旧方法的 ArtMethod 内容替换成新方法的 ArtMethod 内容。

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(dmeth->declaring_class_)->clinit_thread_id_ =            reinterpret_cast(smeth->declaring_class_)->clinit_thread_id_;    reinterpret_cast(dmeth->declaring_class_)->status_ =            static_cast(reinterpret_cast(smeth->declaring_class_)->status_ -1);    //for reflection invoke    reinterpret_cast(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 优缺点

优点:

补丁可以实时生效

缺点:
  • 兼容性差,由于 Android 系统每个版本的实现都有差别,所以需要做很多的兼容。(这也就是为什么上面提供的 demo 代码只能运行在 Android N 上,因为没有对其他版本做兼容)
  • 开发需要掌握 jni 相关知识

6.2 dex 插桩

6.2.1 原理

dex 插桩的实现,是 Qzone 团队提出来的,Nuwa 框架采用这种实现并且开源。

系统默认使用的是 PathClassLoader,继承自 BaseDexClassLoader,在 BaseDexClassLoader 里,有一个 DexPathList 变量,在 DexPathList 的实现里,有一个 Element[] dexElements 变量,这里面保存了所有的 dex。在加载 Class 的时候,就遍历 dexElements 成员,依次查找 Class,找到以后就返回。

下面是重点代码。

  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 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 了。

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 优缺点

优点:
  • 实现简单
  • 不需要太多的适配

缺点

  • 需要重新启动补丁才能生效。因为在插桩之前加载的类是不会再重新加载的,所以需要重新启动,让已经加载过的 Class 重新加载才能应用到补丁
  • class verify 问题。
  • Art 虚拟机上由于 oat 导致的地址偏移问题,可能会需要在补丁包中打入补丁无关的类,导致补丁包体积增大

6.3 dex 替换

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

6.4 InstantRun

6.4.1 原理

InstantRun 是 AndroidStudio 2.0 新增的功能,方便快速的增量编译应用并部署,美团参照其原理实现了 Robust 热修复框架。

其中的原理是,给每个 Class 中新增一个 changeQuickRedirect 的静态变量,并在每个方法执行之前,对这个变量进行了判断,如果这个变量被赋值了,就调用补丁类中的方法,如果没有被赋值,还是调用旧方法。

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 patchMap = (Map) 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 优缺点

优点:

  • 使用 java 实现,开发方便
  • 兼容性好
  • 补丁实时生效

缺点:

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

七、资源热修复方案

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

7.1 替换 AssetManager

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

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

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

八、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 之前即可。

九、总结

更多相关文章

  1. Android混淆从入门到精通
  2. [图解]Android(安卓)View的事件分发机制
  3. System.Lazy延迟加载 在很多情况下,有些对象需要在使用时加载或根
  4. 关于Android(安卓)如何实现mobile data on/off功能
  5. Android(安卓)外部SD卡/U盘无法写入解决方法(需要root)
  6. 为Android硬件抽象层(HAL)模块编写JNI方法提供Java访问硬件服务接
  7. 最强理解:Android对EditText输入时设置监听即TextWatcher的用法
  8. [Skill]Android版本兼容器
  9. Android核心分析 分析方法论探讨之设计意图

随机推荐

  1. Android处理多种屏幕尺寸
  2. Android中实现登陆界面的记住密码功能
  3. Preference组件探究之Base,Support及Andr
  4. Android(安卓)小项目之--SQLite 使用法门
  5. 基于开源框架Glide加载Gif资源图到Androi
  6. Android(安卓)版 Chrome 即將登場,瀏覽器
  7. Android中的日历读写操作!!
  8. 【iOS-cocos2d-X 游戏开发之三】Mac下配
  9. Android异步加载全解析之开篇瞎扯淡
  10. Android换行符变成方框的解决方法