前言

合并方法指的是把多个方法合并成一个方法,从而达到减少方法数量,增加分析难度的目的。

前面提到过,Android中Dex有65536个方法数量的限制,因而进行方法合并有利于缓解此限制。另外上一章提到的重构方法方案会大量的增加方法,如果把增加的方法进行合并,那么可缓解增加方法的数量。

一些问题

合并方法时,需要修改合并后的方法签名,而有些方法是对外提供的接口,是不能合并的。如Android中的Activity等四大组件中继承的一些特定方法,插件化框架中对外提供的接口等等。

被合并方法选择

1、只要被Proguard混淆过的方法,大部分都可以进行合并,而未混淆的代码,则由于可能有各种各样的潜规则(如JNI或其它地方反射调用该方法、作为插件化框架所提供的接口等),而需要通过配置Keep住。

2、没继承关系的方法,皆可进行处理。

3、有继承关系的方法,虽有部分能处理,但经测试量较少,且处理成本非常大,因而不建议处理。

4、重构产生的方法,均可以进行合并处理

合并原理(基于bytecode)

1、实例方法–>静态方法:
前:

1
2
3
4
5
6
class Book {
int n;
int add(int b) {
return n + b;
}
}
1
2
3
4
5
6
7
8
9
class com/tencent/Book {
I n
add(I)I
ALOAD 0
GETFIELD com/tencent/Book.n : I
ILOAD 1
IADD
IRETURN
}

后:

1
2
3
4
5
6
class Book{
int n;
static int add(Book a, int b) {
return a.n+b;
}
}
1
2
3
4
5
6
7
8
9
class com/tencent/Book {
I n
static add(Lcom/tencent/Book;I)I
ALOAD 0
GETFIELD com/tencent/Book.n : I
ILOAD 1
IADD
IRETURN
}

我们看到,实例方法转静态方法基本没什么要改的,只需要把方法的权限改为静态的,然后在方法参数最前面增加一个当前类的对象参数(即this指针)。

2、多个方法–>一个方法(通用方案):
合并前:

1
2
3
static long add(int a, long b) {
return a+b;
}
1
2
3
4
5
6
static add(IL)L
ILOAD 0
I2L
LLOAD 1
LADD
LRETURN
1
2
3
static long print(String str, long k) {
return str.length() - k;
}
1
2
3
4
5
6
7
static print(Ljava/lang/String;L)L
ALOAD 0
INVOKEVIRTUAL java/lang/String.length ()I
I2L
LLOAD 1
LSUB
LRETURN

合并后:

1
2
3
4
5
6
7
8
9
10
11
static long merge(int which, int a, long b, String str) {
switch(which) {
case 0:
return a+b;
case 1:
return str.length() - k;
default:
return 0;
}

}
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
static merge(IIJLjava/lang/String;)J
L0
ILOAD 0
TABLESWITCH
0: L1
1: L2
default: L3
L1
ILOAD 1
LLOAD 2
LSTORE 1
ISTORE 0
ILOAD 0
I2L
LLOAD 1
LADD
LRETURN
L2
ALOAD 4
LLOAD 2
LSTORE 1
ASTORE 0
ALOAD 0
INVOKEVIRTUAL java/lang/String.length ()I
I2L
LLOAD 1
LSUB
LRETURN
L3
LCONST_0
LRETURN

这个方案支持任意两个方法进行合并,原理是把第一个参数作为识别当前调用的是哪个方法,接着通过switch语句来跳转到对应的代码,然后把新方法的参数加载到对应的栈中,并重新保存成原方法需要的参数位置,这样原方法的代码就全部都不用改了。

注意下,这里用于识别旧方法的参数也可以放在最后面,如果放到最后面,且参数位置安排得当的话,可以减少一些修复指令。

上面限定了返回值一致的方法才能合并,其实返回值不一致的也能合并,不过还需要增加不少额外的处理。这里建议返回值为基本数据类型时,需要一致才进行合并,如果返回值为对象类型时,就返回Object就行,这样只需在修复调用该方法的返回值时增加一个强制类型转换。

同理,如果参数为对象类型,那么都可以当作Object类型,然后再在修复时转换为相应的类型。

3、多个方法–>一个方法(start with方案):
合并前:

1
2
3
static long add(int a, long b) {
return a+b;
}
1
2
3
4
5
6
static add(IL)L
ILOAD 0
I2L
LLOAD 1
LADD
LRETURN
1
2
3
static long print(int m, long k, String str) {
return str.length() - k + m;
}
1
2
3
4
5
6
7
8
9
10
static print(Ljava/lang/String;L)L
ALOAD 3
INVOKEVIRTUAL java/lang/String.length ()I
I2L
LLOAD 1
LSUB
ILOAD 0
I2L
LADD
LRETURN

合并后:

1
2
3
4
5
6
7
8
9
10
11
12
static long merge(int a, long b, String str, int which) {
switch(which) {
case 0:
return a+b;
case 1:
return str.length() - b + a;
default:
return 0;

}

}
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
static merge(IJLjava/lang/String;I)J
L0
ILOAD 4
TABLESWITCH
0: L1
1: L2
default: L3
L1
ILOAD 0
I2L
LLOAD 1
LADD
LRETURN
L2
ALOAD 3
INVOKEVIRTUAL java/lang/String.length ()I
I2L
LLOAD 1
LSUB
ILOAD 0
I2L
LADD
LRETURN
L3
LCONST_0
LRETURN

这个方案要求待合并的方法的参数必须是start with类型(以最长的方法参数为基准,其它方法的参数必须与其前面的参数一一对应)。假设一个最长的方法的参数为IJI,那么参数为IJI、IJ、I的方法才能和它进行合并。

显然,这种方案少了很多修复指令,不过会有一定限制,但经测试发现,使用这种方案进行合并后方法数比通用方案只差一点点,但增加的体积相对通用方案明显少了很多,因此建议优先使用。

4、可内联方法插入到方法调用的位置:

内联方法:

1
2
3
static long add(int a, long b) {
return a+b;
}
1
2
3
4
5
static add(II)I
ILOAD 0
ILOAD 1
IADD
IRETURN

调用方法:

1
2
3
static int count(int a, int b, int c) {
return add(add(a,b),add(b,c));
}
1
2
3
4
5
6
7
8
9
static count(III)I
ILOAD 0
ILOAD 1
INVOKESTATIC Main.add (II)I
ILOAD 1
ILOAD 2
INVOKESTATIC Main.add (II)I
INVOKESTATIC Main.add (II)I
IRETURN

内联方法指令合并到调用方法后:

1
2
3
static int count(int a, int b, int c) {
return (a+b) + (b+c)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static count(III)I
ILOAD 0
ILOAD 1
ISTORE 3
ISTORE 4
ILOAD 4
ILOAD 3
IADD
ILOAD 1
ILOAD 2
ISTORE 3
ISTORE 4
ILOAD 4
ILOAD 3
IADD
ISTORE 3
ISTORE 4
ILOAD 4
ILOAD 3
IADDd
IRETURN

内联方法是指指令比较少的方法,上例中通过增加本地变量来模拟内联方法的参数,当然,使用到参数的指令都需要一一进行修改,如果内联方法有临时变量,也需要把临时变量的位置修改一下。

合并方案说明

1、通用方案会增加非常多的指令,以致于dex体积增加,而除了65536方法数限制外,还有个5M线性内存的限制,因此通用方案可能会导致提前达到5M的限制,不建议使用。

2、内联方法方案在处理插件化框架中,修复成本非常高,且经测试,可内联的方法比例非常低。

3、上面方案中,都是针对没继承关系的来处理,并没列出有继承关系的处理方案,当然有兴趣的可找我拿或自行研究下。这里不列出是因为此方案能处理的方法比例非常不高,且处理成本非常大,因而不建议使用。