Kaede Akatsuki

中二病也要开发 Android

ProGuard 又搞了个大新闻

一般情况下,Android 项目经常开启 ProGuard 功能来混淆代码,一方面可以降低应用被反编译后代码的友善度,增加被逆向的难度,另一方面开可以通过精简 Java API 的名字来减少代码的总量,从而精简应用编译后的体积。

ProGuard 有个比较坑爹的问题。在开发阶段,我们一般不启用 ProGuard,只有在构建 Release 包的时候才开启。因此,如果有一些 API 被混淆了会出现 BUG,那么在开发阶段我们往往无法察觉 BUG,只有在构建发布包的时候才发现,甚至要等发布到线上了才能发现,这种时候解决问题的成本就很大了。
不过今天被 ProGuard 坑的不是混淆 API 导致的 BUG,这货在之前相当长的一段时间里一直相安无事,最近突然又搞了个大新闻,而且问题排查起来相当蹊跷、诡异。

新闻发生时候的背景

最近在给项目的开发一个模块之间通讯用的路由框架,它需要有一些处理注解的 APT 功能,大概是长这个样子的。

1
2
3
4
@Route(uri = "action://sing/", desc = "念两句诗")
public static class PoemAction {
...
}

功能大概是这样的,我先编写一个叫做 PoemAction,它的业务功能主要是帮你念上两句诗。然后客户只需要调用 Router.open ("action://sing/") 就可以当场念上两句诗,这也是现在一般路由框架的功能。其中的 desc 没有别的功能,只是为了在生成路由表的时候加上一些注释,说明当前的路由地址是干什么的,看起来像是这样的。

1
2
3
4
5
6
7
8
9
public static class AutoGeneratedRouteTable {
public Route find(String uri) {
...
if("action://sing/".equals(uri)) {
// 念两句诗return PoemActionRoute;
}
...
}
}

嗯,代码很完美,单元测试和调试阶段都没有发现任何问题,好,合并进 develop 分支了。搞定收工,我都不禁想赞美自己的才能了,先去栖霞路玩会儿先。半个小时候突然收到了工头 Yrom・半仙・灵魂架构师・Wang 的电话,我还以为他也想来玩呢,结果他说不知道谁在项目的代码里下毒,导致构建机上有已经有几十个构建任务失败了。我了个去,我刚刚提交的代码,该不会是我的锅吧,赶紧回来。

问题排查过程

异常看起来是这样的。

text
1
2
3
4
5
6
7
8
9
10
FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':app:transformClassesWithMultidexlistForRelease'.
> java.lang.UnsupportedOperationException (no error message)

* Try:
Run with --stacktrace option to get the stack trace. Run with --info or --debug option to get more log output.

BUILD FAILED

这看起来好像是 MultiDex 的问题啊,但是没道理 Debug 构建没问题,而只有 Release 构建出问题了,transformClassesWithMultidexlistForRelease 任务的源码暂时也没有精力去看了,先解决阻塞同事开发的问题要紧。老规矩,使用 二分定位法 挨个回滚到 develop 上面的 commit 记录,逐个查看是那次提交导致的,结果还真是我的提交导致的。

难道是开了混淆,导致一些类找不到?但是类找不到只是运行时的异常而已,应该只会在运行 APP 的时候抛出 “ClassNotFoundException”,不应该导致构建失败啊。难道是 APT 生成的类格式不对,导致 Javac 在编译该类的时候失败?于是我打开由 APT 工具生成的 AutoGeneratedRouteTable.java 类文件瞧瞧,发文件类的格式很完美,没有问题,甚至由于担心是中文引起的问题,我还把 “念两句诗” 改成 “Sing two poems”,问题依旧。

总之一时半会无法排查出问题所在,还是赶紧解决 APK 的构建问题,现在因为构建失败的原因,旁边已经有一票同事正在摩拳擦掌准备把我狠狠的批判一番。所以我打算先去掉 APT 功能,不通过自动生成注册类的方式,而是通过手动代码注册的方式让路由工作,就当我以为事情告一段落的时候,我才发现我还是 “too young” 啊,构建机给了同样的错误反馈。

…………
……

这 TM 就尴尬了啊,我现在导致构建失败的提交与上一次正常构建的提交之间的差异就是给 PeomAction 加多了注解而已啊,而且这个注解现在都没有用到了,难道是注解本身的存在就会导致构建失败?

突然我想起来,注解类本身我是没有加入混淆的,因为代码里没有用反射的反射获取注解,而且我设计注解类本身的目的也只是为了帮我自动生成注册类而已,这些类是编译时生成的,所以不会受到混淆功能的影响。抱着死马当活马医的心态,我把注解里面的 desc 字段去掉了,万万没想到构建问题居然就解决了,而且就算我开启 APT 功能,问题还是没有重现,这…… 这与构建出问题的状态的差别只有一段注释的差别啊,没问题的代码看起来是这样。

1
2
3
4
5
6
7
8
9
10
public static class AutoGeneratedRouteTable {
public Route find(String uri) {
...
if("action://sing/".equals(uri)) {
(这里的注释没有了)
return PoemActionRoute;
}
...
}
}

这难道是真实存在的某种膜法在干扰我的构建过程?突然我又想起来,因为注解类本身不需要写什么代码,所以我创建 Route.java 这个类后基本就没有对它进行过编辑了,我甚至已经忘了我对它写过什么代码,所以我决定看看是不是我写错了些什么。

1
2
3
4
5
6
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.CLASS)
public @interface Route {
String[] value();
String desc() default "";
}

这个注解类看起来再普通不过,一般写完之后也不需要再怎么修改了,而且这个类我是直接参 (co) 考 (py) 另外一个优秀的 Java APT 项目 DeepLinkDispatch 的,想必也不会有什么大坑。目前看起来唯一有更改可能性的地方就是 TargetRetention 这两个属性,至于这俩的作用不属于此文章的范畴,不做展开。

首先, 我试着把 Retention 的级别由原来的 CLASS 改成 SOURCE 级别,没想到就这么一个小改动,编译居然通过了!如果不修改 Retention 的级别,把注解里的 desc 字段移除,只保留一个 value 字段,问题也能解决,真是神奇啊 ,顿时我好像感受到了一股来自古老东方的神秘力量。

在我一直以来的认知里,RetentionPolicy.SOURCE 是源码级别的注解,比如 @Override@WorkerThread@VisibleForTest 等这些注解类,这类的注解一般是配合 IDE 工作的,不会给代码造成任何实际影响,IDE 会获取这些注解,并向你提示哪些代码可能有问题,在编译阶段这类注解加与不加没有任何实际的影响。看一下源码的解释吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public enum RetentionPolicy {
/**
* Annotations are to be discarded by the compiler.
*/
SOURCE,

/**
* Annotations are to be recorded in the class file by the compiler
* but need not be retained by the VM at run time. This is the default
* behavior.
*/
CLASS,

/**
* Annotations are to be recorded in the class file by the compiler and
* retained by the VM at run time, so they may be read reflectively.
*
* @see java.lang.reflect.AnnotatedElement
*/
RUNTIME
}

原来如此,RetentionPolicy.CLASS 级别的注解会被保留到 .class 文件里,所以混淆的时候,注解类也会参与混淆,大概是混淆的时候出的问题吧。总之,先看看注解类 Route.java 被混淆后变成什么样子,查看 build/output/release/mapping.txt 文件。

text
1
2
3
4
5
...
moe.studio.router.Route -> bl.buu:
java.lang.String[] value() -> a
java.lang.String desc() -> a
...

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

所以,最后的解决方案就是把 Retention 的级别由原来的 CLASS 降级成 SOURCE,或者把注解类的字段改成一个。顺便一说,现在大多的 Java APT 项目用的还是 CLASS,它们之所以没有遇到类似的问题,大多是因为他们都选择把整个注解类都 KEEP 住,不进行混淆了。

一些姿势

通过这个事件我也发现了不少问题。其一,无论单元测试写得再完美,集成进项目之前还是有必要进行一次 Release 构建,以确保避免一些平时开发的时候容易忽略的问题,不然小心自己打自己的脸。以下是一次打脸现场。

所以我决定,给项目的构建机加上一次 Daily Building 的功能,每天都定期构建一次,以便尽早发现问题。

其二,除了构建的问题之外,年轻人果然还是要多多学习, 提高一下自己的知识水平 。设想,如果我的 Java 基础够扎实的话,也就不会像这次一样,犯下 RetentionPolicy 错用这样低级的错误。如果有仔细阅读过 transformClassesWithMultidexlistForRelease 任务以及 ProGuard 工具的的源码的话,也许能很快定位到问题发生的根本原因,从而釜底抽薪一举解决问题,不像这次一样,阻塞一大半天开发进度。

以下放出这次定位问题的大致过程。
① 先定位 transformClassesWithMultidexlistForRelease 任务的源码。通过任务名字,可以很快地定位到 MultiDexTransform.java 这个类里面来,以下是这个类在执行任务时候做的工作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
    @Override
public void transform(@NonNull TransformInvocation invocation)
throws IOException, TransformException, InterruptedException {
// Re-direct the output to appropriate log levels, just like the official ProGuard task.
LoggingManager loggingManager = invocation.getContext().getLogging();
loggingManager.captureStandardOutput(LogLevel.INFO);
loggingManager.captureStandardError(LogLevel.WARN);

try {
File input = verifyInputs(invocation.getReferencedInputs());
shrinkWithProguard(input);
computeList(input);
} catch (ParseException | ProcessException e) {
throw new TransformException(e);
}
}

可以看出,MultiDexTransform 的主要工作是在 shrinkWithProguardcomputeList 两个方法里面完成的。其中 shrinkWithProguard 的工作可以定位到 ProGuard 工具的 ProGuard#execute 方法里面。

1
2
3
4
5
6
7
8
9
10
11
12
public void execute() throws IOException
{
System.out.println(VERSION);

GPL.check();
...

if (configuration.dump != null)
{
dump();
}
}

可以定位到 ProGuard 最后执行的 dump () 方法里面,该方法生成了一个 dump.txt 文件,里面用文本的形式,记录了整个项目用到的所有类(混淆后的)的文件结构。查看任务的 LOG 信息以及 dump.txt 文件的内容,发现所有内容都正常生成,因此可以初步确定问题不是由于 shrinkWithProguard 引起的。

接着看看 computeList 方法,这个方法可以定位到以下代码。

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
public Set<String> createMainDexList(
@NonNull File allClassesJarFile,
@NonNull File jarOfRoots,
@NonNull EnumSet<MainDexListOption> options) throws ProcessException {

BuildToolInfo buildToolInfo = mTargetInfo.getBuildTools();
ProcessInfoBuilder builder = new ProcessInfoBuilder();

String dx = buildToolInfo.getPath(BuildToolInfo.PathId.DX_JAR);
if (dx == null || !new File(dx).isFile()) {
throw new IllegalStateException("dx.jar is missing");
}

builder.setClasspath(dx);
builder.setMain("com.android.multidex.ClassReferenceListBuilder");

if (options.contains(MainDexListOption.DISABLE_ANNOTATION_RESOLUTION_WORKAROUND)) {
builder.addArgs("--disable-annotation-resolution-workaround");
}

builder.addArgs(jarOfRoots.getAbsolutePath());
builder.addArgs(allClassesJarFile.getAbsolutePath());

CachedProcessOutputHandler processOutputHandler = new CachedProcessOutputHandler();

mJavaProcessExecutor.execute(builder.createJavaProcess(), processOutputHandler)
.rethrowFailure()
.assertNormalExitValue();

LineCollector lineCollector = new LineCollector();
processOutputHandler.getProcessOutput().processStandardOutputLines(lineCollector);
return ImmutableSet.copyOf(lineCollector.getResult());
}

从源码可以看出,这里调用了 Android SDK 里面的 dx.jar 工具,入口类是 com.android.multidex.ClassReferenceListBuilder,并传入了两个参数,分别是 jarOfRoots 文件和 allClassesJarFile 文件。

② 定位到 dx.jar 工具里具体出问题的地方,通过上面的分析以及构建失败输出的 LOG,可以看到 Gradle 插件调用了 dx.jar 并传入了 build/intermediates/multi-dex/release/componentClasses.jarbuild/intermediates/transforms/proguard/release/jars/3/1f/main.jar 两个文件。直接调用该命令试试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Exception in thread "main" com.android.dx.cf.iface.ParseException: name already added: string{"a"}
at com.android.dx.cf.direct.AttributeListParser.parse(AttributeListParser.java:156)
at com.android.dx.cf.direct.AttributeListParser.parseIfNecessary(AttributeListParser.java:115)
at com.android.dx.cf.direct.AttributeListParser.getList(AttributeListParser.java:106)
at com.android.dx.cf.direct.DirectClassFile.parse0(DirectClassFile.java:558)
at com.android.dx.cf.direct.DirectClassFile.parse(DirectClassFile.java:406)
at com.android.dx.cf.direct.DirectClassFile.parseToEndIfNecessary(DirectClassFile.java:397)
at com.android.dx.cf.direct.DirectClassFile.getAttributes(DirectClassFile.java:311)
at com.android.multidex.MainDexListBuilder.hasRuntimeVisibleAnnotation(MainDexListBuilder.java:191)
at com.android.multidex.MainDexListBuilder.keepAnnotated(MainDexListBuilder.java:167)
at com.android.multidex.MainDexListBuilder.<init>(MainDexListBuilder.java:121)
at com.android.multidex.MainDexListBuilder.main(MainDexListBuilder.java:91)
at com.android.multidex.ClassReferenceListBuilder.main(ClassReferenceListBuilder.java:58)
Caused by: java.lang.IllegalArgumentException: name already added: string{"a"}
at com.android.dx.rop.annotation.Annotation.add(Annotation.java:208)
at com.android.dx.cf.direct.AnnotationParser.parseAnnotation(AnnotationParser.java:264)
at com.android.dx.cf.direct.AnnotationParser.parseAnnotations(AnnotationParser.java:223)
at com.android.dx.cf.direct.AnnotationParser.parseAnnotationAttribute(AnnotationParser.java:152)
at com.android.dx.cf.direct.StdAttributeFactory.runtimeInvisibleAnnotations(StdAttributeFactory.java:616)
at com.android.dx.cf.direct.StdAttributeFactory.parse0(StdAttributeFactory.java:93)
at com.android.dx.cf.direct.AttributeFactory.parse(AttributeFactory.java:96)
at com.android.dx.cf.direct.AttributeListParser.parse(AttributeListParser.java:142)
... 11 more

从异常的堆栈可以直接看出,dx 工具在执行 AnnotationParser#parseAnnotation 方法的时候出错了,原因是有两个相同的字段 a,这也刚好印证了上面 mapping.txt 文件里面的错误信息。

③ 最后定位到源码里具体出问题的地方,查看 dx 工具里的 com.android.dx.rop.annotation.Annotation.java 的源码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
    private final TreeMap<CstString, NameValuePair> elements;
/**
* Add an element to the set of (name, value) pairs for this instance.
* It is an error to call this method if there is a preexisting element
* with the same name.
*
* @param pair {@code non-null;} the (name, value) pair to add to this instance
*/public void add(NameValuePair pair) {
throwIfImmutable();
if (pair == null) {
throw new NullPointerException("pair == null");
}
CstString name = pair.getName();
if (elements.get(name) != null) {
throw new IllegalArgumentException("name already added: " + name);
}
elements.put(name, pair);
}

到此, 从成功定位到产生异常的具体地方

④ 此外,从 :app:assembleRelease --debug --stacktrace 的异常堆栈里是无法直接看出具体出异常的地方的错误信息的,不过可以通过 :app:assembleRelease --full-stacktrace 命令输出更多的错误堆栈,从而直观地看出一些猫腻来。

1
2
3
4
5
6
7
Caused by: com.android.ide.common.process.ProcessException: Error while executing java process with main class com.android.multidex.ClassReferenceListBuilder with arguments {build/intermediates/multi-dex/release/componentClasses.jar build/intermediates/transforms/proguard/release/jars/3/1f/main.jar}
at com.android.build.gradle.internal.process.GradleProcessResult.buildProcessException(GradleProcessResult.java:74)
at com.android.build.gradle.internal.process.GradleProcessResult.assertNormalExitValue(GradleProcessResult.java:49)
at com.android.builder.core.AndroidBuilder.createMainDexList(AndroidBuilder.java:1384)
at com.android.build.gradle.internal.transforms.MultiDexTransform.callDx(MultiDexTransform.java:309)
at com.android.build.gradle.internal.transforms.MultiDexTransform.computeList(MultiDexTransform.java:265)
at com.android.build.gradle.internal.transforms.MultiDexTransform.transform(MultiDexTransform.java:186)

从上面的堆栈信息可以直接看出 Gradle 插件在调用 dx 工具的时候出现异常了(Process 的返回值不是 0,也就是 Java 程序里面调用了 System.exit (0) 之外的结束方法),对应的类为 ClassReferenceListBuilder

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
public static void main(String[] args) {
int argIndex = 0;
boolean keepAnnotated = true;
while (argIndex < args.length -2) {
if (args[argIndex].equals(DISABLE_ANNOTATION_RESOLUTION_WORKAROUND)) {
keepAnnotated = false;
} else {
System.err.println("Invalid option " + args[argIndex]);
printUsage();
System.exit(STATUS_ERROR);
}
argIndex++;
}
if (args.length - argIndex != 2) {
printUsage();
System.exit(STATUS_ERROR);
}
try {
MainDexListBuilder builder = new MainDexListBuilder(keepAnnotated, args[argIndex],
args[argIndex + 1]);
Set<String> toKeep = builder.getMainDexList();
printList(toKeep);
} catch (IOException e) {
System.err.println("A fatal error occurred: " + e.getMessage());
System.exit(STATUS_ERROR);
return;
}
}

由其中的 MainDexListBuilder builder = new MainDexListBuilder (keepAnnotated, args [argIndex], args [argIndex + 1]) 也能进一步定位到上面的 com.android.dx.rop.annotation.Annotation.java 出问题的地方。

参考

推荐阅读 ProGuard 在插件化里的应用