我是如何一步一步爬上 「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高可用架构设计及实践” ,“业务中的疑难杂症及解决方案” 等实用内容进行分享。 也会分享做为技术者如何在公司野蛮成长,包括技术进步,职级及收入的提高。 欢迎来撩。
相关文章
相关标签/搜索