分享这个填坑的记录,主要是感觉身边很多 Androider 都会遇到和我一样的场景。
其实无论影响大不大,丢在项目里总不太好。 当别人帮助不了的时候,真的就只有代码能帮你。尝试过很多方案不可行,很多时候是因为每个方案的背景不一样,包括开发环境背景如 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 源码 中尝试跟踪并找到一下问题的答案。
跟源码比较痛苦,特别是 gradle 源码不支持跳转只能一个一个类查,有些逻辑要看上四五遍。下面流程只列出核心步骤及方法。
在应用构建流程中,会经历 “评估” 阶段。当 apply “com.android.application” 插件之后,评估前后会经历以下流程
上述流程有两个点留意:
VariantManager#createAndroidTasks
开始构建 Android tasksTaskManager#createPostCompilationTasks()
为某一个构建场景添加 task,其中包含了支持 Multi-Dex 的 taskMulti-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个参数
到这里,有点头绪了。
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 {} 复制代码
build/intermediates/legacy_multidex_appt_derived_proguard_rules
目录下的 manifest_keep.txt这三份文件都会影响最终决定那些 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/legacy_multidex_main_dex_list
目录下。
到这里,想办法在勾住 mainDexList.txt
, 在真正打包 classes.dex 之前修改文件时应该能保证方法数控制在 65536 之下的。我们项目中使用了 tinker, tinker 也 keep 了一些类到 classes.dex。从 multiDexKeepProguard/multiDexKeepFile 手段上不存在操作空间,因为这些是业务硬要求的逻辑。只能看编译之后生成的 mainDexList.txt,然后凭借经验去掉一些看起来可能 “前期不需要” 的 class,但稍微不慎都有可能导致 crash 产生。
希望能从代码逻辑上得到 “更为明确的指导”,就得了解下为啥 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() 在构建对象的时候做了以下工作:
最终传递 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; } 复制代码
com.android.tools.r8.util.FilteredArchiveProgramResourceProvider
查看。dex 类型使用 dex 格式解析,class 类型使用字节码格式解析之后保存到 directMappedDexApplication 对象中。traceMainDexDirectDependencies()
方法
traceRuntimeAnnotationsWithEnumForMainDex()
方法
因此,最终生成的集合,会在 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,代码不会说谎,跟一下源码,可以找到答案。
上述分析存在错误欢迎指正,或有更好的处理建议记得评论留言哦。
解决问题很痛苦,逼着你去寻找答案,但解决之后真的爽。