我是如何一步一步爬上 「64K限制」 的坑

分享初衷

分享這個填坑的記錄,主要是身邊不少 Androider 都會遇到難以解決的難題並重復走舊路。java

大部分人都會按照這樣的步驟處理:linux

  1. 遇到一個 BUG ,優先按照本身經驗修復;
  2. 修復不了,開始 Google(不要百度,再三強調),尋找一切和咱們 BUG 類似的問題,而後看看有沒有解決方案;
  3. 嘗試了不少解決方案,a 方案不行換 b 方案,b 方案不行換 c 方案,直到沒有方案能夠嘗試了,開始懷疑人生;
  4. 若是影響不大,那就丟在項目裏(估計也沒人發現),若是影響很大,那隻能尋找別人幫助,若是別人也給不了建議,那就原地爆炸了。

不管 BUG 影響多大,丟在項目裏總不太好。 當別人幫助不了的時候,真的只有代碼能幫你。嘗試過不少方案不可行,不少時候是由於每一個方案的背景不同,包括開發環境背景如 Gradle 版本,編譯版本 ,API 版本場景差別化。我遇到的這個問題也是如此。 但願經過如下的記錄能幫助你在面對無能爲力的 BUG 時更堅決地尋找解決方案。android

問題背景

在咱們項目最近的一個版本中,QA 測試 Feature 功能時反饋 4.4 設備上 APP 全 Crash! 因爲反饋該問題時已經快週末了,按 PM 的流程咱們需在下週一封包給兼容測試部門作質量測試,這個問題就必須在週一前解決。

第一反應GG,感受是個大坑。馬上借了兩臺 4.4 的機型對發生 Crash 場景進行調試,發現都是 java.lang.NoClassDefFoundError。 這個crash代表找不到引用的類,這類本來該在 主 Dex 文件中,可是主 Dex 文件中卻沒有提供這個類。
第一反應就是 「難道咱們沒有 keep 住這個類嗎?」 通過排查肯定是構建工具已經把執行了打包該類的邏輯,卻由於某些緣由沒有被打進去。我嘗試使用 mutilDexKeepProguard keep 住這個類,而後編譯直接不經過了。收到的異常爲:git

D8: Cannot fit requested classes in the main-dex file (# methods: 87855 > 65536 ; # fields: 74641 > 65536)

固然有了 LOG 信息就有了解決問題的但願了。github

定位問題

Dex 文件規範明確指出:單個 dex 文件內引用的方法總數只能爲 65536。而這個限制來源因而 davilk 指令中調用方法的引用索引數值,該數值採用 16 位 二進制記錄,也就是 2^16 = 65536,方法數包括了 Android Framework 層方法,第三方庫方法及應用代碼方法。面試

所謂 主dex,其實就是 classes.dex。還可能存在 classes1.dex,classes2.dex...classesN.dex。由於完整項目可能包含超過 65536 個方法,因此須要對項目的 class 進行分 dex 打包。主dex 會被最早加載,必須包含啓動引用所須要的類及「依賴類」(後面會有詳細介紹)。而我所遇到的問題就是 「包含啓動引用所須要的類及「依賴類包含的方法數」 超過 65536 個,構建系統就 「罷工」 不幹了。 api

事實上,在 minsdkVersion >= 21 的應用環境下是不會出現這種異常的。由於構建apk時方法數雖超過 65536 必須分包處理,但因爲使用 ART 運行的設備在加載 APK 時會加載多個 dex 文件。其在安裝時執行預編譯,掃描 classesN.dex 文件,並把他們編譯成單個.oat 文件。因此 「包含啓動引用所須要的類及「依賴類」 能夠散落在不一樣的 dex 文件上。微信

可是 minsdkVersion < 21 就不同了,5.0 如下的機型用的是 Dalvik 虛擬機,在安裝時僅僅會對 主dex 作編譯優化,啓動時直接加載 主dex。若是必要的類被散落到其餘未加載的dex中,則會出現crash。也就是開頭所說的 java.lang.NoClassDefFoundError架構

關於這個 exception 和 java.lang.ClassNoFoundError 很像,可是有比較大的區別,後者在 Android中常見於混淆引發類沒法找到所致。

尋找解決方案

明白了上述背景以後,就要想辦法減小 主dex 裏的類且確保應用可以正常啓動。app

可是官方只告訴咱們 「如何 Keep 類來新增主 dex 裏面的類」,可是沒有告訴咱們怎麼減小啊 !臥槽了...

因而開始 Google + 各類 github/issue 查看關於如何避免 主dex 方法爆炸的方案,全都是幾年前的文章,這些文章出奇一致地告訴你。

「儘可能避免在application中引用太多第三方開源庫或者避免爲了一些簡單的功能而引入一個較大的庫」

「四大組件會被打包進 classes.dex」

首先我以爲很無奈,沒法知道構建系統是如何將四大組件打包進 classes.dex,項目內的代碼無從考證。其次在版本 feature 已經驗收完畢之下我沒法直接對啓動的依賴樹進行調整,且業務迭代好久的前提下刪除或者移動一個啓動依賴是風險很大的改動。

我很是努力且當心翼翼地優化,再跑一下。

D8: Cannot fit requested classes in the main-dex file (# methods:87463 > 65536 ; # fields: 74531 > 65536)

此時的我很是絕望,按照這樣優化不可能下降到 65536 如下。

在這裏,我花費了不少時間在嘗試網上所說的各類方案。 我很難用 「浪費」 來描述對這段時間的使用,由於若是不是這樣,我可能不會意識到對待這類問題上個人作法多是錯誤的,並指導我之後應該這樣作。

「被迫」啃下源碼

既然是從 .class 到生成 .dex 環節出現了問題,那就只能從構建流程中該環節切入去熟悉。 項目用的是 AGP3.4.1 版本,開始從 Transform 方向去嘗試解惑:從 Gradle 源碼 中嘗試跟蹤並找到一下問題的答案。主要要解決的問題有:

  1. 處理分包的 Transform 是哪一個,主要作了什麼
  2. 影響 maindexlist 最終的 keep 邏輯是怎麼肯定的 ? 構建系統自己 keep 了哪些,開發者能夠 keep 哪些?
  3. 從上游輸入中接受的 clasee 是怎麼根據 keep 邏輯進行過濾的
  4. maindexlist 文件是何時生成的,在哪裏生成。

跟源碼比較痛苦,特別是 Gradle 源碼不支持跳轉因此只能一個一個類手動查,有些邏輯甚至要看上四五遍。

下面流程只列出核心步驟及方法。

尋找分包 Transform

在應用構建流程中,會經歷 「評估」 階段。當 apply com.android.application 插件以後,評估先後會經歷如下流程

1. com.android.build.gradle.BasePlugin#apply()
2. com.android.build.gradle.BasePlugin#basePluginApply()
3. com.android.build.gradle.BasePlugin#createTasks()
4. com.android.build.gradle.BasePlugin#createAndroidTasks()
5. **com.android.build.gradle.internal.VariantManager#createAndroidTasks(**) //重點關注一
6. com.android.build.gradle.internal.VariantManager#createTasksForVariantData()
7. com.android.build.gradle.internal.ApplicationTaskManager#createTasksForVariantScope()  
8. com.android.build.gradle.internal.ApplicationTaskManager#addCompileTask() 
9. **com.android.build.gradle.internal.TaskManager#createPostCompilationTasks()** //重點關注二
10. com.android.build.gradle.internal.pipeline.TransformManager#addTransform()

上述流程有兩個點留意:

  1. 知道 VariantManager#createAndroidTasks 開始構建 Android Tasks
  2. TaskManager#createPostCompilationTasks方法 爲某一個構建場景添加 Task,其中包含了支持 Multi-Dex 的 Task

Multi-Dex support 核心代碼以下

D8MainDexListTransform multiDexTransform = new D8MainDexListTransform(variantScope);
transformManager.addTransform(taskFactory, variantScope, multiDexTransform,
        taskName -> {
            File mainDexListFile =
                    variantScope
                            .getArtifacts()
                            .appendArtifact(
                                    InternalArtifactType.LEGACY_MULTIDEX_MAIN_DEX_LIST,
                                    taskName,
                                    "mainDexList.txt");
            multiDexTransform.setMainDexListOutputFile(mainDexListFile);
        }, null, variantScope::addColdSwapBuildTask);

transformManager#addTransform 一共有6個參數

  • 第三個爲 multiDexTransform 對象
  • 第四個爲 預配置的任務,用於生成 mainDexList.txt 的 action,其實就是爲了延遲建立任務的,用於設置 mainDexList.txt 文件路徑。

到這裏,開始有點頭緒了。

D8MainDexListTransform 作了什麼?

D8MainDexListTransform 的構造器參數很關鍵。

class D8MainDexListTransform(
        private val manifestProguardRules: BuildableArtifact,
        private val userProguardRules: Path? = null,
        private val userClasses: Path? = null,
        private val includeDynamicFeatures: Boolean = false,
        private val bootClasspath: Supplier<List<Path>>,
        private val messageReceiver: MessageReceiver) : Transform(), MainDexListWriter {}
  1. manifestProguardRules 爲 aapt 混淆規則,編譯時產生在 build/intermediates/legacy_multidex_appt_derived_proguard_rules 目錄下的 manifest_keep.txt
  2. userProguardRules 爲項目 multiDexKeepProguard 申明的 keep 規則
  3. userClasses 爲項目 multiDexKeepFile 申明的 keep class

這三份文件都會影響最終決定那些 class 會被打到 clesses.dex 中,邏輯在 transform方法 裏面:

override fun transform(invocation: TransformInvocation) {
    try {
        val inputs = getByInputType(invocation)
        val programFiles = inputs[ProguardInput.INPUT_JAR]!!
        val libraryFiles = inputs[ProguardInput.LIBRARY_JAR]!! + bootClasspath.get()
         // 1 處
        val proguardRules =listOfNotNull(manifestProguardRules.singleFile().toPath(), userProguardRules)
        val mainDexClasses = mutableSetOf<String>()
        //  2 處
        mainDexClasses.addAll(
            D8MainDexList.generate(
                getPlatformRules(),
                proguardRules,
                programFiles,
                libraryFiles,
                messageReceiver
            )
        )
        // 3 處
        if (userClasses != null) {
            mainDexClasses.addAll(Files.readAllLines(userClasses))
        }
        Files.deleteIfExists(outputMainDexList)
        // 4處
        Files.write(outputMainDexList, mainDexClasses)
    } catch (e: D8MainDexList.MainDexListException) {
        throw TransformException("Error while generating the main dex list:${System.lineSeparator()}${e.message}", e)
    }
}

第一處代碼拿到 multiDexKeepProguard keep 規則.

第二處代碼使用 D8MainDexList#generate方法 生成全部須要 keep 在 classes.dex 的 class 集合, getPlatformRules方法 中強制寫死了一些規則。

internal fun getPlatformRules(): List<String> = listOf(
        "-keep public class * extends android.app.Instrumentation {\n"
                        + "  <init>(); \n"
                        + "  void onCreate(...);\n"
                        + "  android.app.Application newApplication(...);\n"
                        + "  void callApplicationOnCreate(android.app.Application);\n"
                        + "  Z onException(java.lang.Object, java.lang.Throwable);\n"
                        + "}",
        "-keep public class * extends android.app.Application { "
                        + "  <init>();\n"
                        + "  void attachBaseContext(android.content.Context);\n"
                        + "}",
        "-keep public class * extends android.app.backup.BackupAgent { <init>(); }",
        "-keep public class * implements java.lang.annotation.Annotation { *;}",
        "-keep public class * extends android.test.InstrumentationTestCase { <init>(); }"
)

第三處代碼把 multiDexKeepFile 申明須要保留的 class 添加到 2 步驟生成的集合中

第四齣代碼最終輸入到 outputMainDexList ,這個文件就是在添加 D8MainDexListTransform 的時候預設置的 mainDexList.txt,保存在 build/intermediates/legacymultidexmaindexlist 目錄下。

到這裏,若是想辦法在勾住 mainDexList.txt則在真正打包 classes.dex 以前修改文件時應該能保證方法數控制在 65536 之下。咱們項目中使用了 tinkertinker 也 keep 了一些類到 classes.dex。從 multiDexKeepProguard/multiDexKeepFile 手段上不存在操做空間,由於這些是業務硬要求的邏輯。只能看編譯以後生成的 mainDexList.txt,而後憑藉經驗去掉一些看起來可能 「前期不須要」 的 class,但稍微不慎都有可能致使 crash 產生。

尋找明確的 「Keep」 鏈

但願能從代碼邏輯上獲得 「更爲明確的指導」,就得了解下爲啥 D8 構建流程, 爲啥 keep 了那麼多類,這些類是否存在刪減的空間。

可是我在 gradle 源碼中並無找到 D8MainDexList.javagenerate方法 相關信息,它被放到 build-system 的另外一個目錄中,核心邏輯以下。

public static List<String> generate(
        @NonNull List<String> mainDexRules,        
        @NonNull List<Path> mainDexRulesFiles,
        @NonNull Collection<Path> programFiles,
        @NonNull Collection<Path> libraryFiles,
        @NonNull MessageReceiver messageReceiver)
        throws MainDexListException {
    D8DiagnosticsHandler d8DiagnosticsHandler =
            new InterceptingDiagnosticsHandler(messageReceiver);
    try {
        GenerateMainDexListCommand.Builder command =
                GenerateMainDexListCommand.builder(d8DiagnosticsHandler)
                        .addMainDexRules(mainDexRules, Origin.unknown()) //d8強制寫死的規則
                        .addMainDexRulesFiles(mainDexRulesFiles) //開發者經過 multiDexKeepProguard 添加的規則
                        .addLibraryFiles(libraryFiles);
        for (Path program : programFiles) {
            if (Files.isRegularFile(program)) {
                command.addProgramFiles(program);
            } else {
                try (Stream<Path> classFiles = Files.walk(program)) {
                    List<Path> allClasses = classFiles
                            .filter(p -> p.toString().endsWith(SdkConstants.DOT_CLASS))
                            .collect(Collectors.toList());
                    command.addProgramFiles(allClasses);
                }
            }
        }
          //最終調用 GenerateMainDexList#run
        return ImmutableList.copyOf(
                GenerateMainDexList.run(command.build(), ForkJoinPool.commonPool()));
    } catch (Exception e) {
        throw getExceptionToRethrow(e, d8DiagnosticsHandler);
    }
}

上述最終經過構建 GenerateMainDexListCommand 對象並傳遞給 GenerateMainDexList 執行。 這兩個類在咱們本地 AndroidSdk 裏,路徑爲 {AndroidSdk}/build-tools/{buildToolsVersion}/lib/d8.jar 中,可經過 JD_GUI 工具查看。

GenerateMainDexListCommandBuilder#build方法 在構建對象的時候作了如下工做:

  1. 構建 DexItemFactory 工廠對象,用於構建 DexString,DexMethod 等相關 dex 信息
  2. 預處理了規則文件,好比刪除 「#」 註解相關等,解析成 ProguardConfigurationRule 對象集
  3. 構建 AndroidApp 對象,用於記錄程序資源的信息,好比 dexClass,libraryResource 等等

最終傳遞 AndroidApp 對象給 GenerateMainDexList#run方法 調用。

private List<String> run(AndroidApp app, ExecutorService executor) throws IOException, ExecutionException {
    // 步驟一
    DirectMappedDexApplication directMappedDexApplication =
         (new ApplicationReader(app, this.options,     this.timing)).read(executor).toDirect();
    // 步驟二
    AppInfoWithSubtyping appInfo = new AppInfoWithSubtyping((DexApplication)directMappedDexApplication);
    // 步驟三
    RootSetBuilder.RootSet mainDexRootSet = 
        (new RootSetBuilder((DexApplication)directMappedDexApplication, (AppInfo)appInfo, (List)this.options.mainDexKeepRules, this.options)).run(executor);
    Enqueuer enqueuer = new Enqueuer(appInfo, this.options, true);
    Enqueuer.AppInfoWithLiveness mainDexAppInfo = enqueuer.traceMainDex(mainDexRootSet, this.timing);
    // 步驟四
    Set<DexType> mainDexClasses = (new MainDexListBuilder(new HashSet(mainDexAppInfo.liveTypes),         (DexApplication)directMappedDexApplication)).run();
    List<String> result = (List<String>)mainDexClasses.stream().map(c -> c.toSourceString().replace('.', '/') +             ".class").sorted().collect(Collectors.toList());
    if (this.options.mainDexListConsumer != null)
          this.options.mainDexListConsumer.accept(String.join("\n", (Iterable)result), (DiagnosticsHandler)this.options.reporter); 
    if (mainDexRootSet.reasonAsked.size() > 0) {
        TreePruner pruner = new TreePruner((DexApplication)directMappedDexApplication, mainDexAppInfo.withLiveness(), this.options);
        DexApplication dexApplication = pruner.run();
        ReasonPrinter reasonPrinter = enqueuer.getReasonPrinter(mainDexRootSet.reasonAsked);
        reasonPrinter.run(dexApplication);
    } 
    return result;
}
  • 步驟一,構建了 ApplicationReader 對象,阻塞等待 read方法 讀取了全部程序的資源,若是是存在 .dex 資源,則歸類到 dex 類型;若是存在 class 類型,則歸到 class 類型(可是過濾了 module-info.class 的文件)。這部分邏輯可在 com.android.tools.r8.util.FilteredArchiveProgramResourceProvider 查看。dex 類型使用 dex 格式解析,class 類型使用字節碼格式解析以後保存到 directMappedDexApplication 對象中。
  • 步驟二 AppInfoWithSubtyping 讀取了 directMappedDexApplication,計算並設置類的 super/sub 關係。
  • 步驟三 把全部收集到的類信息及類的 super/sub 關係,及 keep 的規則傳遞給 RootSetBuilder 用於計算 Root 集合,該集合決定哪些類將最終被 keep 到 classes.dex 裏面。通過匹配混淆以後得到 Root 集合以後,調用 run() 進行向下檢索。主要是計算 Root 集合內的 class 的依賴及使用枚舉做爲運行時註解類。
  • 步驟四 根據 Root 集合,按照如下兩個方法順序檢索獲得 mainDexClass 集合,方法邏輯以下。

    1. traceMainDexDirectDependencies方法

      • 添加 Root 節點 class,添加其全部父類及接口;
      • 添加 Root 節點 class 中靜態變量,成員變量;
      • 添加 Root 節點 class 中的方法的參數類型的 class,返回值類型對應的 class;
      • 收集 Root 節點 class 的註解。
    2. traceRuntimeAnnotationsWithEnumForMainDex方法

      • 全部類中,若是 class 是註解類型且使用枚舉類,則收集;
      • 全部類中,若是 class 使用了上一條規則的枚舉類且枚舉可見,則也收集。

所以,最終生成的集合,會在 D8MainDexListTransform#transform方法 中合併存在的 multiDexKeepFile 規則,並最終寫到 build/intermediates/legacymltidexmaindexlist/ 目錄下的 maindexlist.txt 文件。

嘗試新方案

那麼 D8MainDexListTransform 可以被我勾住使用呢? 固然能夠。 找到 D8MainDexListTransform 對應的 Task,能夠經過 project.tasks.findByName 來獲取 task 對象,而後在 gradle 腳本中監聽這個 task 的執行,在 task 結束以後並返回結果以前插入咱們自定義的 task,可經過 finalizeBy 方法實現。

而 D8MainDexListTransform 對應 Task 的名字的邏輯經過閱讀 TransformManager#getTaskNamePrefix方法 可推斷。

把上述全部邏輯封裝成一個 gradle 腳本並在 application 模塊中 apply 就好了。

project.afterEvaluate {

    println "handle main-dex by user,start..."
    if (android.defaultConfig.minSdkVersion.getApiLevel() >= 21) {
        return
    }
    println "main-dex,minSdkVersion is ${android.defaultConfig.minSdkVersion.getApiLevel()}"
    android.applicationVariants.all { variant ->

        def variantName = variant.name.capitalize()
        def multidexTask = project.tasks.findByName("transformClassesWithMultidexlistFor${variantName}")
        def exist = multidexTask != null
        println "main-dex multidexTask(transformClassesWithMultidexlistFor${variantName}) exist: ${exist}"
        
        if (exist) {
            def replaceTask = createReplaceMainDexListTask(variant);
            multidexTask.finalizedBy replaceTask
        }
    }
}

def createReplaceMainDexListTask(variant) {
    def variantName = variant.name.capitalize()

    return task("replace${variantName}MainDexClassList").doLast {

        //從主dex移除的列表
        def excludeClassList = []
        File excludeClassFile = new File("{存放剔除規則的路徑}/main_dex_exclude_class.txt")
        println "${project.projectDir}/main_dex_exclude_class.txt exist: ${excludeClassFile.exists()}"
        if (excludeClassFile.exists()) {
            excludeClassFile.eachLine { line ->
                if (!line.trim().isEmpty() && !line.startsWith("#")) {
                    excludeClassList.add(line.trim())
                }
            }
            excludeClassList.unique()
        }
        def mainDexList = []
        File mainDexFile = new File("${project.buildDir}/intermediates/legacy_multidex_main_dex_list/${variant.dirName}/transformClassesWithMultidexlistFor${variantName}/maindexlist.txt")
        println "${project.buildDir}/intermediates/legacy_multidex_main_dex_list/${variant.dirName}/transformClassesWithMultidexlistFor${variantName}/maindexlist.txt exist : ${mainDexFile.exists()}"
        //再次判斷兼容 linux/mac 環境獲取
        if(!mainDexFile.exists()){
            mainDexFile = new File("${project.buildDir}/intermediates/legacy_multidex_main_dex_list/${variant.dirName}/transformClassesWithMultidexlistFor${variantName}/mainDexList.txt")
            println "${project.buildDir}/intermediates/legacy_multidex_main_dex_list/${variant.dirName}/transformClassesWithMultidexlistFor${variantName}/mainDexList.txt exist : ${mainDexFile.exists()}"
        }
        if (mainDexFile.exists()) {
            mainDexFile.eachLine { line ->
                if (!line.isEmpty()) {
                    mainDexList.add(line.trim())
                }
            }
            mainDexList.unique()
            if (!excludeClassList.isEmpty()) {
                def newMainDexList = mainDexList.findResults { mainDexItem ->
                    def isKeepMainDexItem = true
                    for (excludeClassItem in excludeClassList) {
                        if (mainDexItem.contains(excludeClassItem)) {
                            isKeepMainDexItem = false
                            break
                        }
                    }
                    if (isKeepMainDexItem) mainDexItem else null
                }
                if (newMainDexList.size() < mainDexList.size()) {
                    mainDexFile.delete()
                    mainDexFile.createNewFile()
                    mainDexFile.withWriterAppend { writer ->
                        newMainDexList.each {
                            writer << it << '\n'
                            writer.flush()
                        }
                    }
                }
            }
        }
    }
}

main_dex_exclude_class.txt 的內容很簡單,規則和 multiDexKeepFile 是同樣的,好比:

com/facebook/fresco
com/android/activity/BaseLifeActivity.class
...

這樣就能夠了,若是你找不到 D8MainDexListTransform 對應的 Task,那你應該是用了 r8 ,r8 會合並 mainDexList 的構建流程到新的 Task,你能夠選擇關閉 r8 或者尋找新的 hook 點,思路是同樣的。

「什麼,你講了一遍流程,可是仍是沒有說哪些能夠刪 」

「其實,除了 D8 強制 keep 住的類和 contentProvider, 其餘均可以刪。」

「可是我看到網上不少文章說,四大組件都要 keep 住哦」

「建議以我爲準。」

固然,我已經試過了,你把入口 Activity 刪除,也只是慢一些而已,只是不建議罷了。或者你能夠選擇把二級頁面所有移除出去,這樣可能會大大減小 classes.dex 的方法數。

最終效果: methods: 87855 > 49386。

上述分析存在錯誤歡迎指正或有更好的處理建議,歡迎評論留言哦。

解決問題很痛苦,逼着你去尋找答案,但解決以後真的爽。

專一 Android 進階技術分享,記錄架構師野蠻成長之路

若是在Android領域有遇到任何問題,包括項目遇到的技術問題,面試及簡歷描述問題,亦或對將來職業規劃有疑惑,可添加我微信 「Ming_Lyan」 或關注公衆號 「Android之禪」,會盡自所能和你討論解決。 後續會針對 「Android 領域的必備進階技術」,「Android高可用架構設計及實踐」 ,「業務中的疑難雜症及解決方案」 等實用內容進行分享。 也會分享做爲技術者如何在公司野蠻成長,包括技術進步,職級及收入的提高。 歡迎來撩。
相關文章
相關標籤/搜索