ProGuard 又搞了個大新聞
一般情況下,Android項目經常開啟ProGuard功能來混淆代碼,一方面可以降低應用被反編譯后代碼的友善度,增加被逆向的難度,另一方面開可以通過精簡Java API的名字來減少代碼的總量,從而精簡應用編譯后的體積。
ProGuard有個比較坑爹的問題。在開發階段,我們一般不啟用ProGuard,只有在構建Release包的時候才開啟。因此,如果有一些API被混淆了會出現BUG,那么在開發階段我們往往無法察覺BUG,只有在構建發布包的時候才發現,甚至要等發布到線上了才能發現,這種時候解決問題的成本就很大了。
不過今天被ProGuard坑的不是混淆API導致的BUG,這貨在之前相當長的一段時間里一直相安無事,最近突然又搞了個大新聞,而且問題排查起來相當蹊蹺、詭異。
新聞發生時候的背景
最近在給項目的開發一個模塊之間通訊用的路由框架,它需要有一些處理注解的APT功能,大概是長這個樣子的。
@Route(uri = "action://sing/", desc = "念兩句詩")
public static classPoemAction{
...
}
功能大概是這樣的,我先編寫一個叫做 PoemAction ,它的業務功能主要是幫你念上兩句詩。然后客戶只需要調用 Router.open("action://sing/") 就可以當場念上兩句詩,這也是現在一般路由框架的功能。其中的 desc 沒有別的功能,只是為了在生成路由表的時候加上一些注釋,說明當前的路由地址是干什么的,看起來像是這樣的。
public static classAutoGeneratedRouteTable{
publicRoutefind(String uri){
...
if("action://sing/".equals(uri)) {
// 念兩句詩
return PoemActionRoute;
}
...
}
}
嗯,代碼很完美,單元測試和調試階段都沒有發現任何問題,好,合并進develop分支了。搞定收工,我都不禁想贊美自己的才能了,先去棲霞路玩會兒先。半個小時候突然收到了工頭 Yrom·半仙·靈魂架構師·Wang 的電話,我還以為他也想來玩呢,結果他說不知道誰在項目的代碼里下毒,導致構建機上有已經有幾十個構建任務失敗了。我了個去,我剛剛提交的代碼,該不會是我的鍋吧,趕緊回來。
問題排查過程
異常看起來是這樣的。
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功能,問題還是沒有重現,這…… 這與構建出問題的狀態的差別只有一段注釋的差別啊,沒問題的代碼看起來是這樣。
public static classAutoGeneratedRouteTable{
publicRoutefind(String uri){
...
if("action://sing/".equals(uri)) {
(這里的注釋沒有了)
return PoemActionRoute;
}
...
}
}
這難道是真實存在的某種膜法在干擾我的構建過程?突然我又想起來,因為注解類本身不需要寫什么代碼,所以我創建 Route.java 這個類后基本就沒有對它進行過編輯了,我甚至已經忘了我對它寫過什么代碼,所以我決定看看是不是我寫錯了些什么。
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.CLASS)
public @interface Route {
String[] value();
Stringdesc()default"";
}
這個注解類看起來再普通不過,一般寫完之后也不需要再怎么修改了,而且這個類我是直接參(co)考(py)另外一個優秀的Java APT項目 DeepLinkDispatch 的,想必也不會有什么大坑。目前看起來唯一有更改可能性的地方就是 Target 和 Retention 這兩個屬性,至于這倆的作用不屬于此文章的范疇,不做展開。
首先, 我試著把 Retention 的級別由原來的 CLASS 改成 SOURCE 級別,沒想到就這么一個小改動,編譯居然通過了!如果不修改 Retention 的級別,把注解里的 desc 字段移除,只保留一個 value 字段,問題也能解決,真是神奇啊 ,頓時我好像感受到了一股來自古老東方的神秘力量。
在我一直以來的認知里, RetentionPolicy.SOURCE 是源碼級別的注解,比如 @Override 、 @WorkerThread 、 @VisibleForTest 等這些注解類,這類的注解一般是配合IDE工作的,不會給代碼造成任何實際影響,IDE會獲取這些注解,并向你提示哪些代碼可能有問題,在編譯階段這類注解加與不加沒有任何實際的影響。看一下源碼的解釋吧。
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 文件。
...
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 這個類里面來,以下是這個類在執行任務時候做的工作。
@Override
publicvoidtransform(@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的主要工作是在 shrinkWithProguard 和 computeList 兩個方法里面完成的。其中 shrinkWithProguard 的工作可以定位到ProGuard工具的 ProGuard#execute 方法里面。
publicvoidexecute()throwsIOException
{
System.out.println(VERSION);
GPL.check();
...
if (configuration.dump != null)
{
dump();
}
}
可以定位到ProGuard最后執行的 dump() 方法里面,該方法生成了一個 dump.txt 文件,里面用文本的形式,記錄了整個項目用到的所有類(混淆后的)的文件結構。查看任務的LOG信息以及 dump.txt 文件的內容,發現所有內容都正常生成,因此可以初步確定問題不是由于 shrinkWithProguard 引起的。
接著看看 computeList 方法,這個方法可以定位到以下代碼。
publicSet<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.jar 和 build/intermediates/transforms/proguard/release/jars/3/1f/main.jar 兩個文件。直接調用該命令試試。
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 的源碼。
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
*/
publicvoidadd(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 命令輸出更多的錯誤堆棧,從而直觀地看出一些貓膩來。
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 。
publicstaticvoidmain(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 出問題的地方。
來自:http://kaedea.com/2017/03/20/android/naughty-proguard/