我是如何一步一步爬上 “64K限制” 的坑 | 經驗貼

初衷

分享這個填坑的記錄,主要是感覺身邊很多 Androider 都會遇到和我一樣的場景。

  1. 遇到一個 BUG ,優先按照自己經驗修復
  2. 修復不了了,開始 Google(不要百度,再三強調),尋找一切和我們 BUG 相似的問題,然後看看有沒有解決方案
  3. 嘗試了很多解決方案,a 方案不行換 b 方案,b 方案不行換 c 方案... 知道沒有方案可以嘗試了,開始絕望...
  4. 如果影響不大,那就丟在項目裏(估計也沒人發現),如果影響很大,那隻能尋找別人幫助,如果別人也給不了建議,那就原地 💥

其實無論影響大不大,丟在項目裏總不太好。 當別人幫助不了的時候,真的就只有代碼能幫你。嘗試過很多方案不可行,很多時候是因爲每個方案的背景不一樣,包括開發環境背景如 gradle 版本,編譯版本 ,api 版本等。我遇到的這個問題也是如此。

希望通過以下的記錄能幫助你在面對無能爲力的 “BUG” 時更堅定地尋找解決方案。

背景

在我們項目最近的一個版本中,QA 測試 feature 功能時反饋 “4.4設備上,APP 都 crash 啦!” 由於反饋該問題的時候已經快週末了,按照 PM 的流程我們需要在下週一封包給 MTL 做質量測試,這個問題必須在週一前解決。 =。=

第一反應 “GG,感覺應該是坑”。立刻借了兩臺 4.4 的機型對發生 Crash 的包體進行調試,發現都是 “java.lang.NoClassDefFoundError”。 這個crash表明找不到引用的類原本應該在 主 Dex 文件中,但是主 Dex 文件中卻沒有提供這個類。

難道我們沒有 keep 住這個類嗎? 不應該啊,顯然是因爲構建工具已經知道這個類應該被打進去,卻因爲某些原因沒有被打進去。我嘗試使用 mutilDexKeepProguard keep 住這個類,然後編譯直接不通過了。收到的異常爲:

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

定位問題

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

所謂 主dex,其實就是 classes.dex。還可能存在 classes1.dex,classes2.dex...classesN.dex,因爲完整的項目可能包含超過 65536 個方法,因爲需要對項目的 class 進行切分。主dex會被最先加載,必須包含啓動引用所需要的類及“依賴類”(後面會有詳細介紹)。而我所遇到的問題就是 “包含啓動引用所需要的類及“依賴類包含的方法數” 超過 65536 個,構建系統不給我繼續構建了。

事實上,在 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裏面的類,同時確保應用能夠正常啓動。

但是官方只告訴我們 “如何 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 是怎麼根據 kepp 邏輯進行過濾的
  4. maindexlist 文件是什麼時候生成的,在哪裏生成。

跟源碼比較痛苦,特別是 gradle 源碼不支持跳轉只能一個一個類查,有些邏輯要看上四五遍。下面流程只列出核心步驟及方法。

尋找分包 Transform

在應用構建流程中,會經歷 “評估” 階段。當 apply “com.android.application” 插件之後,評估前後會經歷以下流程

com.android.build.gradle.BasePlugin#apply()
com.android.build.gradle.BasePlugin#basePluginApply()
com.android.build.gradle.BasePlugin#createTasks()
com.android.build.gradle.BasePlugin#createAndroidTasks()
com.android.build.gradle.internal.VariantManager#createAndroidTasks() //重點關注一
com.android.build.gradle.internal.VariantManager#createTasksForVariantData()
com.android.build.gradle.internal.ApplicationTaskManager#createTasksForVariantScope()
com.android.build.gradle.internal.ApplicationTaskManager#addCompileTask()
com.android.build.gradle.internal.TaskManager#createPostCompilationTasks() //重點關注二
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)
    }
}
  1. 第一處代碼拿到 multiDexKeepProguard keep 規則.

  2. 第二處代碼使用 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>(); }"
    )
    
  3. 第三處代碼把 multiDexKeepFile 申明需要保留的 class 添加到 2 步驟生成的集合中

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

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

尋找明確的 “Keep” 鏈

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

但是我在 gradle 源碼中並沒有找到 D8MainDexList.java#generate 的相關信息,它被放到 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/legacy_mltidex_main_dex_list/ 目錄下的 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。

小結

程序上的BUG,代碼不會說謊,跟一下源碼,可以找到答案。

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

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

扣扣掃碼加入粉絲裙可領取福利!!!

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章