Android平台出现一批优秀的热补丁方案,一种为multidex的热更新框架,另一种则是native hook方案。Robust热更新系统借鉴Instant Run原理,来达到一个兼容性更强而且实时生效的热更新方案。基本思路为:Robust热更新系统在一个方法的入口处插入一段跳转代码,当发现某个方法出现bug就跳转执行补丁中的代码,略过原有代码的执行,否则执行原有方法体逻辑。
Robust凭借着自身的优势,已经得到了快速普及。制作补丁的需求也随之越来越旺盛,人力手动制作补丁明显跟不上业务方的需求。虽然我们已经早早地开始自动化补丁的相关工作,但无奈自动化之路坑太多,一直都难以针对各种情况制作出可用的补丁。
如何快速、稳定地生成补丁已经成为制约Robust热更新系统推广的瓶颈。在Robust推广的初期,补丁基本是手动生成,一个补丁的制作和测试经常需要一天的时间,大大降低了系统对线上问题的反应速度。如果能自动化补丁,补丁的生成就不再是瓶颈,只需要一次打包的时间就可以生成补丁。为此我们进行了不懈的努力,最终为Robust热更新系统提供了一个比较成熟的自动化生成补丁工具。最新的开源版本中,已经包含这部分工作。
自动化原理
自动化工具是如何写补丁中代码的呢?我们知道,携带方法的上下文环境跳转到补丁方法中,可以在补丁方法中利用这些参数来修复bug。不过由于Java访问修饰符的限制,很多方法和字段不能在补丁中直接访问,因此反射就成为了补丁中修复线上bug的最佳选择。在补丁的制作过程中大量的使用反射来调用出现bug类中的方法和字段,还可以在补丁类新增方法或者类,以期达到修复线上问题的目的。举个例子来说,原始代码如下:
public int multiple(int number) {
if(number<0){
return -1;
}
number= changeInputs(number);
return times*number;
}
public int changeInputs(int number) {
return number*2;
}
被Robust热更新系统插入代码之后如下:
public static ChangeQuickRedirect changeQuickRedirect;
public int multiple(int number) {
if(changeQuickRedirect != null) {
Object var2 = null;
if(PatchProxy.isSupport(new Object[]{new Integer(number)}, this, changeQuickRedirect, false, 627)) {
return ((Integer)PatchProxy.accessDispatch(new Object[]{new Integer(number)}, this, changeQuickRedirect, false, 627)).intValue();
}
}
if(number < 0) {
return -1;
} else {
number = this.changeInputs(number);
return this.times * number;
}
}
//这个方法没有被Robust处理
public int changeInputs(int number) {
return number*2;
}
如果我们想把multiple(int number)这个方法修改为增加对负数的处理(删除if的判断条件),补丁如何生成呢?如果是手动书写补丁的话,multiple(int number)这个方法既有字段的访问又有方法的调用,那就是把按照修改后的逻辑,挨个写反射代码咯(这里不需要反射Robust的插桩代码),这个方法的方法体也比较简单。自动化补丁做的事情就是逐个扫描方法体的内容,把字段和方法调用的转换为反射,如下自动化生成的代码:
SampleClass originClass;
public int multiple(int number) {
boolean var4 = false;
Object var5;
var5 = ((SampleClassPatch)this).originClass;
Object[] var6 = new Object[]{new Integer(number)};
int var8 = ((Integer)EnhancedRobustUtils.invokeReflectMethod("b", var5, var6, new Class[]{Integer.TYPE}, SampleClass.class)).intValue();
Log.d("robust", "invoke method is No: 19 changeInputs");
number = var8;
boolean var3 = false;
Object var9;
var9 = ((SampleClassPatch)this).originClass;
int var7 = ((Integer)EnhancedRobustUtils.getFieldValue("c", var9, SampleClass.class)).intValue();
Log.d("robust", "get value is times No: 20");
return var7 * number;
}
注:
1、EnhancedRobustUtils是一个对反射的封装类,可以反射指定对象的指定字段和方法。比如说((Integer)EnhancedRobustUtils.invokeReflectMethod("b", var5, var6, new Class[]{Integer.TYPE}, SampleClass.class)) 就是反射var5对象的b方法,方法的参数类型是Integer,参数的具体值是var6。
2、为什么反射方法的方法名不是multiple?这里是反射混淆后的代码,自动化补丁支持ProGuard混淆,下文有进一步的描述。
3、originClass是出现bug class的对象。
这是自动化生成补丁代码的一小部分,实际的补丁文件还包括对补丁的描述以及补丁方法的转发等。
实现
上面的介绍只是自动化的冰山一角,实际使用时问题就会变得错综迷离,市场上各大App基本上都是ProGuard混淆优化后的,代码变得晦涩难懂,ProGuard大大地增加了自动化补丁的难度,上文的样例中就是对ProGuard之后的代码进行反射(注意看反射字段和方法时的方法名和字段名)。当然ProGuard做的优化工作还远远不止这些,那我们如何应对ProGuard的优化,才能保证补丁中的混淆关系和线上APK中的混淆关系保持一致呢?基本上有如下三种解决办法:
1. applymapping
ProGuard提供了使用指定mapping来进行混淆的功能,就是在proguard-rules.pro文件中添加applymapping这个配置型,可以参考这篇博客:混淆实操——手把手教你用applymapping。第一次看到ProGuard的这个功能如获至宝,这可以极大的减少自动化补丁的工作,可惜事与愿违,当笔者把这个参数应用到App上的时候,没有修改任何代码,仅仅是apply上一次构建的mapping文件,发现映射关系并不一样。然后在网上搜索了一下,也有不少反馈说applymapping并不能保证映射关系的一致性。查看官网的详细介绍之后,我们发现了这样的一段话:
大概的意思是说,applyingmapping只能保证部分映射关系一致。这对于我们来说,是完全不可以接受的,我们需要的是绝对可靠的输出补丁,不能依赖我们无法控制的事物。
下图是在App中使用applymapping指定mapping以及使用了 -useuniqueclassmembernames 进行配置,打包过程中ProGuard输出的日志:
从日志中可以看出,很多类并没有按照mapping中的映射关系去映射,而是被rename了,然后就不得不放弃这种做法。
即使applymapping按照预期保证了映射关系的一致性,但是如果出现如下情形:有个函数是void fun(String s,int t),在项目中对fun使用时只有第一个参数是变化的,第二个参数始终是个常量值,那么经过ProGuard后fun函数会被处理为void fun_xxx(String s)(这种情况属于ProGuard优化范围内,当ProGuard力度达到一定的强度后就会出现),如果在生成补丁那次的代码对fun函数使用时第二个参数不保证是固定值了,那后面那次对fun函数ProGurad的处理,不管如何配置Progurad两次的结果肯定是不一样的。如果fun函数在代码version1时满足内联条件则编译时会做内联处理但是在生成补丁的version2代码时却不符合内联规则了,那么这次fun函数的处理就不能保证处理一致了。
2. 无为而治
每次打包改动不大的话,是可以保持映射关系一致。也就是说,在同一台机器上打包两次,这两次改动相差不是很大,这样就可以保证映射关系一致。这个笔者亲测是可以的,但是对于这个改动的范围到底可以有多大,没有很好的把握,笔者在测试的时候仅仅是增加和删除方法体内的代码这是没啥问题,最终可以保持映射关系一致,但是增加类和方法的时候就发生了一些微妙的改变,部分映射关系发生改变。最怕产生多米诺骨牌效应,一个小小的改动会导致自动化处理混淆出现问题,生成的补丁就不可用的情况,最担心是测试团队没有测试出问题,上线之后一片哀嚎。这种方法也就无疾而终了。
3. Do it yourself
各种捷径均告吹之后,只剩下DIY这条路,一路走来也是满坎坷的,自动化补丁并不是一蹴而就,为了解决形形色色的问题,我们分了多个阶段来处理生成的补丁,比如说混淆的处理就是在Smali汇编语言级别处理的。其实如果仅仅是处理ProGuard的混淆,问题还没有那么复杂,问题的难点在于ProGuard做的事情还有很多,优化、压缩代码就是很重要的一步。举些例子来说,ProGuard会把类中的get、set方法作用的字段直接访问性修改为public,然后删除get和set方法;删除无用的方法;以及最令人头疼的内联问题等等。总的来说,自动化补丁之路就是一部血泪史。
自动化做的事情就是根据修改bug后的代码生成最终可执行的dex,就目前来说,整个补丁制作流程包括:.java ->.class ->.dex ->.smali->.dex 。补丁的生成过程步骤繁杂,与此同时,自动化补丁处理代码风格迥异,需要对Java的各种语法提供支持,无论是泛型、内部类还是Lambda表达式,同时还需要提供对ProGuard的混淆、优化、内联支持,这些极大的增加自动化补丁的难度,也让补丁自动化之路显得漫长无比。总的来说,补丁的自动化过程中主要有这么两类问题:
Java编译器的优化
ProGuard的优化
其中第一类问题并没有增加补丁制作技术难度,但是会具有一些迷惑性,需要去分析这种的语法糖的底层实现,搞明白其实现的原理;第二类问题就是自动化的核心,简单的来说,就是把修改完的代码按照线上Apk的混淆规则,ReProGuard一次,这样才能保证补丁的高可用性。
1. Java编译器的优化
Java编译器的优化工作包括Java编译器会自动生成一些桥方法以及移动代码的位置等,比较典型的就是泛型方法、内部类和Lambda表达式。补丁自动化的过程中使用注解来标注需要补丁的方法,所以当Java编译器针对泛型移动代码时,注解也会被移动,直接导致补丁上线后无法修复问题。以Java编译器对泛型方法的处理为例,Java编译器会为泛型方法生成一个桥方法(在桥方法里面调用真正的方法,桥方法的参数是object的类型,注意这类桥方法Robust热更新系统并没有对其插桩),同时Java编译器把原方法上的注解移动到桥方法上,针对泛型方法制作补丁时,就变成了针对泛型方法的桥方法制作补丁了。Lambda表达式也与此类似,编译器把Lambda表达式的内容,移到了一个新的方法(Java编译器为我们生成的access开头的方法)里面去,而且我们还无法给Lambda表达式加上注解。
为了解决上述的问题,自动化提供了一个静态方法(Robust.modify()),支持在泛型或者Lambda表达式里面调用这个静态方法,自动化扫描所有的方法调用,检测到这个静态方法的调用就就可以找到找到需要制作补丁的方法。这样就可以避免由于Java编译器做的一些优化工作导致我们无法修复预期的bug。
与这个问题类似的,还有内部类的问题,这个问题和ProGuard交织在一起。对于构造方法是私有的内部类,Java编译器也会生成一个包访问性的构造方法,以便于外部类访问。
可以参看官方文档的介绍,如下例:
public class Sample{
public int multiple(int number) {
Children pair=new Children("1");
pair.setFirst("asdad");
number= changeInputs(number);
return times*number;
}
class Children{
private String first=null;
private Children(String fir){
this.first=fir;
setFirst("1");
}
public void setFirst(String fir){
this.first=fir;
}
}
}
我们在multiple(int number)里面创建了一个内部类的对象,其中内部类的方法是私有的,如果这样写,Java编译是不会报错的,但是仔细想一下类的私有构造方法外部类怎么可能调用到呢?明显违反了Java的访问性规则。我们来看看反编译的代码:
public int multiple(int);
Code:
0: new #9 // class com/meituan/sample/SampleClass$Children
3: dup
4: aload_0
5: ldc #39 // String 1
7: aconst_null
8: invokespecial #42 // Method com/meituan/sample/SampleClass$Children."<init>":(Lcom/meituan/sample/SampleClass;Ljava/lang/String;Lcom/meituan/sample/SampleClass$1;)V
11: astore_2
12: aload_2
13: ldc #44 // String asdad
15: invokevirtual #48 // Method com/meituan/sample/SampleClass$Children.setFirst:(Ljava/lang/String;)V
18: aload_0
19: iload_1
20: invokevirtual #51 // Method changeInputs:(I)I
23: istore_1
24: aload_0
25: getfield #28 // Field times:I
28: iload_1
29: imul
30: ireturn
那个init就是构造器的调用(上述截图中code的第八行,调用的指令是:invokespecial),是不是后面加上了一个小尾巴(Lcom/meituan/sample/SampleClass$1,这个SampleClass$1不是笔者手动定义的)?让我们再看看SampleClass$Children里面干了啥:
final com.meituan.sample.SampleClass this$0;
private com.meituan.sample.SampleClass$Children(com.meituan.sample.SampleClass, java.lang.String);
Code:
0: aload_0
1: aload_1
2: putfield #20 // Field this$0:Lcom/meituan/sample/SampleClass;
5: aload_0
6: aload_1
7: invokespecial #23 // Method com/meituan/sample/SampleClass$Parent."<init>":(Lcom/meituan/sample/SampleClass;)V
10: aload_0
11: aconst_null
12: putfield #25 // Field first:Ljava/lang/String;
15: aload_0
16: aload_2
17: putfield #25 // Field first:Ljava/lang/String;
20: aload_0
21: ldc #27 // String 1
23: invokevirtual #31 // Method setFirst:(Ljava/lang/String;)V
26: return
com.meituan.sample.SampleClass$Children(com.meituan.sample.SampleClass, java.lang.String, com.meituan.sample.SampleClass$1);
Code:
0: aload_0
1: aload_1
2: aload_2
3: invokespecial #40 // Method "<init>":(Lcom/meituan/sample/SampleClass;Ljava/lang/String;)V
6: return
这里出现了两个构造方法,编译器自动生成了一个包访问性的构造方法,不过传进来的小尾com.meituan.sample.SampleClass$1就是一个空的类,只有类的定义,其他的啥也没有。
如果事情都是这么简单就好了,这个问题也不难用反射来解决,但是这边存在着两个问题:
像这种匿名内部类名字(数字部分)可能会随着每次打包发生改变的。
当项目中ProGuard力度比较大的时候,内部类的构造方法的访问性会被修改为public,然后编译器生成的方法被优化掉。
第一个问题还容易解决,第二个问题就有点棘手,不确定各个业务方ProGuard力度优化到什么地步,为了避免反射的方法找不到,只好采取一种保守的措施,制作补丁的时候把内部类构造方法的访问性改为public,然后直接反射这个public的构造函数。这样做就避免了编译器优化这一步,确保可以反射到正确的构造方法。
2. ProGuard的优化
ProGuard的相关优化工作是这次补丁自动化的难点。在此之前,我们先来简单了解一下ProGuard做了一些什么事。
从ProGuard的工作流来看,ProGuard做的工作基本主要包含:压缩、优化、混淆以及最后的校验。体现到代码层面上做的事情就是:混淆类名、方法名、字段名,修改方法、字段访问性,删除方法(上例中内部类的构造方法),方法的内联,甚至是减少方法的参数(这就改变了方法签名)等等。大体可以总结为三大问题:混淆、优化、内联,其中优化相关操作,比如说改变方法签名和删除方法,我们可以把这类问题划归到内联,因为在优化后的代码里面这些方法和内联的方法一样,都消失了。
首先来说对于混淆这部分处理思路并不困难,可以在Smali汇编语言那层做字符串的替换,不过需要确保不会引入其他问题,这部分操作需要慎重。而对于内联问题的处理,就有点麻烦,因为内联(ProGuard优化工作可以被当做内联来统一处理)的方法在最终的Apk中是不存在的,所以需要略施小计,把消失的方法给“补”上来。对于这些问题的详细解决办法,听我们一一道来。
对于ProGuard修改访问性的问题,使用反射的方式可以很好地解决这个问题,但是这样可能会引入一个问题,由于ProGuard之后,各个方法和字段的名字混淆为简单字母,比如a、b之类的,子类和父类很大可能行会出现不同的方法或者字段被混淆成一样简单字母。如下例:
public class Parent {
private String first=null;
//混淆为c
private void privateMethod(String fir){
System.out.println(fir);
}
//混淆为b
public void setFirst(String fir){
first=fir;
Parent children=new Children();
children.privateMethod("Robust");
}
}
public class Children extends Parent{
private String sencod=null;
//混淆为c
public void setSecond(String fir){
this.sencod=fir;
}
}
设想这样一种情况,如果我们对Parent的setFirst方法制作补丁,自然而然就会对children.privateMethod方法反射,此时privateMethod被混淆成为c,此时当前的对象实际类型是Children,此时在children实例上反射方法c的话,会反射到哪里呢?反射到了setSecond,这和预期是不一致的,我们想要反射的privateMethod方法。
这个问题的解决办法就是在反射的时候,加强对反射条件限制,强制校验反射的方法或者字段的声明类,如果在反射的时候就知道方法c是类Parent中的方法的话,就可以解决这个问题,在反射的时候就需要多传递一个方法的声明类。
内联问题的共同点是在ProGuard优化之后的Apk中均找不到这些方法,解决办法就是把这些消失的内联方法给“补”上来。我们采取的办法是,为这些内联的方法创建对应的内联类,在内联类里面仅包含这些内联的方法,然后在补丁中携带这些内联类,最后再把代码中调用内联方法的地方修改为调用补丁中内联类的对应方法,这个操作分为几步,最终实现了在补丁中把对应的内联方法“补”上。比如上例的privateMethod被内联了。则补丁应该如下:
public class Parent {
private String first=null;
//privateMethod被内联了
// private void privateMethod(String fir){
// System.out.println(fir);
//}
public void setFirst(String fir){
first=fir;
Parent children=new Children();
//children.privateMethod("Robust");
//内联替换的逻辑
ParentInline inline= new ParentInline(children);
inline.privateMethod("Robust");
}
}
public class ParentInline{
private Parent children ;
public ParentInline(Parent p){
children=p;
}
//混淆为c
public void privateMethod(String fir){
System.out.println(fir);
}
}
当ProGuard力度的不断增大,可能会出现多级内联的问题,最担心会出现循环内联的问题(笔者目前没有遇到这种情况),比如说类A的methodA1内联了类B的methodB1方法,而与此同时类B的methodB2内联类A的methodA2方法,这样就可能会出现一个循环内联问题,导致创建内联类代码陷入死循环。我们解决这种问题的方法是,首先扫描出所有内联的类,为它们创建一个包含内联方法的hook内联类,这个hook内联类里面方法的实现不重要,仅仅是为了编译可以通过,内联方法的地方修改为调用hook的内联类,最后再把hook内联类的方法实现。
总结
补丁自动化过程中遇到问题远远不止上述的几个问题,想要针对形形色色的代码风格以及不同ProGuard力度成功的制作出可用的补丁,并非一件容易的事情,比想象的要复杂的多。一路风雨飘摇的自动化补丁,经过我们的不懈努力之后,最终渐渐地稳定,可以完美的针对多种代码风格生成补丁。