Sophix 热修复亮点鉴赏
概述
2017 年 6 月,阿里巴巴手机淘宝技术团队联合阿里云正式发布新一代非侵入式 Android 热修复方案–Sophix 。Sophix 号称打破了各家热修复技术纷争的局面,在代码修复、资源修复、so 修复、安全性、易用性等方面都做到了业界领先,颇有一统江湖的味道。其官方也给出了各种流行热修复方案的横向比较结果:

除了不能增加四大组件的修复,Sophix 确实在各大方面表现优异。同时,非侵入式是 Sophix 的核心设计理念,不关心打包流程、不进行插桩,同时实现对开发者而言最大的透明度和自由度。本片文章就是学习记录 Sophix 实现热修复过程中的奇技淫巧。
即简单又稳定的即时修复
在 Android 热修复方案中,Andfix 的即时生效令人印象深刻,不需要重新启动即可对要修复的方法完整替换,仿佛充满了魔法。但是这种“魔法”拥有诸多限制且十分危险。
Andfix 实现即时修复的原理是底层热替换。在 Android 虚拟机中,Java 层的方法最终由 C++ 层的 ArtMethod 对象进行描述。Android 5.0 的 ArtMethod 对象描述如下:
class ArtMethod: public Object {
public:
// Field order required by test "ValidateFieldOrderOfJavaCppUnionClasses".
// The class we are a part of
uint32_t declaring_class_;
// short cuts to declaring_class_->dex_cache_ member for fast compiled code access
uint32_t dex_cache_resolved_methods_;
// short cuts to declaring_class_->dex_cache_ member for fast compiled code access
uint32_t dex_cache_resolved_types_;
// short cuts to declaring_class_->dex_cache_ member for fast compiled code access
uint32_t dex_cache_strings_;
// Method dispatch from the interpreter invokes this pointer which may cause a bridge into
// compiled code.
uint64_t entry_point_from_interpreter_;
// Pointer to JNI function registered to this method, or a function to resolve the JNI function.
uint64_t entry_point_from_jni_;
// Method dispatch from portable compiled code invokes this pointer which may cause bridging into
// quick compiled code or the interpreter.
#if defined(ART_USE_PORTABLE_COMPILER)
uint64_t entry_point_from_portable_compiled_code_;
#endif
// Method dispatch from quick compiled code invokes this pointer which may cause bridging into
// portable compiled code or the interpreter.
uint64_t entry_point_from_quick_compiled_code_;
// Pointer to a data structure created by the compiler and used by the garbage collector to
// determine which registers hold live references to objects within the heap. Keyed by native PC
// offsets for the quick compiler and dex PCs for the portable.
uint64_t gc_map_;
// Access flags; low 16 bits are defined by spec.
uint32_t access_flags_;
/* Dex file fields. The defining dex file is available via declaring_class_->dex_cache_ */
// Offset to the CodeItem.
uint32_t dex_code_item_offset_;
// Index into method_ids of the dex file associated with this method.
uint32_t dex_method_index_;
/* End of dex file fields. */
// Entry within a dispatch table for this method. For static/direct methods the index is into
// the declaringClass.directMethods, for virtual methods the vtable and for interface methods the
// ifTable.
uint32_t method_index_;
static void* java_lang_reflect_ArtMethod_;
};
这里的底层替换实际就是替换这个 ArtMethod 对象,将原 ArtMethod 的对象的内容全部替换为修复后的 ArtMethod 对象的内容,替换之后,当访问该方法时,通过 ArtMethod 拿到的已经是修复后的方法。该方法虽好,但是在 Andfix 的实现里,是通过一一替换 ArtMethod 对象的每个元素达到目的,然后随着 Android 系统的升级,难免会更改 ArtMethod 的结构,这样 Andfix 必须重新适配,我们通过 Andfix 的代码结构也可以看出来这一缺点:

而在 Sophix 中巧妙地解决了这个问题,即将以前的逐一替换变成 ArtMethod 的整体替换,而实现这一步的就是 memcpy(smeth, dmeth, sizeof(ArtMethod)) 方法,且就这么一个简单的方法。
这里唯一有些难度的是该方法的第三个参数,ArtMethod 的大小不能有一丝一毫的偏差。第一次看到这里的时候可能会很奇怪,既然是在 native 层操作,用的也是 C++ ,那么直接调用 sizeof(ArtMethod) 不就拿到当前设备上该对象的真实长度了吗?话虽如此,但是 ArtMethod 是 Android 虚拟机内部对象,外部根本拿不到该对象,如果想直接操作它,就得像 Andfix 一样在代码里声明一个 ArtMethod 对象与虚拟机里面的结构完全一致,可这样一来之前的问题就又出现了。事实 Sophix 自然没有这么做,而是利用虚拟机底层数据结构及其排列特点非常巧妙且简单地解决了这个问题。
Sophix 团队通过查看虚拟机为 ArtMethod 分配内存的过程,发现虚拟机在初始化一个类时,将该类的方法一个接一个紧密地 new 出来排在一个方法数组中:

于是 Sophix 特意构造了一个类:
public class NativeStructsModel {
final public static void f1() {}
final public static void f2() {}
}
对于该类来说,f1 和 f2 一定是相邻的两个 ArtMethod ,于是便可以在 JNI 层取得这两个方法地址的差值:
size_t firMid = (size_t) env->GetStaticMethodID(nativeStructsModelClazz, "f1", "()V");
size_t secMid = (size_t) env->GetStaticMethodID(nativeStructsModelClazz, "f2", "()V");
size_t methSize = secMid - firMid;
这样就获取到了 ArtMethod 的大小,memcpy 就可以快速简便地替换掉整个 ArtMethod 了,热替换的目的也达到了,也不需要根据系统版本做各种适配兼容。
如何避免插桩操作
虚拟机在加载 Dex 文件的时候,会先执行 dexopt 过程。该过程中,如果某个 Dex 文件的类所引用的类都在当前 Dex 文件中,那么该 Dex 文件的类会被打上 CLASS_ISPREVERIFIED 标志。补丁包作为单独的 Dex 文件,假设其中有一个 A 类,原 Dex 文件中一个类 B 中的某个方法引用到了 A 类,在执行 B 类的该方法时,会尝试解析 A 类,B 类在此前 dexopt 过程中已经被打上了 CLASS_ISPREVERIFIED 标志,而当前要加载的 A 类却在另外一个 Dex 文件中,于是加载过程中便会抛出异常。
为了解决这个问题,QQ 空间的方案是通过插桩避免所有的 Dex 文件里面的类被打上 CLASS_ISPREVERIFIED 标志。该方案会侵入打包流程,通过编辑 Java 字节码进行插桩操作,让每个类都引用一个公共类,且该公共类被编译在单独的 Dex 文件中,进而避免 dexopt 过程中类被打上 CLASS_ISPREVERIFIED 标志。但是该方案对性能的消耗很大,之所以被打上 CLASS_ISPREVERIFIED 标志,是为了在运行时不再进行类的 verify 操作,该操作会对类的所有方法、所有指令进行校验,如果在运行时对每个类每次都执行这样的操作,损耗是很大的:

在 Tinker 的方案中,是补丁和原 Dex 进行合成为最终的完整 DEX ,然后加载这个合成后的完整的 Dex 而不是单个补丁 Dex 文件,所以就像除此安装一样,dexopt 过程正常执行,没有了 CLASS_ISPREVERIFIED 这个问题。不过合成操作很耗内存,容易引起 OOM。
Sophix 采用的依然是全量 Dex 方案,不过如何合成这个全量的 Dex ,Sophix 有自己的实现。Sophix 团队查看 dalvik 和 art 加载 Dex 文件的过程,发现以下特点:
- Dalvik 在加载的时候,只会加载主 dex 文件,即 classes.dex ,压缩文件中的其他 dex 文件会被字节忽略。
- Art 会加载压缩包中的所有 dex 文件。
所以在 Art 虚拟机,方案就简单很多,即将补丁包作为主 dex 文件(classes.dex),原 apk 中的 dex 依次命令 classes(2,3,4…).dex ,将它们一起打包为一个压缩文件,然后用 DexFile.loadDex 得到 DexFile 对象,将其整个替换掉旧的 dexElements 数组。相比 Tinker 的方案,没有合成 dex 的过程,既简单又高效。
对于 Dalvik 虚拟机来说,因为只能加载主 dex 文件,所以全量 dex 的方式不可取。Sophix 的做法十分巧妙,即将原 Dex 文件中的部分 class 给去掉即可,这些去掉的 class 都是在补丁包中存在的 class 。这样一个补丁 dex 文件 + 去掉补丁中已有 class 的原 Dex 文件就是一个新的修复后的完整 dex 文件。补丁 dex 确实需要进行 dexopt 过程,但是所有旧的 dex 不需要新的 dexopt 过程,将损耗降到了最低。可能一开始看的时候会觉得取出指定 class 的操作会很麻烦,因为要随便祛除一点儿东西,可能整个 Dex 文件的 offset 都要修改,牵一发动全身的感觉。但是,Sophix 在这里又使用了很取巧的办法,在 Dex 的文件结构中,class 是一次排列的,pHeader->classDefsSize 代表了该 Dex 中所有 class 的数量。Sophix 只是删除了 Dex 文件中 class 的入口而不实际删除 class 的内容,整个过程如下图所示:

虽然不是彻底的删除,但是这些 class 数据并没有多少,而且这样一来极大地保证了速度。
巧妙的资源修复
资源修复最早来自于 Instant Run ,之后的热修复方案针对资源的热修复有大体参照 Instant Run :
- 构造一个新的 AssetManager ,并通过反射调用
addAssetPath方法将完整的新资源包加入到 AssetManager 中,得到一个新的 AssetManager 。 - 找到所有之前引用到原有 AssetManager 的地方,通过反射把引用处替换为新的 AssetManager 。
其实绝大部分工作都在第一步,第二步的操作就很简单了。为了摒弃掉第一步的冗余工作,Sophix 团队仔细研究系统解析、加载资源的过程。发现如果直接在原有的 AssetManager 上执行 addAssetPath 添加新的资源,在 Android 5.0 之后,确实可以将新的资源加载到同一个 PackgeGroup 下面,该 Group 是根据 package id 来的,系统的资源 package id 都为 0x01 ,应用资源的 package id 都为 0x7f。但是因为在获取资源时,同一个 PackageGroup 下的资源是依次遍历,新加入的资源在该 Group 的末尾,所以并不会被获取到。在 Android 4.4 及以下版本,addAssetPath 虽然只能将新资源加载到 mAssetPath ,但资源的获取早就完成了,之后的获取会复用之前的结果,所以新的资源永远不会被读取到。
Sophix 基于此,对于补丁资源,构造了一个 package id 为 0x66 的资源包,然后利用 addAssetPath 直接将补丁资源包加载到原有的 AssetManager 中,因为是新的 package id ,所以肯定会被读取到。该补丁包的生成过程可以用一张图表示:

- 新增资源直接假如补丁包。
- 减少的资源不用考虑。
- 修改的资源视为新增资源。
有了这个新的补丁资源包,替换的时候,对于 Android 5.0 以上的版本,在原有的 AssetManager 上反射执行 addAssetPath 就完事儿了。麻烦的是 Android 5.0 及以下的,之前说了新加的资源根本不会访问到,根本原因在于 Native 层 AssetManager 的 mResource 对象只会被赋值一次。Sophix 通过 AssetManager 的源码发现一个有趣的东西:AssetManager#destroy 和 AssetManager#init方法。destroy 这个 native 方法会将 native 层 AssetManager 对象析构,并将其对 Java 层的 AssetManager 引用置空。在析构的时候,native 层的 AssetManager 对象的 mResource 自然也会被释放掉。init 这个 native 方法会在底层构建一个新的 AssetManager 对象,且它的 mResource 对象还未初始化,此时再通过 addAssetPath 加载新的所有的资源包,那么所以的资源就可以重新读取,包括补丁资源。于是,通过对底层 AssetManager 对象的析构再初始化,Java 层的引用并没有改变,同时新的资源已经成功被加载,通过这种巧妙的方式,省时、省心、省力!