Android 热修复笔记1:代码热修复

2018/10/20 Android

记录学习 Android 热修复相关的收获,混淆、泛型、Lambda 对热修复的影响。

  • 代码热修复
  • 代码冷启动修复
  • 资源修复
  • so 修复

代码热修复

直接在运行期修改底层结构的热修复方案,只能支持方法/变量的替换、修改,无法支持方法/变量的增加和减少。

根本原理是基于 native 层方法的替换,当类结构变化时,会导致补丁异常。

哪些改动热修复没办法立即生效

因为方法的增加/减少,会导致类及 Dex 的方法数变化,从而导致方法索引变化,从而影响通过索引对方法的访问。

字段也是如此,影响字段索引。

上面这说的是已有的类,新增的类不受限制。

概括的说,这两种情况无法使用热修复:

  1. 修改会导致原有类的结构变化(方法、变量)
  2. 修复的非静态方法会被反射调用

除了我们实际的修改,为了满足一些 Java 特性,编译器、虚拟机可能会自动合成一些方法/变量,也会导致类的结构变化,因此我们需要深入了解一些常见 Java 特性的实现原理。

以下情况的变动无法通过热修复解决,只能冷启动解决:

  1. 匿名内部类的新增或者减少(导致生成的 OuterClass$N() 方法索引改变)无法热修复,除非是添加到外部类的最后,不影响其他匿名内部类 OuterClass$N() 方法的生成
  2. 静态变量、静态代码块的变更(他们的初始化在<clinit> 方法中,这个方法在类初始化时调用,不可改变)无法热修复
  3. final static 引用类型的变量(还是在 <clinit> 中初始化)的修改 无法热修复,但是 final static 基本类型和 String 常量可以(他们不在<clinit> 中初始化)
  4. 已经被内联的方法,补丁里增加了对它的调用,不生效
  5. 补丁里增加了之前没有的、泛型和多态的使用

内部类和外部类互相访问私有变量时,编译器会生成一个 access$N() 方法,这个方法的作用就是返回私有变量。如果修改了这些变量导致生成的 access$N() 方法索引改变,也有问题。

但这是可以解决的,可以通过修改自己和外部类变量的访问权限来避免生成 access$N()

混淆对方法的影响

开启了混淆方法编译,可能会导致方法的内联和裁剪。

方法内联: 减少函数压栈、出栈、维护上下文的开销,编译器直接把调用比较少的函数里的代码拿出来。

这几种方法会被内联:

  1. 方法没有被调用
  2. 方法只被一个地方调用,调用的地方直接替换成方法实现
  3. 方法足够简单,比如实现只有一行代码

方法裁剪:代码混淆时,如果方法里的参数没被使用,那这个方法就会被裁剪,生成一个没有该参数的方法。

如果补丁的这个方法实现里使用了参数,就等于新增了方法。

解决办法:给混淆配置文件加上 -dontoptimize,就不会做方法的裁剪和内联。

这里需要重点提一下混淆中的两个阶段:

1.optimization ,进一步优化。非公开的类和方法可能被设置为 private staticfinal,无用的参数会被剪除,并且一些方法可能被内联。

2.preverification ,对 .class 文件的预校验。 加一些额外信息,会让 Hotspot VM 虚拟机在类加载时执行类校验阶段会省去一些步骤,因此类加载会更快。

但 Android 虚拟机有自己的代码校验逻辑 (dvmVerifyClass),这个预编译是没有意义的,反而会降低打包速度。

因此混淆配置里也加上 -dontpreverify 吧。

小结一下:

为了避免混淆对热修复造成影响,需要在配置里加上 -dontoptimize-dontpreverify

再谈泛型编译

泛型是 Java5 引入的,通过扩展虚拟机指令集来支持泛型会为 Java 厂商升级 JVM 造成很大的障碍,因此就不通过修改虚拟机实现,而使用在编译器中实现的泛型擦除方法。

我们所写的泛型代码会由编译器执行类型检查和类型推断,生成普通的、非泛型的字节码。

我们在使用泛型时,之所以不需要做强转就可以拿到想要的类型,是因为编译器会在字节码中自动加上强制类型转换。

多态与泛型

多态,使用动态绑定(invoke-dynamic)实现

首先回顾重写和重载的区别:

  1. 重写:子类中的方法与父类中的某一方法方法名、参数、返回值都相同
  2. 重载:多个方法,方法名相同,参数必须不同,返回值不影响

假如父类中的某个方法里(参数/返回值)使用了泛型,子类的方法里填入了具体的类型。

由于父类在泛型擦除后,使用泛型的地方会被替换成 Object,看起来子类的方法和父类的方法不是重写的(因为参数/返回值不同),但编译器却认为这是重写,会加上 @Override 注解。

读者可以自己尝试写一下父类使用泛型声明,子类填入具体实现。

我们的本意是进行重写,实现多态,但经过泛型擦除,其实变成了重载。

为了不让类型擦除影响多态,编译器只能再做一步工作:增加桥接方法。

编译器心想:都怪当初接下这个泛型擦除的锅,现在还得继续擦屁股,早知道把泛型的实现甩给虚拟机了。

真正重写父类方法的,其实是编译器会生成桥接方法,它的方法名、参数、返回值都和类型擦除后的父类方法一致,只不过在它内部,又调用了子类填入具体类型的方法。

小结一下:

生成桥接方法会导致方法新增,因此如果热修复的补丁里,新增了或者修改了【泛型与多态】的代码,就很有可能无法生效,只能走冷部署。

Lambda 表达式编译过程

Lambda 表达式在 Java7 引入,使用 Lambda 表达式也可能导致方法的新增/减少。

Sun/Oracle Hotspot VM 如何解释 Lambda 表达式

Lambda 表达式和匿名内部类有些类似,区别如下:

  1. 匿名类的 this 指向当前匿名类, Lambda 表达式的 this 指向包围 Lambda 表达式的类
  2. Java 编译器将 Lambda 表达式编译成类的私有方法,然后使用 invokedynamic 字节码指令动态绑定这个方法;但将匿名内部类编译成一个单独的类,名为 OuterClass$N

编译器生成的字节码 .class 中会为 Lambda 表达式生成私有静态方法 lambda$main$N(xx),这个方法的实现就是 Lambda 表达式里面的逻辑。

然后通过 invokedynamic 指令执行。

invokedynamic 指令是在 Java7 JVM 新增的,用于支持动态语言,即允许方法调用可以在运行时指定类和方法,而不必在编译时确定。

invokedynamic 指令执行时会调用一个静态方法 java/lang/invoke/LambdaMetafactory.metafactory(),这个静态方法在运行时会生成一个实现函数式接口的具体类。

这个具体的类会调用前面生成的私有静态方法 lambda$main$N(xx)

也就是说, Lambda 表达式在 .class 中是这么实现的:

  1. 编译时生成类的私有静态方法 lambda$main$N(xx)
  2. 运行时生成一个新类,实现接口,然后调用上面的私有静态方法

Android 虚拟机新的编译工具

Android 中 java 代码的编译过程:通过 javac.java 文件编译成 .class 文件,然后通过 dx 工具 优化为适合移动设备的 dex 字节码文件。

此外,如果要使用 Java8 的特性,还需要使用 Android 的新工具链 Jack 工具链

Jack 工具链(Java Android Compiler Kit)可以将 Java 代码直接编译为 Dalvik 字节码,试图取代 javac/dx/proguard/jarjar/multidex 等工具。

构建 dex (Android Dalvik 可执行文件)可用的两种工具链对比:

  1. 旧 javac 工具链:javac (.java -> .class) -> dx(.class -> .dex)
  2. 新 Jack 工具链:Jack(.java -> .jack -> .dex)

可以看到,Jack 工具链编译中间会生成 .jack 文件,没有 .class 文件。

对比 Lambda 表达式的实现

对比 .class 文件和 Jack 工具链编译出的 dex 文件,可以看到他们对 Lambda 表达式的实现异同点:

  1. 都会在编译期间为外部类生成 static 方法,内部逻辑为 Lambda 表达式
  2. 不同在于,.class 字节码中会在运行时为 Lambda 表达式生成新类;.dex 字节码中在编译期间就生成了新类

此外,.dex 字节码中,Lambda 表达式编译器期间生成了新类,如果 Lambda 表达式访问了外部类的非静态变量或者方法,会导致这个新类里有一个成员变量,持有“外部类”的引用。

Lambda 表达式对热修复的影响

  1. 新增 Lambda 表达式会导致方法增加,无法热修复
  2. 修改 Lambda 表达式时,如果之前没访问外部类的非静态变量/方法,补丁里访问了,会新增变量,也无法使用热修复

加载类时的校验、优化

一个类的加载过程,会经过 resolve link init 三个阶段,这期间会进行一些校验、优化。

补丁类在单独的 dex 文件中,要加载这个 dex 的话,需要进行 dexoptdexopt主要做两件事:

  1. 校验
  2. 优化

校验执行流程:dvmVerifyClass -> verifyMethod -> verifyInstruction

父类/实现接口的访问权限检查

link 阶段会对类访问父类/实现接口的权限进行检查,具体实现在 dvmLinkClass() 中。

如果当前类的父类和实现接口是非 public,或者加载两者的 classloader 不是一个,会在类加载阶段报错。

一般热修复方案都会使用新的 ClassLoader,因此这阶段报错的话,可以走冷启动方案,不会直接 crash。

但是,如果补丁类中引用了非 public 的类,在加载阶段不会报错,没办法检测出来,只有在运行时才能发现,那时就为时已晚,没办法补救了。

小结

热修复技术上有很多限制,能够应用的场景比较少,此外如果业务逻辑由于热修复进行修改,也很可能导致问题。

因此热修复只能用于修改一些简单的 bug,比如说空指针什么的,不建议做功能方面的更新。

参考资料

主要学习自《深入探索 Android 热修复原理》

Search

    Table of Contents