初衷
分享这个填坑的记录,主要是感觉身边很多 Androider 都会遇到和我一样的场景。
- 遇到一个 BUG ,优先按照自己经验修复
- 修复不了了,开始 Google(不要百度,再三强调),寻找一切和我们 BUG 相似的问题,然后看看有没有解决方案
- 尝试了很多解决方案,a 方案不行换 b 方案,b 方案不行换 c 方案... 知道没有方案可以尝试了,开始绝望...
- 如果影响不大,那就丢在项目里(估计也没人发现),如果影响很大,那只能寻找别人帮助,如果别人也给不了建议,那就原地 💥
其实无论影响大不大,丢在项目里总不太好。 当别人帮助不了的时候,真的就只有代码能帮你。尝试过很多方案不可行,很多时候是因为每个方案的背景不一样,包括开发环境背景如 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 源码 中尝试跟踪并找到一下问题的答案。
- 处理分包的 Transform 是哪个,主要做了什么
- 影响 maindexlist 最终的 keep 逻辑是怎么确定的 ? 构建系统本身 keep 了哪些,开发者可以 keep 哪些?
- 从上游输入中接受的 clasee 是怎么根据 kepp 逻辑进行过滤的
- 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#createTasksForVariant