0%

第2章-热替换代码修复

底层热替换的原理

App 启动到一半的时候,所有需要发生变更的类已经被加载过了,Android 中无法对一个分类进行卸载的。腾讯系的方案是让 ClassLoader 去加载新类,因此只有在下次 App 重启的时候还没运行到业务逻辑之前抢先加载补丁中的新类

阿里系的 Andfix 采用的方法是直接在 Native 层替换了已经加载的类的方法。原理是:每一个 Java 方法在 Art 虚拟机中都对应一个 ArtMethod ,ArtMethod 记录了这个 Java 方法的所有信息,包括所属类、访问权限、代码执行地址等,可以获取 ArtMethod 的起始地址,然后强制转化为 ArtMethod 指针,从而对其包含的所有成员进行修改

但是,上述做法会导致兼容性问题,因为Android 是开源的,所以手机厂商可能对 ArtMethod 的结构体进行修改,举个例子:

在 Andfix 中替换 declaring_class_ 的地方:

smeth->declaring_class_ = dmeth->declaring_class_

由于 declaring_class_ 是 官方 ArtMethod 的第一个成员,因此它和以下代码等价:

(unit32_t) (smeth + 0) = (uit32_t) (dmeth + 0)

但如果手机厂商在 ArtMethod 的结构体 declaring_class_ 前面添加了 additional_ 字段,那么 additional_ 就成了第一个成员,所以 smeth + 0 在这台设备上实际就成了 additional_,代码含义就变化了。

突破底层差异

起始,Native 层面替换思路,其实就是替换 ArtMethod 的所有成员。那么,如果不构造 ArtMethod 中的各个成员字段,只要把 ArtMethod 作为整体替换,这不就可以了吗?示意图如下:

ArtMethod整体替换对比

所以,一系列繁琐替换:

繁琐替换

都可以缩写为:

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

即使手机厂商把 ArtMethod 改得面目全非都无所谓,但是 sizeof(ArtMethod) 获取 ArtMethod 的 size 大小不容易,很容易导致替换区域超出边界或者没替换到

由于 ArtMethod 存在一个 ArtMethodArray 中,并且多个 ArtMethod 紧密排列,所以一个 ArtMethod 的大小刚好就是两个相邻 ArtMethod 的起始地址的差值,就这样巧妙获取到了 ArtMethod 的大小。所以,后续不管 ArtMethod 结构怎么变化,只要他们在 ArtMethodArray 中还是以线性排列,就能直接适配。

访问权限问题

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

需要注意的是,在构造函数调用同一个类的私有方法 func 时,没有做任何权限检查,也就是说,这时即使把 func 方法偷梁换柱,也能直接跳过去正常执行而不报错。可以推测在dex2oat 生成 AOT 代码的时候是有做一些检查和优化了,由于在 dex2oat 编译机器码时确认了两个方法同属一个类,所以机器码中就不存在权限检查。

相同包名的问题

但是,你会发现补丁中的类在访问同包名下的类时,会爆出访问权限异常。这个问题是如何产生的呢?

虽然 com.path.demo.BaseBug 和 com.path.demo.MyClass 是同一个包 com.path.demo 下的,但是由于我们替换了 com.path.demo.BaseBug.test 方法,而用于替换的 BaseBug.test 方法是从补丁包 Classloader 中加载的,与原有的 base 包就不是同一个 Classloader 了,就这样,导致两个类无法被判别为同包名。

解决办法就是设置新类的 Classloader 为原来类的就可以了,代码如下:

1
2
3
Field classloaderField = Class.class.getDeclaredField("classLoader");
classloaderField.setAccessible(true);
classloaderField.set(newClass, oldClass.getClassLoader());

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

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

反射非静态方法抛出异常

这里面,expected receiver 的 BaseBug 和 got 的 BaseBug 不是同一个类,虽然路径名字都一样,前者是被热替换的方法所属的类,后者是作为被调用的实例对象 bb 所属的类,是原有的 BaseBug,二者是不同的。因为在执行 invoke 的时候会检查调用对象是否是类的一个实例,只有是它的实例才能调用。具体的解决办法我们采用另一种冷启动机制应对

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

即时生效带来的限制

这种直接在运行时修改底层结构的热修复方案,都存在一个限制:只能支持方法的替换,新增/减少方法或者增加/减少成员字段,都是不适用的。因为一旦补丁类中出现了方法的增加或者减少,就会导致这个类以及整个 Dex 的方法数变化,方法数的变化会伴随方法索引的变化,这样,在访问方法时无法正常地索引正确的方法。

字段的变化也是一样的,所有字段的索引都会发生变化。更严重的是,如果程序运行中某个类突然增加了一个字段,那么对于原有这个类的实例,他们还是原来的结构,这是无法改变的;而新方法使用这些旧的实例对象时,访问新增字段就会产生不可预期的结果。不过,新增一个完整的、原有包里面不存在的新类是可以的

编译期与语言特性的影响

内部类编译

在修改外部类的某个方法时,最后打出的补丁竟然提示新增了一个方法!所以有必要了解内部类在编译期是怎么工作的,首先要知道内部类在编译期间会被编译为外部类一样的顶级类

内部类和外部类互相访问

既然内部类实际上和外部类一样都是顶级类,那应该私有的 method/field 是无法被访问到的,事实上外部类为了访问内部类私有 域/方法 ,编译期间会为内部类自动生成 access$ 数字编号相关方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class BaseBug {
public void test(Context context) {
InnerClass inner = new InnerClass("old apk");
Toast.makeText(context, inner.s, Toast.LENGTH_SHORT).show();
}


class InnerClass {
private String s;
private InnerClass(String s) {
this.s = s;
}
}
}

为了能在外部类实现 inner.s 这种调用,编译器会自动为 InnerClass 类合成 access$100 方法,这个方法简单返回私有域 s 的值。同样,如果此时匿名内部类需要访问外部类的私有属性/方法时,外部类也会自动生成access$ 相关方法供内部类访问**。

所以,出现问题的时候应该是出现了这样一种场景:打补丁前test方法没访问 inner.s 打补丁后访问了,那么补丁包中就会新增 access$100 方法!

解决方案

既然知道是因为产生了自动生成的方法导致的,那么我们阻止方法的自动生成就好了:

把外部类、内部类的所有 method/field 的私有访问权限改为 protected 或者 public 或者默认访问权限

匿名内部类特殊情形

匿名内部类其实也是个内部类,所以也会有上面内部类的影响,但是新增一个匿名类(补丁热修复模式允许新增类),并且符合上述内部类解决方案的要求,但是还会提示 method 新增,所以接着了解匿名内部类。

匿名内部类名称格式一般是: 外部类$数字编号,后面的数字编号是编译期根据匿名内部类在外部类出现的先后关系,依次累加命名的。看如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class DexFixDemo {
public static void test(Context context) {
/*new DialogInterface.OnlickListener() {
@Override
public void onClick(){
Log.d("aa", "bb");
}
}*/

new Thread("thread-1") {
@Override
public void run(){
Log.d("xx", "yy");
}
}.start();
}
}

修复后新增 DialogInterface.OnlickListener 这个匿名内部类,但是最后补丁工具发现新增 onClick 方法!具体原因:修复之前只有一个 Thread 匿名内部类,此时它的名称应该是 DexFixDemo$1 ,然后打补丁后在 test 方法中新增了 DialogInterface.OnlickListener 匿名内部类,由于位置关系,它的名称变成了 DexFixDemo$1,而 Thread 的匿名内部类的名称变为 DexFixDemo$2 了 !所以,前后2个 DexFixDemo$1 类进行对比差异,就会完全乱套。同理,减少一个匿名内部类也存在同样的情况

匿名内部类解决方案

新增或者减少匿名内部类,实际上是无解的,因为补丁工具拿到的是已经编译后的 .class 文件,根本没法区分 DexFixDemo$1 或者 DexFixDemo$1 类。所以,应当避免插入一个新的匿名内部类。当然,如果匿名内部类是插入到外部类的末尾,那么是允许的!

有趣的域编译

热部署中也不支持 的修复,这个方法会在 Dalvik 虚拟机中类加载的时候进行初始化调用,Java中本身没有这个方法,是编译器自动生成的。静态的 field 的初始化和静态代码块实际上就会被编译在 方法中。也就是热修复不支持静态代码块和静态field **。顺便说一下,静态代码块和静态域初始化在 中的先后关系就是二者出现在源码中的先后关系**。

非静态域与非静态代码块被编译器翻译在 默认无参构造函数中,域 和 代码块出现在 的顺序也和源码中的顺序一致。 是在对象初始化的时候被调用的,简单来说就是创建一个对象就会对这个对象进行初始化。

域编译热部署解决方案

前面说了, 方法不支持热部署,即静态 field 和静态代码块都不支持热部署。非静态的 field 和 代码块的变更会被编译到 中,热部署模式下会被视为一个普通方法的变更,因此热部署是不受影响的。

final static 域编译

开始很自然地认为 final static 是一个静态域,因此自然认为会被编译到 方法中,所以热部署也是不支持的,但是测试发现,final static 修饰的基本类型或者 String 常量类型不会被编译到 中!static 和 final static 修饰的 field 的区别如下:

  • final static 修饰的原始类型和String类型域(非引用类型,如: “haha”),并不会被编译在 方法中,而是在类初始化执行 initSFields 方法时初始化赋值
  • final static 修饰的引用类型(如: new String(“haha)),仍然在 中初始化

所以我们平时看到的说如果一个field是常量,那么尽量推荐使用 static final 作为修饰符,这句话应该是不完全正确的,得到优化的仅仅只是原始类型和String类型域(非引用类型),如果是引用类型不会有任何优化。那到底这个优化过程是怎样的呢?如果只是 static ,那在获取 field 之前,还需要判断是否有解析所属的类,如果没有还要解析这个类,之后才能获得;但是 static final 就可以通过立即数就能获得(注意,对于字符串来说,获取的立即数只是字符串常量的索引,还需要去通常说的“字符串常量“区获取)。

final static 热部署方案

  • 对于 final static 修饰的基本类型或者非引用类型的 String 域,由于**在编译期间引用到基本类型的地方被立即数替换,所以在热部署模式下,最终所有引用到该 final static 域的方法都会被替换,所以是可以做热部署方案的。
  • 修改final static 引用类型域,是不允许的

有趣的方法编译

除了以上内部类和匿名内部类可能造成 method 新增外,还有使用了混淆方法编译导致的内联和裁剪,也会导致最终 method 的新增或者减少。

方法内联

实际上好几种情况会导致方法被内联:

  • 方法没有被任何地方引用,会被内联掉
  • 方法足够简单,比如方法只有一行代码,那么任何调用该方法的地方斗殴会被该方法的实现替换掉
  • 方法只被一个地方引用,就会被内联

方法裁剪

比如如下方法:

1
2
3
4
5
public class BaseBug {
public static void test (Context context) {
Log.d("aa", "bb");
}
}

但是查看混淆 mapping.txt 映射文件:

1
2
com.demo.BaseBug -> com.demo.a;
void test$faab20d() -> a

因为context 没有被使用,所以 test 方法的 context 参数被裁剪,混淆任务首先生成 test$faab20d() 裁剪后的无参方法,然后再混淆。那怎么才能不发生裁剪呢?不让编译器在优化的时候认为引用了一个无用的参数就好了,方法很多,介绍一个最有效的方法:

1
2
3
4
5
6
public static void test(Context context) {
if (Boolean.FALSE.booleanValue()) {
context.getApplicationContext();
}
Log.d("aa", "bb");
}

注意,这里不能用基本类型 false,必须用包装类 Boolean,否则if语句也会被优化掉!

内联裁剪的热部署解决方案

实际上只要在混淆配置文件中加上 -dontoptimize 项,就不会做方法的裁剪和内联。

switch-case语句编译

在switch-case情况下,case 后面的id 可能替换不掉的情况。因为switch-case编译时会做优化,比如,case后面的值是连续的 1,3,5 ,那就会编译成 packed-switch 指令;如果是 1,3,10 这种没什么规律连续的,就编译为 sparse-switch。编译器会自行确定怎样才是连续的。

热部署方案

如果 case 被编译成 packed-switch 指令,那么如果不做任何处理的话,就会遇到资源id替换不完全的问题。解决方案也很简单,修改 smali 反编译流程,碰到 packed-switch 指令就强转为 sparse-switch指令,然后做资源ID的暴力替换,最后再回编译 smali 为 dex 。但是这也会导致打补丁变得慢了些。

泛型编译

泛型的使用也会导致 method 的新增。Java 语言的泛型基本完全在编译器中实现,由编译器执行类型检查和类型推断,然后生成普通的非泛型字节码。虚拟机是完全无感知的。

有泛型的情况下,子类真正重写基类方法的是通过编译器桥接实现的。桥接方法的内部实现就是调用子类重写的方法。

泛型的热部署方案

如果我们的代码由 class B extends A 变成了 B extends A ,那么就可能增加对应的桥接方法,此时新增了方法,就只能通过冷部署修复。

Lambda表达式编译

Lambda 表达在编译期间会为外部类生成一个static 类型的辅助方法,该方法内部实现 Lambda 的逻辑。所以导致方法的新增。在这个过程中会在外部类中生成一个非静态的内部类。

Lambda 表达式热部署

由上面可知,修改源代码中的Lambda 也会出问题:

  • 如果基线包Lambda没有访问非静态field/method ,那么它就不会持有对外部类的引用;
  • 如果后续补丁包访问外部类非静态 field/method,则会持有外部类引用,会新增field,热修复失败

所以总结如下:

  • 增加或者减少Lambda 表达式会导致类方法错乱,导致热部署失败
  • 修改Lambda 表达式可能导致新增 field ,导致热修复失败

访问权限检查对热修复的影响

如果当前类和实现接口(父类)是非 public 时,同时负责二者的 ClassLoader 不一样的情况下,直接检查失败;所以热修复如果不处理这里,会导致类加载阶段报错,因为我们当前的代码热修复方案是基于新的 ClassLoader 加载补丁类。

如果补丁类中存在非 public 类的访问或非 public 方法或域的调用,那么会加载失败,并且在补丁加载阶段是检测不出来的,只有在运行阶段才会报错。

热修复方案

如果出现这种情况只能冷启动重启生效,因为类已经加载过了。

引申

关于内部类

静态内部类/非静态内部类的区别: 后者会持有外部类的引用,前者不会。在 smali 中的表现就是非静态内部类会在编译期间自动合成 this$0 域,这个 this$0 就是外部类的引用。

关于proguard

preverification step : 针对 .class 文件的预校验,在 .class 文件中添加 StackMa/StackMapTable 信息,这样在执行类校验阶段会省去一些步骤,这样类加载更快一些。但是Android 虚拟机执行的是 .dex 文件,会把所有的 .class 转为 .dex ,所以这个选项是没有任何意义的,反而降低打包速度。所以一般禁止这个选项: -dontpreverify 这一项

谢谢你的鼓励