深入探索Android热修复技术原理读书笔记 —— 代码热修复技术

软件发布|下载排行|最新软件

当前位置:首页IT学院IT技术

深入探索Android热修复技术原理读书笔记 —— 代码热修复技术

huansky   2021-05-08 我要评论

在前一篇文章 深入探索Android热修复技术原理读书笔记 —— 热修复技术介绍中,对热修复技术进行了介绍,下面将详细介绍其中的代码修复技术。

1 底层热替换原理

在各种 Android 热修复方案中,Andfix 的即时生效令人印象深刻,它稍显另类, 并不需要重新启动,而是在加载补丁后直接对方法进行替换就可以完成修复,然而它的使用限制也遭遇到更多的质疑。

1.1 Andfix 回顾

我们先来看一下,为何唯独 Andfix 能够做到即时生效呢?

原因是这样的,在 app 运行到一半的时候,所有需要发生变更的分类已经被加载过了,在 Android 上是无法对一个分类进行卸载的。而腾讯系的方案,都是让 Classloader 去加载新的类。如果不重启,原来的类还在虚拟机中,就无法加载新类。因此,只有在下次重启的时候,在还没走到业务逻辑之前抢先加载补丁中的新类,这样后续访问这个类时,就会 Resolve 为新的类。从而达到热修复的目的。

Andfix 采用的方法是,在已经加载了的类中直接在 native 层替换掉原有方法, 是在原来类的基础上进行修改的。对于不同 Android 版本的 art,底层 Java 对象的数据结构是不同的,因而会进一步区分不同的替换函数。每一个 Java 方法在 art 中都对应着一个 ArtMethod,ArtMethod 记录了这个 Java 方法的所有信息,包括所属类、访问权限、代码执行地址等等。

通过 env->FromReflectedMethod,可以由 Method 对象得到这个方法对应的 ArtMethod 的真正起始地址。然后就可以把它强转为 ArtMethod 指针,从而对其所有成员进行修改。 

这样全部替换完之后就完成了热修复逻辑。以后调用这个方法时就会直接走到新 方法的实现中了。

1.2 虚拟机调用方法的原理

为什么这样替换完就可以实现热修复呢?这需要从虚拟机调用方法的原理说起。

在 Android 6.0, art 虚拟机中 ArtMethod 的结构是这个样子的:

class ArtMethod FINAL {
... protected: // Field order required by test "ValidateFieldOrderOfJavaCppUnionClasses". // The class we are a part of. GcRoot<mirror::Class> declaring_class_; // Short cuts to declaring_class_->dex_cache_ member for fast compiled code access. GcRoot<mirror::PointerArray> dex_cache_resolved_methods_; // Short cuts to declaring_class_->dex_cache_ member for fast compiled code access. GcRoot<mirror::ObjectArray<mirror::Class>> dex_cache_resolved_types_; // 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_; // Fake padding field gets inserted here. // Must be the last fields in the method. // PACKED(4) is necessary for the correctness of // RoundUp(OFFSETOF_MEMBER(ArtMethod, ptr_sized_fields_), pointer_size). struct PACKED(4) PtrSizedFields { // Method dispatch from the interpreter invokes this pointer which may cause a bridge into // compiled code. void* entry_point_from_interpreter_; // Pointer to JNI function registered to this method, or a function to resolve the JNI function. void* entry_point_from_jni_; // Method dispatch from quick compiled code invokes this pointer which may cause bridging into // the interpreter. void* entry_point_from_quick_compiled_code_; } ptr_sized_fields_;
... }

这其中最重要的字段就是 entry_point_from_interprete_ 和 entry_point_ from_quick_compiled_code_ 了,从名字可以看出来,他们就是方法的执行入口。 我们知道,Java 代码在 Android 中会被编译为 Dex Code。

art 中可以采用解释模式或者 AOT 机器码模式执行。

  • 解释模式,就是取出 Dex Code,逐条解释执行就行了。如果方法的调用者是以解释模式运行的,在调用这个方法时,就会取得这个方法的 entry_point_fronn_ interpreter,然后跳转过去执行。

  • AOT方式,就会先预编译好 Dex Code 对应的机器码,然后运行期直接执行机器码就行了,不需要一条条地解释执行 Dex Code。如果方法的调用者 是以AOT机器码方式执行的,在调用这个方法时,就是跳转到 entry_point_from_ quick_compiled_code_ 执行。

那我们是不是只需要替换这几个 entry_point_* 入口地址就能够实现方法替换了呢?

并没有这么简单。在实际代码中,有许多更为复杂的调用情况。很多情况下还需要用到 dex_code_item_offset_ 等字段。由此可以看出,AOT 机器码的执行过程,还是会有对于虚拟机以及ArtMethod 其他成员字段的依赖。

因此,当把一个旧方法的所有成员字段都换成新方法后,执行时所有数据就可以 保持和新方法的一致。这样在所有执行到旧方法的地方,会取得新方法的执行入口、 所属class、方法索引号以及所属 dex 信息,然后像调用旧方法一样顺滑地执行到新 方法的逻辑。 

1.3 兼容性问题的根源

然而,目前市面上几乎所有的 native 替换方案,比如 Andfix 和其他安全界的 Hook 方案,都是写死了 ArtMethod 结构体,这会带来巨大的兼容性问题。

由于Android是开源的,各个手机厂商都可以对代码进行改造,而 Andfix 里 ArtMethod 的结构是根据公开的 Android 源码中的结构写死的。如果某个厂商对这个 ArtMethod 结构体进行了修改,就和原先开源代码里的结构不一致,那 么在这个修改过了的设备上,替换机制就会出问题。

这也正是 Andfix 不支持很多机型的原因,很大的可能,就是因为这些机型修改了底层的虚拟机结构。

1.4 突破底层结构差异

知道了 native 替换方式兼容性问题的原因,我们是否有办法寻求一种新的方式,不依赖于 ROM 底层方法结构的实现而达到替换效果呢?

我们发现,这样 native 层面替换思路,其实就是替换 ArtMethod 的所有成员。 那么,我们并不需要构造出 ArtMethod 具体的各个成员字段,只要把 ArtMethod 的作为整体进行替换,这样不就可以了吗?

也就是把原先这样的逐一替换:

 

变成了这样的整体替换:

// %%把旧函数的所有成员变量都替换为新函数的。
smeth->declaring_class_ = dmeth->declaring_class_;
smeth->dex_cache_resolved_methods_ = dmeth->dex_cache_resolved_methods_;
smeth->dex_cache_resolved_types_ = dmeth->dex_cache_reso1ved_types_; smeth->access_flags_ = dmeth->access_flags_;
smeth->dex_code_item_offset_ = dmeth->dex_code_item_offset_;
smeth->dex_method_index_ = dmeth->dex_method_index_;
smeth->method_index_ = dmeth->method_index_;

其实可以浓缩为:

memcpy(smeth, dmeth, sizeof(ArtMethod));

就是这样,一句话就能取代上面一堆代码,这正是我们深入理解替换机制的本质之后研发出的新替换方案。

但这其中最关键的地方,在于sizeof(ArtMethod)□如果sizeit算有偏差, 导致部分成员没有被替换,或者替换区域超出了边界,都会导致严重的问题。

对于ROM开发者而言,是在art源代码里面,所以一个简单的sizeof (Art- Method)就行了,因为这是在编译期就可以决定的。

但我们是上层开发者,app会被下发给各式各样的Android设备,所以我们是 需要在运行时动态地得到app所运行设备上面的底层ArtMethod大小的,这就没那 么简单了。

在 art 里面,初始化一个类的时候会给这个类的所有方法分配空间,类的方法有 direct 方法和 virtual 方法。direct 方法包含 static 方法和所有不可 继承的对象方法。而 virtual 方法就是所有可以继承的对象方法了。需要对两中类型方法都进行分配空间。

方法是一个接一个紧密地new出来排列在 ArtMethod Array  中的。这时只是分配出空间,还没填入真正的 ArtMethod 的各个 成员值:

 

正是这里给了我们启示,ArtMethod 们是紧密排列的,所以一个 ArtMethod 的大小,不就是相邻两个方法所对应的 ArtMethod 的起始地址的差值吗?

正是如此。我们就从这个排列特点入手,自己构造一个类,以一种巧妙的方式获 取到这个差值。

public class NativeStructsModel {
    final public static void fl 0 {}
    final public static void f2() {}
} 

由于 f1 和 f2 都是 static 方法,所以都属于 direct ArtMethod Array。由于 NativeStructsModel 类中只存在这两个方法,因此它们肯定是相邻的。

那么我们就可以在JNI层取得它们地址的差值:

size_t firMid = (size_t) env->GetStaticMethodID(nativeStructsModelClazzf
"fl", " ()V,r);
size_t secMid = (size_t) env->GetStaticMethodID(nativeStructsModelClazz,
uf2H, " OV");
size_t methsize = secMid - firMid;

然后,就以这个methSize作为sizeof (ArtMethod),代入之前的代码。

memcpy(smeth, dmeth, methSize);

问题就迎刃而解了。

值得一提的是,由于忽略了底层 ArtMethod 结构的差异,对于所有的 Android 版本都不再需要区分,而统一以 memcpy 实现即可,代码量大大减少。即 使以后的 Android 版本不断修改ArtMethod的成员,只要保证 ArtMethod 数组仍是以线性结构排列,就能直接适用于将来的 Android 8.0、9.0 等新版本,无需再针对新的系统版本进行适配了。

1.5 访问权限的问题

1.5.1 方法调用时的权限检查

看到这里,你可能会有疑惑:我们只是替换了 ArtMethod 的内容,但新替换的方法的所属类,和原先方法的所属类,是不同的类,被替换的方法有权限访问这个类的其他 private 方法吗?

在构造函数 调用同一个类下的私有方法func时,不会做任何权限检查。也就是说,这时即使我偷梁换柱,也能直接跳过去正常执行而不会报错。

可以推测,在 dex2oat 生成 AOT 机器码时是有做一些检查和优化的,由于在 dex2oat 编译机器码时确认了两个方法同属一个类,所以机器码中就不存在权限检查的相关代码。 

1.5.2 同包名下的权限问题

但是,并非所有方法都可以这么顺利地进行访问的。我们发现补丁中的类在访问同包名下的类时,会报出访问权限异常:

具体的校验逻辑是在虚拟机代码的 Class : : IsInSamePackage 中:

// android-6.0.I_r62/art/runtime/mirror/class.cc
bool Class::IsInSamePackage(Class* that) {
    Class* klassl = this;
    Class* klass2 = that;
    if (klassl == klass2) {
        return true;
    }
    // Class loaders must match.
    if (klassl->GetClassLoader() != klass2->GetClassLoader()) {
        return false;
    }
    // Arrays are in the same package when their element classes are.
    while (klassl->IsArrayClass0) {
        klassl = klassl->GetComponentType();
    }
    while (klass2->IsArrayClass()) {
        klass2 = klass2->GetComponentType();
    }
    // trivial check again for array types
    if (klassl == klass2) {
        return true;
    }
    // Compare the package part of the descriptor string.
    std::string tempi, temp2;
    return IslnSamePackage(klassl->GetDescriptor(&templ), klass2-
    >GetDescriptor(&temp2));
}

关键点在于,Class loaders must match 这行注释。

知道了原因就好解决了,我们只要设置新类的Classloader为原来类就可以了。 而这一步同样不需要在JNI层构造底层的结构,只需要通过反射进行设置。这样仍旧能够保证良好的兼容性。

实现代码如下:

Field classLoaderField = Class.class.getDeclaredField("classLoader"); 
classLoaderField.setAccessible(true); classLoaderField.set(newClass, oldClass.getClassLoader());

这样就解决了同包名下的访问权限问题。

1.5.3 反射调用非静态方法产生的问题

当一个非静态方法被热替换后,在反射调用这个方法时,会抛出异常。

// BaseBug. test方法已经被热替换了。
BaseBug bb = new BaseBug();
Method testMeth = BaseBug. class. getDeclaredMethod (11 test"); testMeth.invoke(bb);

invoke 的时候就会报:

Caused by: java.lang.IllegalArgumentException:
Expected receiver of type com.patch.demo.BaseBug, but got com.patch.demo.BaseBug

这里面,expected receiver 的 BaseBug,和 got 到的 BaseBug,虽然都叫 com.patch.demo.BaseBug,但却是不同的类。

前者是被热替换的方法所属的类,由于我们把它的 ArtMethod 的 declaring_class_ 替换了,因此就是新的补丁类。而后者作为被调用的实例对象 bb 的所属类, 是原有的 BaseBug。两者是不同的。

那为什么方法是非静态才有这个问题呢?因为如果是静态方法,是在类的级别直接进行调用的,就不需要接收对象实例作为参数。所以就没有这方面的检查了。

对于这种反射调用非静态方法的问题,我们会采用另一种冷启动机制对付,本文在最后会说明如何解决。

1.6 即时生效所带来的限制

除了反射的问题,像本方案以及 Andfix 这样直接在运行期修改底层结构的热修复,都存在着一个限制,那就是只能支持方法的替换。而对于补丁类里面存在方法增加和减少,以及成员字段的增加和减少的情况,都是不适用的。

原因是这样的,一旦补丁类中出现了方法的增加和减少,就会导致这个类以及整个 Dex 的方法数的变化。方法数的变化伴随着方法索引的变化,这样在访问方法时就无法正常地索引到正确的方法了。

而如果字段发生了增加和减少,和方法变化的情况一样,所有字段的索引都会发生变化。并且更严重的问题是,如果在程序运行中间某个类突然增加了一个字段,那 么对于原先已经产生的这个类的实例,它们还是原来的结构,这是无法改变的。而新 方法使用到这些老的实例对象时,访问新增字段就会产生不可预期的结果。

不过新增一个完整的、原先包里面不存在的新类是可以的,这个不受限制。

总之,只有两种情况是不适用的:

  1. 引起原有了类中发生结构变化的修改

  2. 修复了的非静态方法会被反射调用

而对于其他情况,这种方式的热修复都可以任意使用。

虽然有着一些使用限制,但一旦满足使用条件,这种热修复方式是十分出众的, 它补丁小,加载迅速,能够实时生效无需重新启动 app,并且具有着完美的设备兼容性。对于较小程度的修复再适合不过了。

2 你所不知的Java

和业界很多热修复方案不同,Sophix 热修复一直秉承粒度小、注重快捷修复、无侵入适合原生工程。因为坚持这个原则,我们在研发过程中遇到很多编译期的问题,这些问题对我们最终方案的实施和热部署也带来或多或少地影响,令人印象深刻。

本节列举了我们在项目实战中遇到的一些挑战,这些都是 Java 语言在编译实现上的一些特点,虽然这些特点与热修复没有直接关系,但深入研究它们对 Android 及 Java 语言的理解都颇有脾益。 

2.1 内部类编译

有时候我们会发现,修改外部类某个方法逻辑为访问内部类的某个方法时,最后打出来的补丁包竟然提示新增了一个方法,这真的很匪夷所思。所有我们有必要了解 下内部类在编译期间是怎么编译的,首先我们要知道内部类会在编译期会被编译为跟 外部类一样的顶级类。

2.1.1 静态内部类/非静态内部类区别

静态内部类/非静态内部类的区别大家应该都很熟悉,非静态内部类持有外部类的引用,静态内部类不持有外部类的引用。所以在android性能优化中建议handle 的实现尽量使用静态内部类,防止外部类Activity类不能被回收导致可能 OOM。非静态内部类,编译期间会自动合成 this$0 域表示的就是外部类的引用。

内部类和外部类互相访问

既然内部类实际上跟外部类一样都是顶级类,既然都是顶级类,那是不是意味着对方 private 的 method/field 是没法被访问得到的,事实上外部类为了访问内部类私有的域/方法,编译期间自动会为内部类生成 access&** 相关方法

此时外部类 BaseBug 为了能访问到内部类 InnerClass 的私有域 s,所以编译 器自动为 InnerClass 这个内部类合成 access&100 方法,这个方法的实现简单返 回私有域s的值。同样的如果此时匿名内部类需要访问外部类的 private 属性/方法, 那么外部类也会自动生成 access&** 相关方法提供给内部类访问使用。

2.1.2 热部署解决方案

所以有这样一种场景,patch 前的 test 方法没访问 inner.s, patch 后的 test 方法访问了 inner.s,那么补丁工具最后检测到了新增了 access&ioo 方法。那么我们 只要防止生成 access&** 相关方法,就能走热部署,也就是底层替换方式热修复。 

所以只要满足以下条件,就能避免编译器自动生成 access&** 相关方法

  • 一个外部类如果有内部类,把所有 method/field 的 private 访问权限改成 protected 或者默认访问权限或 public。

  • 同时把内部类的所有 method/field 的 private 访问权限改成 protected 或者默认访问权限或 public。 

2.2匿名内部类编译

匿名内部类其实也是个内部类,所以自然也有上一小节说明情况的影响,但是我 们发现新增一个匿名类(补丁热部署模式下是允许新增类),同时规避上一节的情况, 但是匪夷所思的还是提示了 method 的新增,所以接下来看下匿名内部类跟非匿名内 部类相比,又有怎么样的特殊性。

2.2.1 匿名内部类编译命名规则

匿名内部类顾名思义就是没名字的。匿名内部类的名称格式一般是外部类 &numble,后面的 numble,编译期根据该匿名内部类在外部类中出现的先后关系, 依次剛命名。一旦新增或者减少内部类会导致名字与方法含义出现乱套的情况。

2.2.2 热部署解决方案

新增/减少匿名内部类,实际上对于热部署来说是无解的,因为补丁工具拿到的 已经是编译后的 .class 文件,所以根本没法去区分 DexFixDemo&1/DexFixDemo&2 类。所以这种情况下,如果有补丁热部署的需求,应该极力避免插入一个新的匿名内部类。当然如果是匿名内部类是插入到外部类的末尾,那么是允许的。 

2.3 有趣的域编译

2.3.1 静态field,非静态field编译

实际上在热部署方案中除了不支持 method/fleld 的新增,同时也是不支持 <ciinit>的修复,这个方法会在 Dalvik 虚拟机中类加载的时候进行类初始化时候调 用。在 java 源码中本身并没有 clinit 这个方法,这个方法是 android 编译器自动合成的 方法。通过测试发现,静态field的初始化和静态代码块实际上就会被编译器编译在 <ciinit>这个方法,所以我们有必要去了解一下 field/代码块到底是怎么编译的。这块内容其实在 Java 类加载机制详解 一文中也有详细介绍。

来看个简单的示例。

 public class DexFixDemo {
        {
            i = 2;
        }
        private int i = 1;
        private static int j = 1;
        static {
            j = 2;
        }
    }

反编译为smali看下

2.3.2 静态field初始化,静态代码块

上面的示例中,能够很明显静态 field 初始化和静态代码块被编译器翻译在 <clinit>方法中。静态代码块和静态域初始化在 clinit 中的先后关系就是两者出现在源码中的先后关系,所以上述示例中最后 j==2 。前面说过,类加载然后进行类初始化的时候,会去调用 clinit 方法,一个类仅加载一次。以下三种情况都会尝试去 加载一个类:

  1. new —个类的对象(new-instance 指令)

  2. 调用类的静态方法(invoke-static 指令)

  3. 获取类的静态域的值(sget 指令)

首先判断这个类有没有被加载过,如果没有加载过,执行的流程 dvniResolve- Class - >dvmLinkClass- >dvmInitClass,类的初始化时在 dvmlnitClass。dvmlnitClass 这个函数首先会尝试会对父类进行初始化,然后调用本类的 clinit 方法,所以此时静态field得到初始化和静态代码块得到执行。

2.3.3 非静态field初始化,非静态代码块

上面的示例中,能够很明显的看到非静态field初始化和非静态代码块被编译器翻 译在<init>默认无参构造函数中。非静态field和非静态代码块在init方法中的先后顺 序也跟两者在源码中出现的顺序一致,所以上述示例中最后 i==1。实际上如果存在有参构造函数,那么每个有参构造函数都会执行一个非静态域的初始化和非静态代码块。

构造函数会被android编译器自动翻译成<init>方法 

前面介绍过clinit方法在类加载初始化的时候被调用,那么 <init> 构造函数方 法肯定是对类对象进行初始化时候被调用的,简单来说 new —个对象就会对这个对象进行初始化,并调用这个对象相应的构造函数,看下这行代码 String s = new String ("test");编译之后的样子。 

首先执行 new-instance 指令,主要为对象分配堆内存,同时如果类如果之前没加载过,尝试加载类。然后执行 invoke-direct 指令调用类的 init 构造函数方法执行对象的初始化。

2.3.4 热部署解决方案

由于我们不支持<clinit>方法的热部署,所以任何静态field初始化和静态代码块的变更都会被翻译到 clinit 方法中,导致最后热部署失败,只能冷启动生效。如上所见,非静态 field 和非静态代码块的变更被翻译到<init>构造函数中,热部署 模式下只是视为一个普通方法的变更,此时对热部署是没有影响的。

2.4 final static 域编译

final static 域首先是一个静态域,所以我们自然认为由于会被翻译到 clinit 方法中,所以自然热部署下面也是不能变更。但是测试发现,final static修饰的基 本类型/String常量类型,匪夷所思的竟然没有被翻译到 clinit 方法中,见以下分析。

2.4.1 final static域编译规则

final static 静态常量域。看下 final static 域被编译后的样子。

看下反编译得到的smali文件 

 

我们发现,final static int 12 = 2 和 final static String s2 = "haha" 这两个静态域竟然没在中被初始化。其它的非final静态域均在clinit函数中得到初始化。这里注意下 "haha" 和 new String ("heihei") 的区别,前者是字符串常量,后者是引用类型。那这两个final static域(i2和s2)究竟在何处得到初始化?

事实上,类加载初始化 dvmlnitClass 在执行 clinit 方法之前,首先会先执行 initSFieids,这个方法的作用主要就是给static域赋予默认值。如果是引用类型, 那么默认初始值为NULL。0101 Editor工具查看 dex 文件结构,我们能看到在 dex 的类定义区,每个类下面都有这么一段数据,图中 encoded_array_item。

上述代码示例中,那块区域有4个默认初始值,分别是 t1 = =NULL, t2==NULL, s1==NULL, s2=="haha", i1==0, i2 = =2。 其中 t1/t2/s2/i1 在 initSFields 中首先赋值了默认初始化值,然后在随后的 clinit 中赋值了程序设置的值。而 i2/s2 在 initSFields 得到的默认值就是程序中设置的值。

现在我们知道了 static 和 final static 修饰 field 的区别了。简单来说:

  • final static 修饰的原始类型和 String 类型域(非引用类型),在并不会被翻译在 clinit 方法中,而是在类初始化执行 initSFields 方法时号到了初始化赋值。

  • final static 修饰的弓I用类型,初始化仍然在 clinit 方法中;

2.4.2 final static域优化原理

另外一方面,我们经常会看到android性能优化相关文档中有说过,如果一个 field是常量,那么推荐尽量使用static final作为修饰符。很明显这句话不大 对,得到优化的仅仅是final static原始类型和String类型域(非引用类型), 如果是引用类型,实际上不会得到任何优化的。

2.4.3 热部署解决方案

所有我们可以得到最后的结论:

  • 修改 final static 基本类型或者 String 类型域(非引用类型)域,由于编译期 间引用到基本类型的地方被立即数替换,引用到String类型(非引用类型) 的地方被常量池索引id替换,所以在热部署模式下,最终所有引用到该 final static 域的方法都会被替换。实际上此时仍然可以走热部署。

  • •修改 final static 引用类型域,是不允许的,因为这个 field 的初始化会被翻译到clinit方法中,所以此时没法走热部署。

2.5 有趣的方法编译

2.5.1 应用混淆方法编译

除了以上的内部类/匿名内部类可能会造成method新增之后,我们发现项目如 果应用了混淆,由于可能导致方法的内联和裁剪,那么最后也可能导致method的新 增/减少,以下介绍哪些场景会造成方法的内联和裁剪。

2.5.2 方法内联

实际上有好几种情况可能导致方法被内联掉。

  1. 方法没有被其它任何地方引用到,毫无疑问,该方法会被内联掉

  2. 方法足够简单,比如一个方法的实现就只有一行,该方法会被内联掉,那么 任何调用该方法的地方都会被该方法的实现替换掉

  3. 方法只被一个地方引用到,这个地方会被方法的实现替换掉。

举个简单的例子进行说明下。

此时假如print方法足够复杂,同时只在 test 方法中被调用,假设 test 方法没被内联,print 方法由于只有一个地方调用此时 print 方法会被内联。

如果恰好将要 patch 的一方法调用了 print方法,那么print被调用了两次, 在新的apk中不会被内联,补丁工具检测到新增了 print 方法。那么该补丁只能走冷 启动方案。

2.5.3 方法裁剪

查看下生成的mapping.txt文件

com.taobao.hotfix.demo.BaseBug -> com.taobao.hotfix.demo.a:

  void test$faab20d() -> a

此时test方法context参数没被使用,所以test方法的context参数被裁剪, 混淆任务首先生成test$faab20d()裁剪过后的无参方法,然后再混淆。所以如果 将要patch该test方法,同时恰好用到了 context参数,那么test方法的context 参数不会被裁剪,那么补丁工具检测到新增了 test (context)方法。那么该补丁只 能走冷启动方案。

怎么让该参数不被裁剪,当然是有办法的,参数引用住,不让编译器在优化的 时候认为这是一个无用的参数就好了,可以采取的方法很多,这里介绍一种最有效 的方法:

 

注意这里不能用基本类型false,必须用包装类Boolean,因为如果写基本类型 这个if语句也很可能会被优化掉的。

2.5.4 热部署解期案

实际上只要混淆配置文件加上-dontoptimize 这项就不会去做方法的裁剪和内联。一般情况下项目的混淆配置都会使用到 android sdk 默认的混淆配置文件 proguard-android-optimize. txt 或者 proguard- android. txt, 两者的区别就是后者应用了 -dontoptimize 这一项配置而前者没应用。

2.6 switch case 语句编译

由于在实现资源修复方案热部署的过程中要做新旧资源 id 的替换,我们发现竟然存在 switch case 语句中的 id 不会。

所以有必要来探索下switch case语句编译的特殊性。

 

看下 testContinue/testNotContinue 方法编译出来有何不同。

 

testNotContinue 方法的 switch case 语句被翻译成 sparse-switch 指令。 比较下差异 testContinue的switch 语句的case项是连续的几个值比较相近的值1,3,5。所以被编译期翻译为 packed-switch 指令,可以看到对这几个连续的数中间的差值用 :pswitch_0 补齐,:pswitch_0 标签处直接 retum-void。testNotContinue 的 switch 语句的 case 项分别是1,3,10,很明显不够连续,所以 被编译期翻译为 sparse-switch 指令。怎么才算连续的case值这个是由编译器来决定的。 

2.6.1 热部署解决方案

—个资源 id 肯定是const final static变量,此时恰好 switch case语句 被翻译成 packed-switch 指令,所以这个时候如果不做任何处理就存在资源id替换 不完全的情况。解决方案其实很简单暴力,修改smali反编译流程,碰到packed- switch 指令强转为sparse-switch指令,:pswitch_N 等相关标签指令也需 要强转为 :sswitch_N 指令。然后做资源id的暴力替换,然后再回编译 smali 为dex。再做类方法变更的检测,所以就需要经过反编译 -> 资源 id 替换 -> 回编译的过程,这也会使得打补丁变得稍慢一些。

2.7 泛型编译

泛型是 java5 才开始引入的,我们发现泛型的使用,也可能导致 method 的新增,所以是时候深入了解一下泛型的编译过程了。

为什么需要泛型?

  • Java语言中的泛型基本上完全在编译器中实现,由编译器执行类型检查和类 型推断,然后生成普通的非泛型的字节码,就是虚拟机完全无感知泛型的存在。这种实现技术称为擦除 (erasure) 编译器使用泛型类型信息保证类型安 全,然后在生成字节码之前将其清除。

  • Java5才引入泛型,所以扩展虚拟机指令集来支持泛型被认为是无法接受的, 因为这会为 Java 厂商升级其JVM造成难以逾越的障碍。因此采用了可以完 全在编译器中实现的擦除方法。

2.7.1 类型擦除与多态的冲突和解决

子类中真正重写基类方法的是编译器自动合成的bridge方法。而类 B 定义的get和set方法上面的 @Override 只不过是假象,bridge方法的内部实 现去调用我们自己重写的print方法而已。所以,虚拟机巧妙使用了桥方法的方式,来解决了类型擦除和多态的冲突

这里或许也许会有疑问,类B中的字节码中的方法 get () Ljava/lang/Nuniber ; 和 get () Ljava/lang/Object;是同时存在的,这就颠覆了我们的认知,如果是我 们自己编写Java源代码,这样的代码是无法通过编译器的检查的,方法的重载只能 以方法参数而无法以返回类型别作为函数重载的区分标准,但是虚拟机却是允许这样做的,因为虚拟机通过参数类型和返回类型共同来确定一个方法,所以编译器为了实 现泛型的多态允许自己做这个看起来“不合法”的事情,然后交给虚拟器自己去区别 处理了。

2.7.2 泛型类型转换

同时前面我们还留了一个坑,泛型是可以不需要强制类型转换。

 

代码示例中,第一个不需要强制类型转换,但是第二个必须强制类型转换否则编译期报incovertiable types错误。反编译看下smali:

字节码文件很意外,两者其实并没有什么区别,实际上编译期间,编译器发现如 果有一个变量的申明加上了泛型类型的话,编译器自动加上check-cast类型转换, 而不需要程序员在源码文件中进行强制类型转换,这里不需要并不意味着不会类型转换,可以发现其实只是类型转换编译器自动帮我们完成了而已。

2.7.3 热部署解决方案

前面类型擦除中说过,如果由 B extends A 变成了 B extends A<Number>, 那么就可能会新增对应的桥方法。此时新增方法了,只能走冷部署了。这种情况下, 如果要走热部署,应该避免类似上面那种的修复。

另外一方面,实际上泛型方法内部会生成一个 dalvik/annotation/Signa- ture 这个系统注解

2.8 Lambda表达式编译

Lambda 表达式是 java7 才引入的一种表达式,类似于匿名内部类实际上又与 匿名内部类有很大的区别,我们发现 Lambda 表达式的使用也可能导致方法的新增/减少,导致最后走不了热部署模式。所以是时候深入了解一下 Lambda 表达式的编 译过程了。

2.8.1 Lambda表达式编译规则

首先简单介绍下 lambda 表达式,lambda 为 Java 添加了缺失的函数式编程 特点,Java现在提供的最接近闭包的概念便是 Lambda 表达式。gradle 就是基于 groovy 存在大量闭包。函数式接口具有两个主要特征,是一个接口,这个接口具有唯一的一个抽象方法,我们将满足这两个特性的接口称为函数式接口。比如 Java 标准库中的 java.lang.Runnable 和 java.util.Comparator 都是典型的函数式 接口。跟匿名内部类的区别如下:

  • 关键字 this 匿名类的this关键字指向匿名类,而lambda表达式的this关键 字指向包围lambda表达式的类。

  • 编译方式,Java编译器将lambda表达式编译成类的私有方法,使用了 Java7 的 invokedynamic 字节码指令来动态绑定这个方法。Java 编译器将匿名内部类编译成外部类&numble的新类。

dex字节码文件和.class字节码文件对lambda表达式处理的 异同点。

  • 共同点:辐译期间都会为外部类合成一个static辅助方法,该方法内部逻辑 实现lambda表达式。

  • 不同点:1 .class字节码中通过 invokedynamic 指令执行lambda表达式。而.dex字节码中执行lambda表达式跟普通方法调用没有任何区别。2 .class字节码中运行时生成新类。.dex字节码中编译期间生成新类。

2.8.2 热部署解决方案

有了以上知识点做基础,同时我们知道我们打补丁是通过反编译为 smali 然后新 apk 跟基线 apk 进行差异对比,得到最后的补丁包。所以首先:

新增一个lambda表达式,会导致外部类新增一个辅助方法,所以此时不支 持走热部署方案,还有另外一方面,可以看下合成类的命名规则 Test$$Lamb-da$-void_main_j ava_lang_String args_LambdaImpl0.smali:外部类名 + Lambda + Lambda 表达式所在方法的签名 + Lambdalmpl + 出现的顺序号。构成这个合成类。所以此时如果不是在末尾插入了一个新的Lambda 表达式,那么就会导 致跟前面说明匿名内部类一样的问题,会导致类方法比较乱套。减少一个lambda表 达式热部署情况下也是不允许的,也会导致类方法比较乱套。

那么如果只是修改 lambda 表达式内部的逻辑,此时看起来仅仅相当于修改了一 个方法,所以此时是看起来是允许走热部署的。事实上并非如此。我们忽略了一种情 况,lambda表达式访问外部类非静态 field/method 的场景。

前面我们知道 .dex 字节码中 lambda 表达式在编译期间会自动生成新的辅助类。 注意该辅助类是非静态的,所以该辅助类如果为了访问 “外部类” 的非静态field/ method就必须持有"外部类"的引用。如果该辅助类没有访问"外部类”的非静态 field/method,那么就不会持有"外部类"的引用。这里注意这个辅助类和内部类 的区别。我们前面说过如果是非static内部类的话一定会持有外部类的引用的!

2.9 访问权限检查对热替换的影响

访问权限的问题中有提到权限问题对于底层热替换的影响,下面我们就来深入剖析虚拟机下权限控制可能给我们的热修复方案带来的影响,下面代码示例仅演 示Dalvik虚拟机。

2.9.1 类加载阶段父类/实现接口访问权限检查

如果当前类和实现接口 /父类是非 public,同时负责加载两者的 classLoader 不一样的情况下,直接 return false。所以如果此时不进行任何处理的 话,那么在类加载阶段就报错。我们当前的代码热修复方案是基于新 classLoader 加载补丁类,所以在patch的过程中就会报类似如下的错误。

2.9.2 类校验阶段访问权限检查

如果补丁类中存在非 public 类的访问/非 public 方法/域的调用,那么都会导致失败。更为致命的是,在补丁加载阶段是检测不出来的,补丁会被视为正常加载,但是在运行阶 段会直接crash异常退出。

2.10 <clinit>方法

由于补丁热部署模式下的特殊性一不允许类结构变更以及不允许变更 <clinit> 方法,所以我们的补丁工具如果发现了这几种限制情况,那么此时只能走冷启动重启 生效,冷启动几乎是无任何限制的,可以做到任何场景的修复。可能有时候在源码层 面上来看并没有新增/减少 method 和 field,但是实际上由于要满足 Java 各种语法 特性的需求,所以编译器会在编译期间为我们自动合成一些 method 和 field,最后 就有可能触发了这几个限制情况。以上列举的情况可能并不完全详细,这些分析也只是一个抛砖引玉的作用,具体情况还需要具体分析,同时一些难以理解的 java 语法 特性或许从编译的角度去分析可能就无处遁形了。

3 冷启动类加载原理

前面我们提到热部署修复方案有诸多特点(有关热部署修复方案实现。其根本原 理是基于 native 层方法的替换,所以当类结构变化时,如新增减少类 method/field 在热部署模式下会受到限制。但冷部署能突破这种约束,可以更好地达到修复目的,再加上冷部署在稳定性上具有的独特优势,因此可以作为热部署的有力补充而存在。 

3.1 冷启动实现方案概述

冷启动重启生效,现在一般有以下两种实现方案,同时给出他们各自的优缺点:

上面的表格,我们能清晰的看到两个方案的缺点都很明显。这里对 tinker 方案

dex merge 缺陷进行简单说明一下:

dex merge 操作是在 java 层面进行,所有对象的分配都是在 java heap 上, 如果此时进程申请的java heap对象超过了 vm heap 规定的大小,那么进程发生 OOM,那么系统 memory killer 可能会杀掉该进程,导致 dex 合成失败。另外一方 面我们知道 jni 层面 C++ new/malloc 申请的内存,分配在native heap, native heap 的增长并不受 vm heap 大小的限制,只受限于RAM,如果 RAM 不足那么进 程也会被杀死导致闪退。所以如果只是从 dexmerge 方面思考,在jni层面进行dex merge,从而可以避免 OOM 提高 dex 合并的成功率。理论上当然可以,只是jni层 实现起来比较复杂而已

3.2 类校验

apk 第一次安装的时候,会对原 dex 执行 dexopt,此时假如 apk只存在一个 dex,所以 dvmVerifyClass(clazz) 结果为 true。所以 apk 中所有的类都会被打上 class_ispreverifIed 标志,接下来执行dvmOptimizeClass,类接着被打上 CLASS_ISOPTIMIZED 标志。

  • dvmVerifyClass:类校验,类校验的目的简单来说就是为了防止类被篡改校 验类的合法性。此时会对类的每个方法进行校验,这里我们只需要知道如果 类的所有方法中直接引用到的类(第一层级关系,不会进行递归搜索)和当前 类都在同一个dex中的话,dvmVerifyClass 就返回 true。

  • dvmOptimizeClass:类优化,简单来说这个过程会把部分指令优化成虚拟机 内咅B指令,比如方法调用指令:invoke-* 指令变成了 invoke-*-quick, quick指令会从类的vtable表中直接取,vtable简单来说就是类的所有方法 的一张大表(包括继承自父类的方法)o因此加快了方法的执行速率。

3.3 Art下冷启动实现

前面说过补丁热部署模式下是一个完整的类,补丁的粒度是类。现在我们的需 求是补丁既能走热部署模式也能走冷启动模式,为了减少补丁包的大小,并没有为 热部署和冷启动分别准备一套补丁,而是同一个热部署模式下的补丁能够降级直接 走冷启动,所以我们不需要做dex merge。但是前面我们知道为了解决Art下类地 址写死的问题,tinker通过dex merge成一^全新完整的新dex整个替换掉旧的 dexElements数组。事实上我们并不需要这样做,Art虚拟机下面默认已经支持多 dex压缩文件的加载了。

需要注意一点:

  • 补丁 dex 必须命名为 classes.dex

  • loadDex 得到的 DexFile 完整替换掉 dexElements 数组而不是插入

3.4 不得不说的其它点

我们知道DexFile.loadDex尝试把一个dex文件解析并加载到native内存, 在加载到native内存之前,如果dex不存在对应的odex,那么Dalvik下会执行 dexopt, Art 会执行 dexoat,最后得到的都是一个优化后的odex。实际上最后虚 拟机执行的是这个 odex而不是dex。

现在有这么一个问题,如果dex足够大那么dexopt/dexoat实际上是很耗时的, 根据上面我们提到的方案,Dalvik下实际上影响比较小,因为loadDex仅仅是补丁包。 但是Art下影响是非常大的,因为loadDex是补丁 dex和apk中原dex合并成的一个 完整补丁压缩包,所以dexoat非常耗时。所以如果优化后的odex文件没生成或者没 生成一个完整的odex文件,那么loadDex便不能在应用启动的时候进行的,因为会 阻塞loadDex线程,一般是主线程。所以为了解决这个问题,我们把loadDex当做 一个事务来看,如果中途被打断,那么就删除。dex文件,重启的时候如果发现存在 odex文件,loadDex完之后,反射注入/替换dexElements数组,实现patch。 如果不存在。dex文件,那么重启另一个子线程loadDex,重启之后再生效。

另外一方面为了 patch补丁的安全性,虽然对补丁包进行签名校验,这个时候能 够防止整个补丁包被篡改,但是实际上因为虚拟机执行的是odex而不是dex,还需 要对odex文件进行md5完整性校验,如果匹配,则直接加载。不匹配,则重新生成 —遍 odex 文件,防止 odex 文件被篡改。 

3.5 完整的方案考虑

代码修复冷启动方案由于它的高兼容性,几乎可以修复任何代码修复的场景,但 是注入前被加载的类(比如 Application 类)肯定是不能被修复的。所以我们把它作 为一个兜底的方案,在没法走热部署或者热部署失败的情况,最后都会走代码冷启动 重启生效,所以我们的补丁是同一套的。具体实施方案对 Dalvik 下和 Art 下分别做了处理:

  • Dalvik下采用我们自行研发的全量DEX方案

  • Art 下本质上虚拟机已经支持多dex的加载,我们要做的仅仅是把补丁 dex 作为主 dex(classes.dex) 加载而已。

4 多态对冷启动类加载的影响

前面我们知道冷启动方案几乎是可以修复任何场景的,但 Dalvik 下 QFix 方案存在很大的限制,下面将深入介绍下目前方案下为什么会有这些限制,同时给出具体的 解决方案。

4.1 重新认识多态

实现多态的技术一般叫做动态绑定,是指在执行期间判断所引用对象的实际类 型,根据其实际的类型调用其相应的方法。多态一般指的是非静态非 private 方法的多态。field 和静态方法不具有多态性。

子类 vtable 的大小等于子类 virtual 方法数+父类vtable的大小。

  • 整个复制父类 vtable 到子类的 vtable

  • 遍历子类的 virtual 方法集合,如果方法原型一致,说明是重写父类方法,那么相同索引位置处,子类重写方法覆盖掉 vtable 中父类的方法

  • 方法原型不一致,那么把该方法添加到vtable的末尾

4.2 冷启动方案限制

dex文件第一次加载的时候,会执行dexopt, dexopt 有两个过程:verify+optimize。

  • dvmVerifyClass:类校验,类校验的目的简单来说就是为了防止类被篡改校 验类的合法性。此时会对类的每个方法进行校验,这里我们只需要知道如果 类的所有方法中直接引用到的类(第一层级关系,不会进行递归搜索)和当前 类都在同一个dex中的话,dvmVerifyClass就返回true。

  • dvmOptimizeClass:类优化,简单来说这个过程会把部分指令优化成虚拟机 内部指令,比如方法调用指令:invoke-virtual-quick, quick 指令会从类的 vtable 表中直接取,vtable 简单来说就是类的所有方法的一张大表(包括继 承自父类的方法)。因此加快了方法的执行速率。

所以,如果在补丁类中新增新的方法有可能会导致方法调用错乱。

5 Dalvik下完整DEX方案的新探索

5.1 一种新的全量Dex方案

一般来说,合成完整dex,思路就是把原来的 dex 和 patch 里的 dex 重新合并 成一个。然而我们的思路是反过来的。

我们可以这样考虑,既然补丁中已经有变动的类了,那只要在原先基线包里的 dex 里面,去掉补丁中也有的 class。这样,补丁+去除了补丁类的基线包,不就等于了新app中的所有类了吗?

参照 Android 原生 multi-dex 的实现再来看这个方案,会很好理解。multi-dex 是把 apk 里用到的所有类拆分到 classes.dex、classes2 .dex、classes3.dex、...之中,而每个dex都只包含了部分的类的定义,但单个 dex 也是可以加载的,因为只要把所有 dex 都 load 进去,本 dex 中不存在的类就可以在运行期间 在其他的dex中找到。

因此同理,在基线包 dex 里面在去掉了补丁中 class 后,原先需要发生变更的旧的class就被消除了,基线包dex里就只包含不变的class。而这些不变的class 要用到补丁中的新class时会自动地找到补丁dex,补丁包中的新class在需要用到 不变的 class 时也会找到基线包dex的class。这样的话,基线包里面不使用补丁类的 class仍旧可以按原来的逻辑做odex,最大地保证了 dexopt的效果。

这么一来,我们不再需要像传统合成的思路那样判断类的增加和修改情况,而且也不需要处理合成时方法数超过的情况,对于dex的结构也不用进行破坏性重构。

现在,合成完整 dex 的问题就简化为了一如何在基线包dex里面去掉补丁包 中包含的所有类。接下来我们看一下在 dex 中去除指定类的具体实现。

需要注意的是,我们并不是要把某个Class的所有信息都从dex移除,因为如 果这么做,可能会导致dex的各个部分都发生变化,从而需要大量调整offset,这样就变得就费时费力了。我们要做的,仅仅是让在解析这个 dex 的时候找不到这个 Class 的定义就行了。因此,只需要移除定义的入口,对于 class 的具体内容不进 行删除,这样可以最大可能地减少 offset 的修改。

我们只是去除了类的定义,而对于类的方法实体以及其他dex信息不做移除, 虽然这样会把这个被移除类的无用信息残留在dex文件中,但这些信息占不了太多空 间,并且对 dex 的处理速度是提升很大的,这种移除类操作的方式就变得十分轻快。

5.2 对于 Application 的处理

由此,我们实现了完整的dex合成。但仍然有个问题,这个问题所有完整 dex 替换方案都会遇到,那就是对于 Application 的处理。

众所周知,Application 是整个 app 的入口,因此,在进入到替换的完整 dex 之前,一定会通过Application的代码,因此,Application必然是加载在原来的老 dex里面的。只有在补丁加载后使用的类,会在新的完整dex里面找到。

因此,在加载补丁后,如果 Application 类使用其他在新dex里的类,由于不在 同一个dex戛 如果Application被打上了 pre-verified标志,这时就会抛出异常

在 Application 类初始化的时候。此时补丁还 没进行加载,所以就会提前加载到原始dex中的类。接下来当补丁加载完毕后,这些 已经加载的类如果用到了新 dex 中的类,并且又是 pre-verified 时就会报错。

这里最大的问题在于,我们无法把补丁加载提前到 dvmOptResolveClass 之前,因为在一个 app 的生命周期里,没有可能到达比入口 Application 初始化更早的 时期了。

而这个问题常见于多dex情形,当存在多dex时,无法保证 Application 的用到的类和它处于同个 dex 中。如果只有一个 dex,—般就不会有这个问题。

多dex情况下要想解决这个问题,有两种办法:

  • 第一种办法,让Application用到的所有非系统类都和Application位 于同一个dex里,这就可以保证pre-verified标志被打上,避免进入 dvmOptResolveClass,而在补丁加载完之后,我们再清除 pre-verified 标志,使得接下来使用其他类也不会报错。

  • 第二种办法,把Application里面除了热修复框架代码以外的其他代码都剥离开,单独提出放到一个其他类里面,这样使得Application不会直接用到 过多非系统类,这样,保证这个单独拿出来的类和 Application 处于同一个 dex的几率还是比较大的。如果想要更保险,Application可以采用反射方式 访问这个单独类,这样就彻底扌巴Application和其他类隔绝开了。

第一种方法实现较为简单,因为 Android 官方 multi-dex 机制会自动将 Application 用到的类都打包到主 dex 中,因此只要把热修复初始化放在 attachBaseContext 的最前面,大多都没问题。而第二种方法稍加繁琐,是在代码架构层面进行重新设计,不过可以一劳永逸地解决问题。 

Copyright 2022 版权所有 软件发布 访问手机版

声明:所有软件和文章来自软件开发商或者作者 如有异议 请与本站联系 联系我们