从零开始的Android热修复之旅(三):进阶以及优化改进

前言

在之前的两篇文章中,我们从基本原理以及丁自动化流程这两个方面了解了热修复是什么,完成一个热修复框架应该怎么做。并且在上一篇文章的结尾,列举了一些需要处理的实际问题,在这一篇文章里,我们来一一分析这些问题,并找到这些问题的解决方法。

super.调用优化

在上一篇文章中,我们讨论了处理super调用的方式,将所有被super调用的方法进行替换,转为对一个额外注入的桥接方法的调用,该方法会预先列举所有可能被super调用的方法,以便他可以通过参数进行调用转发。这样的方式能够解决我们最根本的问题,但是并不是那么完美,如果被修复的类是一个庞大复杂的类,还有复杂的继承关系,那这个桥接方法可能会非常大,甚至有可能超过方法的指令上限,并且额外注入的方法也会导致代码增多,使包体变大。
那有什么方式可以避免新增额外的桥接方法呢?可以先看一下对父类方法的super.调用在指令层面的表现是什么。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 示例代码 TestClass.class
public void runTestMethod() {
super.protectedSuperDotCallTest();
super.publicSuperDotCallTest();
}

// 字节码
public void runTestMethod();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=8, locals=1, args_size=1
0: aload_0
1: invokespecial #6 // Method com/xxxxx/hotfix/testcase/SuperClass.protectedSuperDotCallTest:()V
4: aload_0
5: iconst_1
6: iconst_0
9: invokespecial #12 // Method com/xxxxx/hotfix/testcase/SuperClass.publicSuperDotCallTest:()Ljava/lang/String;
10: pop
11: return

可以看到,super.的调用指令实际上和private方法以及构造方法的调用指令相同,都是invoke-special指令,既然指令相同,能否按照一般指令的转译方式来制作补丁呢?我们可以先尝试让补丁类继承被修复类的父类,使super调用可以正确指向父类方法,并把操作数替换为被修复类的运行时实例,这样看起来很完美的解决了问题,也无需在接入业务构建时插入额外的桥接方法了。但是有一个点,patch类继承被修复类的父类,如果父类拥有有参构造方法,会导致在应用补丁创建patch类示例时无法调用无参构造方法完成实例化,在无法覆盖所有父类构造方法执行逻辑的情况下,也无法使用默认值完成参数的传递。
那第二步,把patch类的父类改成Object是否可行呢?尝试一下,我们便会得到一个Verifier rejected Error.,行不通。不继承被修复父类就无法调用super方法,继承了被修复类父类又无法在所有场景下使用无参构造方法,那应该怎么做才能在保证patch类父类为Object的情况下调用到被修复类的父类方法呢?这里就可以印证一条名言,计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决——找一个可以执行这条指令的类作为中间层就行了,我们可以新建一个桥接类,让该类继承被修复类的父类,再声明一个静态方法,在静态方法(使用静态方法避免类的实例化)中完成super的调用,这样就可以通过虚拟机的类检查,而补丁类中的super.调用,转为对这个静态方法的invoke-static调用即可。

签名区分映射

进行热修复的方法分发时,我们要进行方法的区分,在前文中已经提到过使用方法签名就可以完美做到区分不同的方法,而且能够保证唯一性,并且签名字符串也在常量池有对应项,这也意味着不会额外增加包体积,但是通过签名做区分的方式还是有两个问题,

  1. 字符串判断相等是O(n)复杂度的,随着签名字符串长度增加,判断分发的时间也会增加
  2. 字符串在混淆时不会被处理,会导致方法信息明文展示

为了优化这两项,我们可以参考mapping文件的映射机制,也在构建时针对方法签名进行一个映射,让其变成更加无意义、更短的标志。如果仅为了区分方法,我们可以用一个整型来做表示,这个整型数值和方法签名的映射关系映射关系大致如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
com.xxxxx.hotfix.runtime.patch.supervisor.TestPatchSupervisor$1
onEvent(ILjava/lang/String;)V->0
com.xxxxx.hotfix.demo.testcase.NewMethodTestCase
runTestCheck()V->1
runTestMethod()V->0
com.xxxxx.hotfix.runtime.helper.Logger
w(Ljava/lang/String;)V->1
e(Ljava/lang/String;)V->0
v(Ljava/lang/String;)V->2
com.xxxxx.hotfix.demo.testcase.CrashCase
makeCrash(Lcom/xxxxx/hotfix/demo/LogView;)V->0
com.xxxxx.hotfix.demo.TestPanelActivity$onCreate$5
onClick(Landroid/view/View;)V->0

...

类名无须映射,其下声明的所有方法分配不重复的整型id。该映射关系和R8/ProGuard的mapping.txt类似,在后续针对线上包的所有操作和解析中都要用到,比如在制作补丁时作为反解规范使用,因此在发布后应当和.apk, mapping.txt, usage.txt等文件一起归档储存。

改动检测

前几年开源的热修复框架定位修复函数的手段主要是使用注解或者锚点方法,优点很明显,定位明确,构建时需要处理的范围非常小。对于一些简单修复的场景来说这样的方式是足够的,但是一旦改动范围增加,在不了解编译器编译规则的情况下,手动增加注解很容易有疏漏。在此基础之上,我们可以考虑使用代码对比进行自动检测,来定位被改动的方法。自动检测有以下好处:

  1. 减少打补丁时增加注解的步骤,开发只需修改代码,补丁流程可以放到CI/CD里
  2. 可以在检测中进行规则约束,及时通知制作补丁的同学修改不支持的操作
  3. 可以对大范围的改动进行检测(比如三方sdk升级)

改动检测实际上是比较改动前后的内容差异,因此我们要将改动前的代码储存下来和改动之后的代码进行比较。借助ASM对class文件的解析,我们可以在内存中拿到类的所有属性、方法和字段的信息。如果两个类是完全一样的,那么在内存中他们的表现形式也应该相同,但是ASM并没有提供这样比较的API供我们使用,因此要自实现以完成比较,并且输出可以用于制作补丁的差异信息。
类文件经过ASM Tree API的解析后,在内存中会以ClassNode的数据结构储存,ClassNode的成员有类对应的属性、MethodNodeFieldNode,而MethodNodeFieldNode也是一系列属性的结合。进行比较需将上述提到的所有属性都进行对比,但由于属性数量较多,每一种类型的比较都进行处理会是一个比较庞大的工作量。我们不妨换一种思路————使用反射。拿到所有field,然后基本类型和String直接进行值的对比,非基本类型的对象再递归进行反射比较。递归要额外注意一些指针的引用(比如指令列表InsnList),会造成递归无限循环,因此一定要将指针加入过滤,不进行比较。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
private fun reflectMemberCompare(
curr: Any,
ori: Any,
filterList: Array<String>,
process: (ChangeDetail) -> Boolean
) {
val clazz = curr::class.java
if (clazz != ori::class.java) {
throw IllegalArgumentException("param curr type: ${clazz.canonicalName} is not equals to param ori type:${ori::class.java.canonicalName} ")
}
clazz.declaredFields.filter {
!filterList.contains(it.name)
}.forEach {
it.isAccessible = true
val currValue = it.get(curr)
val oriValue = it.get(ori)
if (!isEquals(currValue, oriValue)) {
return
}
}
}

fun isEquals(clazz: Class<*>, target: Any?, other: Any?): Boolean {
if (other?.javaClass != clazz) {
return false
}
//基本类型和String,一定override了equals,直接比较
if (isWrapperType(clazz) || target is String) {
return target == other
}
//array相等的比较
if (clazz.isArray) {
return isArrayEquals(target!!, other)
}
if (target is List<*>) {
return isListEquals(target, other as List<*>)
}
clazz.declaredFields.forEach {
it.isAccessible = true
val currValue = it.get(target)
val oriValue = it.get(other)
if (!isEquals(currValue, oriValue)) {
return false
}
}
return true
}

用上述的方式可以很好的处理属性变更比较和FieldNode的比较,不过方法由于指令体的存在,导致其比较特殊,需额外进行处理。先来看看方法的组成,方法的组成大致可以分为三部分,1.方法签名,2.方法体,3.方法属性(注解,范型、访问级别、局部变量表等),这几部分都有变更的可能性。方法签名包括了方法的名称、返回值、参数(类型、顺序、数量),其中任一部分改变,我们都会认为是新增了一个变更后的方法,且原方法被删除。方法体的变更检查则要从指令入手,我们可以用方法变更前后的指令数为第一个判定标准,若指令数相同,再比较指令是否变更(操作符与操作数是否相同),需要注意的是LineNumberNode,该属性不会影响运行时逻辑,在检测中应该被忽略。
第二点,约束规则,因为Instant Run方案的一些限制,诸如注解、accessFlag的修改都是不支持的,通过在文档中一条条列举不支持的选项对于使用方来说无疑是很繁琐的,因此如果在构建补丁时,自动检测出改动的内容是否在我们支持的范围内,并给出不支持修改的提示信息,是一个较为合适的解决方案。而在前一段中讨论到的检测方案,粒度能够精确到类文件所有属性,也能够让我们在规范规则约束的时候提供详尽的改动信息。
最后一点,部分框架使用注解标记方法进行修复方法的定位,但新增注解并不能适用于已经引入的第三方库。假使一个三方库在线上暴露出了一些问题,按照正常迭代修改的话,只需要修改maven/gradle依赖的版本,等待上线即可。但如果情况比较严重,想针对这种情况进行热修复的发布,那制作补丁时就一定需要知道所有改动的点,人为判断在此时就略显乏力了。使用改动检测就能很好解决这样的问题,只用将三方库或引入的模块加入检测范围,就可以自动化的完成改动的检测。当然这一点也会让补丁构建的时间增长很多。

R8编译/ProGuard处理

实际上,几乎对于每一个应用来说,在构建release版本的包时,开启R8编译/ProGuard都是一个必须的选项,更是总所周知的,使用这个工具对构建流程进行处理后,能够让包体积变小、混淆代码增大反编译难度,但是也会带来一些诸如反射失效之类的问题。
看一下官方如何描述R8编译的任务

  • 代码资源缩减:移除不使用的类、字段、方法、属性以及资源
  • 混淆:缩短类和成员的名称
  • 优化:检查并重写代码
    我们使用的热修复方案的覆盖范围是方法粒度的,因此最后一项优化对方案没有任何影响,它只会优化方法内部的执行逻辑,而前两项,会切实的改动程序内方法的对外形式,针对这两项,需要我们进一步的探讨。

针对混淆的处理

R8进行混淆处理,会缩短所有未保持的类、字段、方法的名称,下面是一个来自mapping.txt的示例:

1
2
3
4
5
6
7
8
9
10
androidx.appcompat.app.ActionBarDrawerToggle$DelegateProvider -> a.a.a.b:
androidx.appcompat.app.AlertController -> androidx.appcompat.app.AlertController:
android.content.Context mContext -> a
int mListItemLayout -> O
int mViewSpacingRight -> l
android.widget.Button mButtonNeutral -> w
int mMultiChoiceItemLayout -> M
boolean mShowTitle -> P
int mViewSpacingLeft -> j
int mButtonPanelSideLayout -> K

混淆处理后意味着在应用中已经不存在androidx.appcompat.app.ActionBarDrawerToggle$DelegateProvider类了,取而代之的是一串短且不易区分的名称a.a.a.b,一些针对类名、方法名的反射操作就会失效。通常面对需要反射处理的类,都会对其进行Keep处理,但是热修复生成补丁时,并不会预先知道将会被处理的类,也就无从谈起Keep处理了。我们要做的,就是让补丁内的类也应用如上规则。
ProGuard提供了-applymapping选项用来应用已有的mapping文件。不过这里我们可以舍近求远,尝试通过修改class文件的常量池和属性表来做到补丁的混淆,通过这种方式可以让更好理解混淆的原理,且能更好自定义处理流程。
我们要确定范围,然后通过mapping文件将名称进行映射即可。先看一下混淆的改动范围,分为类自身成员和引用的外部成员,类自身的成员实际上只有声明在类中的方法和字段(内部类可以认为是一个新的类),外部引用的范围就比较大了,会有如下内容:

  1. 父类类名
  2. 实现接口类名
  3. 声明方法的参数&返回值类型类名
  4. 字段类型类名
  5. 注解类名
  6. 访问的方法名称(包括方法所属类的类名)
  7. 访问的字段名称(包括字段所属类的类名)

其中,第三点需要解析方法描述符,依次映射完成后再组合进行替换。举一个例子:(Ljava/lang/String;Ljava/util/List;)Z,这个类型描述符表示它所描述的方法接受两个类型分别为StringList的参数,返回一个bool类型的参数。在mapping中查找Ljava/lang/String;混淆后的名称(实际情况下jdk内的类不会混淆,这里只是举一个例子)进行替换,Ljava/util/List;同理,返回值如果非基本类型也需要进行如上映射。处理完成后描述符可能会是如下形式:(La/b/a;La/c/a;)Z
第五点需要处理的比较多,注解会被使用在多个地方,可能是方法,字段,方法参数等等,需要去读取上述成员的RuntimeVisibleAnnotations表和RuntimeInvisibleAnnotations表,再进行替换。
第六、七点处理方式大体一致,可以直接遍历常量池,寻找Methodref,Fieldref,InterfaceMethodref项,处理这些项中索引到的Class项和NameAndType项。
处理完上面所有范围的成员名称后,就可以得到一个经过混淆的类。

针对裁剪的处理

R8编译/Progurad在未使用-dontshrink时,会删除程序中未使用到的类、方法字段,对应的删除内容会输出到usage.txt文件当中。以一个类说明,被删除的类在程序中相当于不存在了,但是无法保证我们在制作补丁的时候不会引用到该类。既然该类在构建时被删除了,那我们就可以认为对于程序来说,该类是一个新增类,我们可以用新增类的形式去处理它,方法和字段同理。那么如何正确定位到被shrink处理的内容呢?
先来看一下usage.txt的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
androidx.drawerlayout.R$attr
androidx.vectordrawable.R
androidx.appcompat.app.AppCompatDelegateImpl
public void setSupportActionBar(androidx.appcompat.widget.Toolbar)
public boolean hasWindowFeature(int)
public void setHandleNativeActionModesEnabled(boolean)
android.view.ViewGroup getSubDecor()
public void setLocalNightMode(int)
final androidx.appcompat.app.AppCompatDelegateImpl$AutoNightModeManager getAutoNightModeManager()
public final androidx.appcompat.app.ActionBarDrawerToggle$Delegate getDrawerToggleDelegate()
private static final boolean DEBUG
private static final java.lang.String KEY_LOCAL_NIGHT_MODE
static final java.lang.String EXCEPTION_HANDLER_MESSAGE_SUFFIX
...

如果某个类,后面没有成员行的信息,表示的是整个类都被删除,有成员行,表示被列出的成员都被删除了,但是该类没有被删除。可以通过mapping.txt的内容来判断一个成员是否被删除了,如果在mapping.txt中找到了映射关系,则该成员没有被删除。我们能够根据上述规则,从需要被补丁的类开始进行查找,遍历其类结构和所有的方法指令,找到其中的引用,然后进行上述操作得到一个需要被还原的集合,其中会包含被还原的类、某个类中被还原的字段和方法。这些内容都会被视为新增,然后进行后续的处理。

如何支持修复构造方法

构造方法的修复和普通方法的修复略有不同,由于所有的构造方法必须直接或者间接调用到父类的构造方法(super() 或者 this()),因此对构造方法插桩相较于普通方法来说要额外增加this()或者super()的指令。但是被修复的逻辑中,很可能调用的方法和修复前不同,比如被修复类调用了super(activity),但修复后会改为对super(application)的调用,这时就要求注入方法逻辑内的super/this调用要具备选择分发的能力。
在Instant Run的实现中,在保留原有构造方法的情况下会生成一个新的构造方法,这个构造方法的方法体对所有可能调用的当前类的构造方法和父类的构造方法做了枚举,根据传入的参数来确定需要执行哪一个构造方法。构建流程中注入到if/else逻辑中的this()调用就将会调用到这个新增的构造方法,由此来达成构造方法的修复支持,静态构造方法同理。
由于虚拟机对构造方法的约束,导致仅有这样的方式能够赋予构造方法动态性以支持热修复,这种方法的弊端同上一篇文章中讲到生成的super桥接方法类似,都会增大包的体积,对构造方法的修复可以依据接入方的实际情况进行取舍。

如何支持新增一个成员字段

在讲到R8/ProGuard时提到,有字段被shrink的情况下,如果我们在补丁制作时使用到了这个字段,对于运行的应用来说,这个字段是一个当前数据逻辑中不存在,新增的字段。新增方法时,我们新增的是一段逻辑,不需要区分他是类方法还是成员方法,在运行补丁时只要能够调用到逻辑即可,但是字段不同,成员字段在每个实例中都有一个备份。不过,换一个角度来说,如果为被修复类的所有运行时实例都做好映射,那也就能够支持字段的新增了。
再明确一下新增成员字段的需求,需要建立被修复类实例和新增成员字段的映射关系,且这个映射关系不能影响到实例的gc,由此我们很容易想到可以使用WeakHashMap来维护这个映射关系。当在制作补丁时开发新增了字段,制作补丁时会区分是类字段还是实例字段,如果是类字段,直接将字段放到补丁类中即可,如果是实例字段,则需要维护一个被修复类运行时实例和实例字段值的WeakHashMap,并根据传入的被修复类运行时实例进行索引。所有在原本逻辑中对新增字段的操作指令,都会转为对WeakHashMap中value的操作指令。由此便可以实现新增一个字段的修复。

反射调用优化

大家都知道,反射调用一个方法时,因为额外校验、无法编译器优化等原因,性能会比直接调用相差一个数量级以上,在逻辑简单时尚可接受,但是若在一些密集处理,或者在绘制时频繁调用的地方应用热修复,就有可能导致原先正常的逻辑在执行补丁时出现明显卡顿,甚至ANR。因此,优化掉反射调用可以很好的帮助热修复扩大适用场景和修复范围。
为什么使用反射调用原有类的逻辑在前文中已经分析过了,在此我们可以复习一下。热修复的补丁包含了改动后的逻辑,改动后的逻辑有可能需要调用类中原本存在的非public方法或访问非public的字段,又由于补丁内的类是一个全新的类,将会在应用补丁时通过ClassLoader加载,因此对其他非public方法\字段的调用就需要通过反射完成了。
既然非public方法\字段的访问才需要反射调用,那我们或许可以减少这样的字段或方法——在编译器将非framework层的所有字段访问标志改为public,并将所有非private的方法访问标志改为public。至于为什么不改动private方法的访问标志,主要是因为public方法的调用指令其实是invoke-virtual和private方法的调用指令(invoke-special)是不同的,虚拟机执行前者时会在函数表中查询具体实现,为了将对指令的改动和影响降低到最小,对private方法的标志位改动就忽略了。但是private的调用在所有的方法调用中实际上占据了大头,不对其进行处理的话,优化就很难有所成效。
或许可以从修改方法访问标志以外的角度入手。从更为通用的角度看去,不难发现程序实际上只是逻辑和数据的集合,逻辑的体现是方法指令,而数据的体现就是字段,在进行热修复时,原本的数据势必是无法随意迁移的,但是逻辑并非如此,因此我们可以考虑将逻辑也直接放到补丁当中,具体体现就是将所有的private方法直接复制到补丁类中,然后将补丁中所有对被修复类private方法的调转译为对补丁内对应方法的调用即可。
如此,就可以将所有非framework层在热修复时需要反射调用的点优化掉。不过由于我们无法修改framework层的方法优先级,因此在补丁中调用到framework层的protected方法时就需要反射调用了,除此之外还有private的JNI方法,由于无法copy其代码,也需要反射调用。

优化和线上成果

方案存在的问题

受限于实现机制,该方案还是会有一些无法支持的修复方式,会在下面列出,所幸对于热修复这样一个轻量级的操作来说,修改涉及到下列的情况通常极少出现

  • 不支持修改范型注解等一系列修改非方法逻辑的操作
  • 不支持override线上版本未override的方法
  • 不支持新增四大组件的子类
  • 不支持在已有匿名内部类的类中,新增一个声明位置不在最后的匿名内部类