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

分享初衷

分享这个填坑的记录,主要是身边很多 Androider 都会遇到难以解决的难题并重复走旧路。

大部分人都会按照这样的步骤处理:

  1. 遇到一个 BUG ,优先按照自己经验修复;
  2. 修复不了,开始 Google(不要百度,再三强调),寻找一切和我们 BUG 相似的问题,然后看看有没有解决方案;
  3. 尝试了很多解决方案,a 方案不行换 b 方案,b 方案不行换 c 方案,直到没有方案可以尝试了,开始怀疑人生;
  4. 如果影响不大,那就丢在项目里(估计也没人发现),如果影响很大,那只能寻找别人帮助,如果别人也给不了建议,那就原地爆炸了。

无论 BUG 影响多大,丢在项目里总不太好。 当别人帮助不了的时候,真的只有代码能帮你。尝试过很多方案不可行,很多时候是因为每个方案的背景不一样,包括开发环境背景如 Gradle 版本,编译版本 ,API 版本场景差异化。我遇到的这个问题也是如此。 希望通过以下的记录能帮助你在面对无能为力的 BUG 时更坚定地寻找解决方案。

问题背景

在我们项目最近的一个版本中,QA 测试 Feature 功能时反馈 4.4 设备上 APP 全 Crash! 由于反馈该问题时已经快周末了,按 PM 的流程我们需在下周一封包给兼容测试部门做质量测试,这个问题就必须在周一前解决。

第一反应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)

当然有了 LOG 信息就有了解决问题的希望了。

定位问题

Dex 文件规范明确指出:单个 dex 文件内引用的方法总数只能为 65536。而这个限制来源于是 davilk 指令中调用方法的引用索引数值,该数值采用 16 位 二进制记录,也就是 2^16 = 65536,方法数包括了 Android Framework 层方法,第三方库方法及应用代码方法。

所谓 主dex,其实就是 classes.dex。还可能存在 classes1.dex,classes2.dex...classesN.dex。因为完整项目可能包含超过 65536 个方法,所以需要对项目的 class 进行分 dex 打包。主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 是怎么根据 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>,
        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()
        //  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 = listOf(
        "-keep public class * extends android.app.Instrumentation {\n"
                        + "  (); \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 { "
                        + "  ();\n"
                        + "  void attachBaseContext(android.content.Context);\n"
                        + "}",
        "-keep public class * extends android.app.backup.BackupAgent { (); }",
        "-keep public class * implements java.lang.annotation.Annotation { *;}",
        "-keep public class * extends android.test.InstrumentationTestCase { (); }"
)

第三处代码把 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 generate(
        @NonNull List mainDexRules,        
        @NonNull List mainDexRulesFiles,
        @NonNull Collection programFiles,
        @NonNull Collection 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 classFiles = Files.walk(program)) {
                    List 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 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 mainDexClasses = (new MainDexListBuilder(new HashSet(mainDexAppInfo.liveTypes),         (DexApplication)directMappedDexApplication)).run();
    List result = (List)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高可用架构设计及实践” ,“业务中的疑难杂症及解决方案” 等实用内容进行分享。
也会分享作为技术者如何在公司野蛮成长,包括技术进步,职级及收入的提升。
欢迎来撩。

你可能感兴趣的:(android)