关于android:我是如何一步一步爬上-64K限制-的坑

6次阅读

共计 14225 个字符,预计需要花费 36 分钟才能阅读完成。

分享初衷

分享这个填坑的记录,次要是身边很多 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.dexclassesN.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<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.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/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 高可用架构设计及实际”,“业务中的疑难杂症及解决方案”等实用内容进行分享。
也会分享作为技术者如何在公司横蛮成长,包含技术提高,职级及支出的晋升。
欢送来撩。

正文完
 0