解析 ClassLoader 系列
前言
在 Android 应用开发中, 热修复技术被越来越多的开发者所使用, 也出现了很多热修复框架, 比如: AndFixTinkerDexposed 和 Nuwa 等等如果只是会这些热修复框架的使用那意义并不大, 我们还需要了解它们的原理, 这样不管热修复框架如何变化, 只要基本原理不变, 我们就可以很快的掌握它们这一个系列不会对某些热修复框架源码进行解析, 而是讲解热修复框架的通用原理
1. 热修复的产生概述
在开发中我们会遇到如下的情况:
刚发布的版本出现了严重的 bug, 这就需要去解决 bug 测试并打渠道包在各个应用市场上重新发布, 这会耗费大量的人力物力, 代价会比较大
已经改正了此前发布版本的 bug, 如果下一个版本是一个大版本, 那么两个版本的间隔时间会很长, 这样要等到下个大版本发布再修复 bug, 这样此前版本的 bug 会长期的影响用户
版本升级率不高, 并且需要很长时间来完成版本覆盖, 此前版本的 bug 就会一直影响不升级版本的用户
有一个小而重要的功能, 需要短时间内完成版本覆盖, 比如节日活动
为了解决上面的问题, 热修复框架就产生了对于 Bug 的处理, 开发人员不要过于依赖热修复框架, 在开发的过程中还是要按照标准的流程做好自测配合测试人员完成测试流程
2. 热修复框架的对比
热修复框架的种类繁多, 按照公司团队划分主要有以下几种:
类别 | 成员 |
---|---|
阿里系 | AndFix、Dexposed、阿里百川、Sophix |
腾讯系 | 微信的 Tinker、QQ 空间的超级补丁、手机 QQ 的 QFix |
知名公司 | 美团的 Robust、饿了么的 Amigo、美丽说蘑菇街的 Aceso |
其他 | RocooFix、Nuwa、AnoleFix |
虽然热修复框架很多, 但热修复框架的核心技术主要有三类, 分别是代码修复资源修复和动态链接库修复, 其中每个核心技术又有很多不同的技术方案, 每个技术方案又有不同的实现, 另外这些热修复框架仍在不断的更新迭代中, 可见热修复框架的技术实现是繁多可变的作为开发需需要了解这些技术方案的基本原理, 这样就可以以不变应万变
部分热修复框架的对比如下表所示
特性 | AndFix | Tinker/Amigo | QQ 空间 | Robust/Aceso |
---|---|---|---|---|
即时生效 | 是 | 否 | 否 | 是 |
方法替换 | 是 | 是 | 是 | 是 |
类替换 | 否 | 是 | 是 | 否 |
类结构修改 | 否 | 是 | 否 | 否 |
资源替换 | 否 | 是 | 是 | 否 |
so 替换 | 否 | 是 | 否 | 否 |
支持 gradle | 否 | 是 | 否 | 否 |
支持 ART | 是 | 是 | 是 | 是 |
支持 Android7.0 | 是 | 是 | 是 | 是 |
我们可以根据上表和具体业务来选择合适的热修复框架, 当然上表的信息很难做到完全准确, 因为部分的热修复框架还在不断更新迭代 从表中也可以发现 Tinker 和 Amigo 拥有的特性最多, 是不是就选它们呢? 也不尽然, 拥有的特性多也意味着框架的代码量庞大, 我们需要根据业务来选择最合适的, 假设我们只是要用到方法替换, 那么使用 Tinker 和 Amigo 显然是大材小用了另外如果项目需要即时生效, 那么使用 Tinker 和 Amigo 是无法满足需求的对于即时生效, AndFixRobust 和 Aceso 都满足这一点, 这是因为 AndFix 的代码修复采用了底层替换方案, 而 Robust 和 Aceso 的代码修复借鉴了 Instant Run 原理, 现在我们就来学习代码修复
3. 代码修复
代码修复主要有三个方案, 分别是底层替换方案类加载方案和 Instant Run 方案
3.1 类加载方案
类加载方案基于 Dex 分包方案, 什么是 Dex 分包方案呢? 这个得先从 65536 限制和 LinearAlloc 限制说起 65536 限制 随着应用功能越来越复杂, 代码量不断地增大, 引入的库也越来越多, 可能会在编译时提示如下异常:
com.android.dex.DexIndexOverflowException: method ID not in [0, 0xffff]: 65536
这说明应用中引用的方法数超过了最大数 65536 个产生这一问题的原因就是系统的 65536 限制, 65536 限制的主要原因是 DVM Bytecode 的限制, DVM 指令集的方法调用指令 invoke-kind 索引为 16bits, 最多能引用 65535 个方法 LinearAlloc 限制 在安装时可能会提示 INSTALL_FAILED_DEXOPT 产生的原因就是 LinearAlloc 限制, DVM 中的 LinearAlloc 是一个固定的缓存区, 当方法数过多超出了缓存区的大小时会报错
为了解决 65536 限制和 LinearAlloc 限制, 从而产生了 Dex 分包方案 Dex 分包方案主要做的是在打包时将应用代码分成多个 Dex, 将应用启动时必须用到的类和这些类的直接引用类放到主 Dex 中, 其他代码放到次 Dex 中当应用启动时先加载主 Dex, 等到应用启动后再动态的加载次 Dex, 从而缓解了主 Dex 的 65536 限制和 LinearAlloc 限制
Dex 分包方案主要有两种, 分别是 Google 官方方案 Dex 自动拆包和动态加载方案因为 Dex 分包方案不是本章的重点, 这里就不再过多的介绍, 我们接着来学习类加载方案 在 Android 解析 ClassLoader(二)Android 中的 ClassLoader 中讲到了 ClassLoader 的加载过程, 其中一个环节就是调用 DexPathList 的 findClass 的方法, 如下所示 libcore/dalvik/src/main/java/dalvik/system/DexPathList.java
- public Class<?> findClass(String name, List<Throwable> suppressed) {
- for (Element element : dexElements) {//1
- Class<?> clazz = element.findClass(name, definingContext, suppressed);//2
- if (clazz != null) {
- return clazz;
- }
- }
- if (dexElementsSuppressedExceptions != null) {
- suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
- }
- return null;
- }
Element 内部封装了 DexFile,DexFile 用于加载 dex 文件, 因此每个 dex 文件对应一个 Element 多个 Element 组成了有序的 Element 数组 dexElements 当要查找类时, 会在注释 1 处遍历 Element 数组 dexElements(相当于遍历 dex 文件数组), 注释 2 处调用 Element 的 findClass 方法, 其方法内部会调用 DexFile 的 loadClassBinaryName 方法查找类如果在 Element 中 (dex 文件) 找到了该类就返回, 如果没有找到就接着在下一个 Element 中进行查找 根据上面的查找流程, 我们将有 bug 的类 Key.class 进行修改, 再将 Key.class 打包成包含 dex 的补丁包 Patch.jar, 放在 Element 数组 dexElements 的第一个元素, 这样会首先找到 Patch.dex 中的 Key.class 去替换之前存在 bug 的 Key.class, 排在数组后面的 dex 文件中的存在 bug 的 Key.class 根据 ClassLoader 的双亲委托模式就不会被加载, 这就是类加载方案, 如下图所示
类加载方案需要重启 App 后让 ClassLoader 重新加载新的类, 为什么需要重启呢? 这是因为类是无法被卸载的, 因此要想重新加载新的类就需要重启 App, 因此采用类加载方案的热修复框架是不能即时生效的 虽然很多热修复框架采用了类加载方案, 但具体的实现细节和步骤还是有一些区别的, 比如 QQ 空间的超级补丁和 Nuwa 是按照上面说得将补丁包放在 Element 数组的第一个元素得到优先加载微信 Tinker 将新旧 apk 做了 diff, 得到 patch.dex, 然后将 patch.dex 与手机中 apk 的 classes.dex 做合并, 生成新的 classes.dex, 然后在运行时通过反射将 classes.dex 放在 Element 数组的第一个元素饿了么的 Amigo 则是将补丁包中每个 dex 对应的 Element 取出来, 之后组成新的 Element 数组, 在运行时通过反射用新的 Element 数组替换掉现有的 Element 数组
采用类加载方案的主要是以腾讯系为主, 包括微信的 TinkerQQ 空间的超级补丁手机 QQ 的 QFix 饿了么的 Amigo 和 Nuwa 等等
3.2 底层替换方案
与类加载方案不同的是, 底层替换方案不会再次加载新类, 而是直接在 Native 层修改原有类, 由于是在原有类进行修改限制会比较多, 不能够增减原有类的方法和字段, 如果我们增加了方法数, 那么方法索引数也会增加, 这样访问方法时会无法通过索引找到正确的方法, 同样的字段也是类似的情况 底层替换方案和反射的原理有些关联, 就拿方法替换来说, 方法反射我们可以调用 java.lang.Class.getDeclaredMethod, 假设我们要反射 Key 的 show 方法, 会调用如下所示
Key.class.getDeclaredMethod("show").invoke(Key.class.newInstance());
Android 8.0 的 invoke 方法, 如下所示 libcore/ojluni/src/main/java/java/lang/reflect/Method.java
- @FastNative
- public native Object invoke(Object obj, Object... args)
- throws IllegalAccessException, IllegalArgumentException, InvocationTargetException;
invoke 方法是个 native 方法, 对应 Jni 层的代码为: art/runtime/native/java_lang_reflect_Method.cc
- static jobject Method_invoke(JNIEnv* env, jobject javaMethod, jobject javaReceiver,
- jobject javaArgs) {
- ScopedFastNativeObjectAccess soa(env);
- return InvokeMethod(soa, javaMethod, javaReceiver, javaArgs);
Method_invoke 函数中又调用了 InvokeMethod 函数: art/runtime/reflection.cc
- jobject InvokeMethod(const ScopedObjectAccessAlreadyRunnable& soa, jobject javaMethod,
- jobject javaReceiver, jobject javaArgs, size_t num_frames) {
- ...
- ObjPtr<mirror::Executable> executable = soa.Decode<mirror::Executable>(javaMethod);
- const bool accessible = executable->IsAccessible();
- ArtMethod* m = executable->GetArtMethod();//1
- ...
- }
注释 1 处获取传入的 javaMethod(Key 的 show 方法)在 ART 虚拟机中对应的一个 ArtMethod 指针, ArtMethod 结构体中包含了 Java 方法的所有信息, 包括执行入口访问权限所属类和代码执行地址等等, ArtMethod 结构如下所示 art/runtime/art_method.h
- class ArtMethod FINAL {
- ...
- protected:
- GcRoot<mirror::Class> declaring_class_;
- std::atomic<std::uint32_t> access_flags_;
- uint32_t dex_code_item_offset_;
- uint32_t dex_method_index_;
- uint16_t method_index_;
- uint16_t hotness_count_;
- struct PtrSizedFields {
- ArtMethod** dex_cache_resolved_methods_;//1
- void* data_;
- void* entry_point_from_quick_compiled_code_;//2
- } ptr_sized_fields_;
- }
ArtMethod 结构中比较重要的字段是注释 1 处的 dex_cache_resolved_methods_和注释 2 处的 entry_point_from_quick_compiled_code_, 它们是方法的执行入口, 当我们调用某一个方法时(比如 Key 的 show 方法), 就会取得 show 方法的执行入口, 通过执行入口就可以跳过去执行 show 方法 替换 ArtMethod 结构体中的字段或者替换整个 ArtMethod 结构体, 这就是底层替换方案 AndFix 采用的是替换 ArtMethod 结构体中的字段, 这样会有兼容问题, 因为厂商可能会修改 ArtMethod 结构体, 导致方法替换失败 Sophix 采用的是替换整个 ArtMethod 结构体, 这样不会存在兼容问题 底层替换方案直接替换了方法, 可以立即生效不需要重启采用底层替换方案主要是阿里系为主, 包括 AndFixDexposed 阿里百川 Sophix
3.3 Instant Run 方案
除了资源修复, 代码修复同样也可以借鉴 Instant Run 的原理, 可以说 Instant Run 的出现推动了热修复框架的发展 Instant Run 在第一次构建 apk 时, 使用 ASM 在每一个方法中注入了类似如下的代码:
- IncrementalChange localIncrementalChange = $change;//1
- if (localIncrementalChange != null) {//2
- localIncrementalChange.access$dispatch(
- "onCreate.(Landroid/os/Bundle;)V", new Object[] { this,
- paramBundle });
- return;
- }
其中注释 1 处是一个成员变量 localIncrementalChange , 它的值为 $change,$change 实现了 IncrementalChange 这个抽象接口当我们点击 InstantRun 时, 如果方法没有变化则 $change 为 null, 就调用 return, 不做任何处理如果方法有变化, 就生成替换类, 这里我们假设 MainActivity 的 onCreate 方法做了修改, 就会生成替换类
MainActivity$override
, 这个类实现了 IncrementalChange 接口, 同时也会生成一个 AppPatchesLoaderImpl 类, 这个类的 getPatchedClasses 方法会返回被修改的类的列表(里面包含了 MainActivity), 根据列表会将 MainActivity 的 $change 设置为
MainActivity$override
, 因此满足了注释 2 的条件, 会执行
MainActivity$override
的 access$dispatch 方法, access$dispatch 方法中会根据参数 "onCreate.(Landroid/os/Bundle;)V" 执行
MainActivity$override
的 onCreate 方法, 从而实现了 onCreate 方法的修改 借鉴 Instant Run 的原理的热修复框架有 Robust 和 Aceso
来源: https://juejin.im/post/5aa7598b51882555731bca9e