Kaede Akatsuki

中二病也要开发 Android

西方程序员跑得比谁都快

昨天刚刚发表了一篇文章(ProGuard又搞了个大新闻),主要吐槽的是项目里面使用ProGuard工具导致的一个诡异的坑。其中根本的原因就是,ProGuard混淆Java注解类的时候,把两个方法混淆成同样的名字,导致dx工具在打包.dex文件的时候报错。

本来以为这件事情算是告一段落了,没想到自己还是太Naive了。今天早上突然收到了ProGuard开发者发来的一份邮件,Exciting!邮件里谈到了这次的坑出现的真正原因 —— Java源码和字节码(bytecode)里方法的重载(OverLoading)。

被雪藏的问题真正原因

在上一篇文章里,我分析到这次问题的原因是

ProGuard工具在混淆注解类类Route.java的时候,把它的两个字段都混淆成a了,按道理应该是一个a和一个b,不知道是不是ProGuard的BUG,还是Route与其他库冲突了。

本来我以为是ProGuard的BUG,把注解类的两个字段都混淆成一样的名字,或者是ProGuard受到别的库的影响才出现了这个BUG。显然,在Java代码里面,是不允许有两个名字相同且形参一样的方法的,哪怕是它们的返回值不同。

1
2
3
4
5
6
7
8
public static class Hello {
public String[] foo() {
return new String[]{"wor", "ld"};
}
public String foo() {
return "world";
}
}

这两个方法是无法重载的,IDE会提示错误并且无法编译。虽然现在不少的新编程语言支持这样返回值类型不同的方法重载,但是在Java里行不通,原因也很简单,类似下面的方法立刻就会产生歧义。

1
2
3
4
public void call() {
// 无法确定调用的是哪个方法。
foo();
}

问题的原因虽然只是这么简单,但是其实在.class文件的字节码(bytecode)里,这样的重载方法是被允许的。为什么呢?简单点说,在字节码里面,对类的文件结构的描述十分严谨,方法调用必须有指定的返回类型,所以像上面那样的调用是不存在的,自然也就不存在产生歧义的问题。

假设现在有这样一个正常的类(上面的示例代码的正常版)。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Hello {
public String[] foo1() {
return new String[]{"wor", "ld"};
}
public String foo2() {
return "world";
}

public void main() {
foo1();
String s = foo2();
}
}

这个类编译成.class字节码文件后,它的文件结构大概是这样的。

Text
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
+ Program class: com/bilibili/routertest/Hello
...
Interfaces (count = 0):
Constant Pool (count = 30):
...
Fields (count = 0):

Methods (count = 4):
- Method: <init>()V
Access flags: 0x1
= public Hello()
...

+ Method: foo1()[Ljava/lang/String;
Access flags: 0x1
= public java.lang.String[] foo1()
...

+ Method: foo2()Ljava/lang/String;
Access flags: 0x1
= public java.lang.String foo2()
...

+ Method: main()V
Access flags: 0x1
= public void main()
Class member attributes (count = 1):
+ Code attribute instructions (code length = 11, locals = 2, stack = 1):
[0] aload_0 v0
[1] invokevirtual#7
+ Methodref [com/bilibili/routertest/Hello.foo1 ()[Ljava/lang/String;]
[4] pop
[5] aload_0 v0
[6] invokevirtual#8
+ Methodref [com/bilibili/routertest/Hello.foo2 ()Ljava/lang/String;]
[9] astore_1 v1
[10] return
Code attribute exceptions (count = 0):
Code attribute attributes (attribute count = 1):
+ Line number table attribute (count = 3)
[0] -> line 12
[5] -> line 13
[10] -> line 14

Class file attributes (count = 1):
...

我们重点关心其中的main()V方法,可以清楚的看到,上面的Java源码中,main方法调用了foo1方法,虽然没有处理返回值,但是在字节码文件结构对应的方法里明确地指明了改该方法的的返回值类型是[Ljava/lang/String,区别于foo2方法的Ljava/lang/String。也就是说,字节码里面并不会存在我们上面提到的方法调用的歧义问题,因此可以支持相同形参不同返回值的方法的重载。

对于这个课题感兴趣的同学可以参考这篇出自Oracle的调研文章:Return-Type-Based Method Overloading in Java Blog

总结一些人参经验

关于造成该问题原因的一些阐述。

  1. 上一篇文章提到的ProGuard构建问题其实不是ProGuard的BUG,而是Android SDK的dx工具的BUG。
  2. 不是只有在开启MultiDex的时候才会出现这个问题,不开启问题也会存在,这个问题与MultiDex完全没有关系。
  3. ProGuard混淆的是字节码而不是Java源码,字节码支持相同形参不同返回值的方法的重载,ProGuard为了最大限度压缩代码量,对后者的重载提供了支持。
  4. 不仅注解类,普通的类也会出现类似的问题。

解决该问题的一些方法。

  1. 如果不开启ProGuard的overloadaggressively功能,ProGuard不会对字节码中相同形参不同返回值的方法进行重载(这个功能默认不开启)。
  2. 尝试将注解类的RetentionPolicy级别降级为SOURCE级别。
  3. 不要让注解类出现相同形参不同返回值不同名字的方法,不然可能被混淆成重载的方法。
  4. Keep住相应的注解类。

以下是ProGuard开发者给出的建议。

Text
1
2
3
4
5
6
7
8
9
10
11
Unfortunately, dx has a bug: it crashes on this overloading. Workarounds:

- Do not use the option '-overloadaggressively' in your ProGuard configuration.

- Alternatively, keep the original annotation method names:

-keepclassmembernames @interface * { <methods>; }

The dx tool should then accept the code.

If it works, you can post this solution in your blog.

最后,感叹作者的反馈这么迅速。引用作者的一句原话,It's a fast world!,西方程序员跑的比谁都快。