ProGuard 又搞了個大新聞

通常狀況下,Android項目常常開啓ProGuard功能來混淆代碼,一方面能夠下降應用被反編譯後代碼的友善度,增長被逆向的難度,另外一方面開能夠經過精簡Java API的名字來減小代碼的總量,從而精簡應用編譯後的體積。java

ProGuard有個比較坑爹的問題。在開發階段,咱們通常不啓用ProGuard,只有在構建Release包的時候纔開啓。所以,若是有一些API被混淆了會出現BUG,那麼在開發階段咱們每每沒法察覺BUG,只有在構建發佈包的時候才發現,甚至要等發佈到線上了才能發現,這種時候解決問題的成本就很大了。android

不過今天被ProGuard坑的不是混淆API致使的BUG,這貨在以前至關長的一段時間裏一直相安無事,最近忽然又搞了個大新聞,並且問題排查起來至關蹊蹺、詭異。git

新聞發生時候的背景

最近在給項目的開發一個模塊之間通信用的路由框架,它須要有一些處理註解的APT功能,大概是長這個樣子的。github

@Route(uri = "action://sing/", desc = "念兩句詩")
public static class PoemAction {
    ...
}

功能大概是這樣的,我先編寫一個叫作 PoemAction,它的業務功能主要是幫你念上兩句詩。而後客戶只須要調用 Router.open("action://sing/") 就能夠當場念上兩句詩,這也是如今通常路由框架的功能。其中的desc沒有別的功能,只是爲了在生成路由表的時候加上一些註釋,說明當前的路由地址是幹什麼的,看起來像是這樣的。架構

public static class AutoGeneratedRouteTable {
    public Route find(String uri) {
        ...
        if("action://sing/".equals(uri)) {
            // 念兩句詩
            return PoemActionRoute;
        }
        ...
    }
}

嗯,代碼很完美,單元測試和調試階段都沒有發現任何問題,好,合併進develop分支了。搞定收工,我都不由想讚美本身的才能了,先去棲霞路玩會兒先。半個小時候忽然收到了工頭 Yrom·半仙·靈魂架構師·Wang 的電話,我還覺得他也想來玩呢,結果他說不知道誰在項目的代碼裏下毒,致使構建機上有已經有幾十個構建任務失敗了。我了個去,我剛剛提交的代碼,該不會是個人鍋吧,趕忙回來。app

問題排查過程

異常看起來是這樣的。框架

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記錄,逐個查看是那次提交致使的,結果還真是個人提交致使的。ide

難道是開了混淆,致使一些類找不到?可是類找不到只是運行時的異常而已,應該只會在運行APP的時候拋出「ClassNotFoundException」,不該該致使構建失敗啊。難道是APT生成的類格式不對,致使Javac在編譯該類的時候失敗?因而我打開由APT工具生成的AutoGeneratedRouteTable.java類文件瞧瞧,發文件類的格式很完美,沒有問題,甚至因爲擔憂是中文引發的問題,我還把「念兩句詩」改爲「Sing two poems」,問題依舊。工具

總之一時半會沒法排查出問題所在,仍是趕忙解決APK的構建問題,如今由於構建失敗的緣由,旁邊已經有一票同事正在摩拳擦掌準備把我狠狠的批判一番。因此我打算先去掉APT功能,不經過自動生成註冊類的方式,而是經過手動代碼註冊的方式讓路由工做,就當我覺得事情告一段落的時候,我才發現我仍是「too young」啊,構建機給了一樣的錯誤反饋。單元測試

…………
……

這TM就尷尬了啊,我如今致使構建失敗的提交與上一次正常構建的提交之間的差別就是給PeomAction加多了註解而已啊,並且這個註解如今都沒有用到了,難道是註解自己的存在就會致使構建失敗?

忽然我想起來,註解類自己我是沒有加入混淆的,由於代碼裏沒有用反射的反射獲取註解,並且我設計註解類自己的目的也只是爲了幫我自動生成註冊類而已,這些類是編譯時生成的,因此不會受到混淆功能的影響。抱着死馬當活馬醫的心態,我把註解裏面的desc字段去掉了,萬萬沒想到構建問題竟然就解決了,並且就算我開啓APT功能,問題仍是沒有重現,這…… 這與構建出問題的狀態的差異只有一段註釋的差異啊,沒問題的代碼看起來是這樣。

public static class AutoGeneratedRouteTable {
    public Route find(String uri) {
        ...
        if("action://sing/".equals(uri)) {
            (這裏的註釋沒有了)
            return PoemActionRoute;
        }
        ...
    }
}

這難道是真實存在的某種膜法在干擾個人構建過程?忽然我又想起來,由於註解類自己不須要寫什麼代碼,因此我建立Route.java這個類後基本就沒有對它進行過編輯了,我甚至已經忘了我對它寫過什麼代碼,因此我決定看看是否是我寫錯了些什麼。

@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會獲取這些註解,並向你提示哪些代碼可能有問題,在編譯階段這類註解加與不加沒有任何實際的影響。看一下源碼的解釋吧。

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
    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方法裏面。

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方法,這個方法能夠定位到如下代碼。

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兩個文件。直接調用該命令試試。

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
     */
    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命令輸出更多的錯誤堆棧,從而直觀地看出一些貓膩來。

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

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在插件化裏的應用

著做信息:
本文章出自 Kaede 的博客,原創文章若無特別說明,均遵循 CC BY-NC 4.0 知識共享許可協議4.0(署名-非商用-相同方式共享),能夠隨意摘抄轉載,但必須標明署名及原地址。

相關文章
相關標籤/搜索