Android MultiDex 实践:如何绕过那些坑?

13,686 阅读12分钟

本文是我的同事徐冬投稿,介绍他在方法数到达65k上限后,应用官方MultiDex方案时踩过的一些坑,以及如何解决这些棘手问题的实践过程。

前言

Android应用65k方法数的限制一直为广大开发者所诟病,在应用功能越来越丰富、各种开源库越来越多的今天,65k方法数瓶颈俨然已是一大绊脚石。

关于65k方法数限制的更多细节可以看下冯建的这篇文章:

http://www.jayfeng.com/2016/03/10/由Android-65K方法数限制引发的思考/

至于怎么解决这个问题,业内有包括插件化在内的一些方案,我们今天的重点是Android官方给出的这个方案,MultiDex。

MultiDex, 顾名思义,是指多dex实现,大多数App,解压其apk后,一般只有一个classes.dex文件,采用MultiDex的App解压后可以看到有classes.dex,classes2.dex,… classes(N).dex,这样每个dex都可以最大承载65k个方法,很大限度地缓解了单dex方法数限制。

下文将详细介绍我在应用MultiDex方案,以及尝试解决由此带来的问题的实践过程。

MultiDex初步探索

先试试官方方案?

Android官方MultiDex方案使用比较简单:

http://developer.android.com/intl/zh-cn/tools/building/multidex.html


1. 在gradle中添加MultiDex支持

multiDexEnable true

2. 加载classes2.dex

AndroidManifest.xml的application中添加MultiDexApplication,或者如果已经重载了Application,则在attachBaseContext()中执行MultiDex.install()即可。

官方方案简单易用,但遗留问题也不少,江湖传言坑很多。

果然有坑!

最初对我们的App进行MultiDex实现时,App还只是超过65535大概上百个方法,就按Android官方方案对App进行多dex支持,启动发现无任何异常(包括启动速度、启动时应用不响应ANR、Crash等),classes2.dex也只有大概几十KB大小,暗自窃喜了一小阵子!

好景不长,随着一些SDK的引入,导致classes2.dex文件达到了200+KB,测试发现不少机型启动ANR、Crash,或者启动时间过长,回去看了下Android官方指出的MultiDex存在的问题,嗯,发现基本都是描述里提到的那些坑(启动时间过长、找不到类)!

第一个坑:启动时间过长

在解决这些坑之前,先来简要看看App启动流程:


不难发现,Application.attachBaseContext是我们能控制的最早执行的代码,在这个方法里面执行MultiDex.install()无疑是最佳时机。

还有一点我们需要了解,首次启动时Dalvik虚拟机会对classes.dex执行dexopt操作,生成ODEX文件,这个过程非常耗时,而执行MultiDex.install()必然会再次对classes2.dex执行dexopt等操作,所有这些操作必须在5秒内完成,否则就ANR给你看!

非首次启动则直接从cache中读取已经执行过dexopt的ODEX文件,这个过程对启动并无太大影响。

我们实际测试中发现首次启动classes2.dex加载需要1~2秒,非首次启动classes2.dex加载只需要几十毫秒左右。

这可能就是为什么classes2.dex不能太大的一个原因。

基于此,对attachBaseContext稍作改动:

@Override
protected void attachBaseContext(final Context base) {
    super.attachBaseContext(base);
    initBeforeDex2Installed();
    
    if (isFirstLaunch()) {
        // 首次启动
        new Thread(new Runnable() {
        
            @Override
            public void run() {
                MultiDex.install(base); 
                initAfterDex2Installed();
            }
        }).start();
    } else {
        // 非首次启动
        MultiDex.install(base);
        initAfterDex2Installed();
    }
}

以上逻辑便是改动之后的初步实现。

首次启动开启一个线程来加载classes2.dex,防止阻塞UI线程,非首次启动则同步执行。

initAfterDex2Installed()方法是根据Classes2.dex中结果,将涉及到的相关初始化工作移到classes2.dex加载完之后执行,避免启动问题。

建议在classes2.dex加载完成前,设置一个启动等待界面,之后再进入主界面,确保用户体验。

第二个坑:ANR/Crash

ANR!!Crash!!对,这就是MultiDex引起的另外一个坑!解决完启动时间过长的问题,测试不时就来找我:“这个机型ANR了”,“这个机型又ANR了”,“这个机型怎么又Crash了”,“这个手机启动不了”,简直令人抓狂!云测试通过率也没之前的高!

实际上所有这些都是同一个问题导致的:classes2.dex没加载完成之前,程序调用了classes2.dex中的类或者方法!adb logcat看下,基本也就是3类问题:

  • NoClassDefFoundError

  • Could not find class

  • Could not find method

个人建议在用MultiDex时,多次启动看logcat,重点关注以上3类信息!知道了是哪些类引起的错误之后,只需将这些类强制分到classes.dex中即可。

那么具体如何实现呢?还得先简单了解下MultiDex编译过程。

MultiDex编译过程

要想完全了解MultiDex编译过程,需要对gradle, groovy有些了解,限于篇幅这里不对它们作过多介绍,只介绍MultiDex编译过程中关键的几个gradle task。

task,顾名思义就是任务的意思,是gradle build的基本单位,一个project所有的build最终是由一个个task来完成,以下面一段简单的build日志为例,相信在build时有留意日志的同学不会陌生:

MyProject:generateDebugSources
MyProject:processDebugJavaRes
MyProject:compileDebugNdk UP-TO-DATE
MyProject:compileDebugSources
MyProject:collectDebugMultiDexComponents

日志中,generateDebugSources、processDebugJavaRes…都是build过程中依次执行的task任务,将上面的Debug替换为Release即为Release build时的task,这个好理解,下面主要介绍Debug的task。

这些task分别完成不同的功能,最终完成整个build,其中与MultiDex编译过程相关的task主要有3个:

1. collectDebugMultiDexComponents

先收集,这个task扫描AndroidManifest.xml中的application、activity、receiver、provider、service等相关类,并将这些类的信息写入到manifest_keep.txt文件中,该文件位于build/intermediates/multi-dex/debug目录下。

2. shrinkDebugMultiDexComponents

再压缩,这个task会根据proguard规则以及manifest_keep.txt文件来进一步优化manifest_keep.txt,将其中没有用到的类删除,最终生成componentClasses.jar文件,该文件同样位于build/intermediates/multi-dex/debug目录下。

3. createDebugMainDexClassList

最后创建,这个task会根据上步中生成的componentClasses.jar文件中的类,递归扫描这些类所有相关的依赖类,最终形成maindexlist.txt文件,该文件也位于build/intermediates/multi-dex/debug目录下,这个文件中的类最终会打包进classes.dex中。

需要注意的是,maindexlist.txt文件并没有完全列出有所的依赖类,如果发现要查找的那个class不在maindexlist中,也无需奇怪。如果一定要确保某个类分到主dex中,将该类的完整路径加入到maindexlist中即可,同时注意两点:

  • 如果加入的类并不在project中,则gradle构建会忽略这个类,

  • 如果加入了多个相同的类,则只取其中一个。

以上3个task在build日志中都能找到。

回到第二个坑:ANR/Crash如何解决?

回到前面的问题:如何将某个类强制打包到classes.dex中,以避免ANR/Crash?

上面的3个task已经给出了答案!对,只需将该类完整路径添加到maindexlist.txt中即可!

createDebugMainDexClassList这个task正是实现这个操作的关键,主要代码如下:

tasks.whenTaskAdded {task ->
    if (task.name.startsWith("create") && task.name.endsWith("MainDexClassList") {
        task.doLast {
            File tempFile
            File keepFile
  
            if (task.name.contains("Debug")) {
                tempFile = new File("$project.rootDir/MyProject/keep_in_maindexlist_debug.txt")
                keepFile = new File("${project.buildDir}/intermediates/debug/maindexlist.txt")
            } else if (task.name.contains("Release")) {
                // Release时类似处理
            }
            
            tempFile.eachLine("utf-8") { str, linenumber ->
                keepFile.append(str + "\n")
            }
        }
    }
}

这里将需要强制分到classes.dex中的类放在keepin_maindexlist_debug.txt,这种实现方式基本能够解决眼前问题,但现在看来还是too simple sometimes navie!

主要问题是不可控,任何一次对代码的改动都有可能导致不同的分包结果,这就可能隐藏着不同的类导致首次启动失败,大量测试结果也证明了这种方法的不可控性。作为开发,代码不可控无疑无法忍受,如何改进这种方法使得MultiDex可控呢?与同事交流后间接找到了一种改进方案,下面讲讲这个方案。

MultiDex的一种改进实现

该如何让MultiDex分包可控呢?我的做法是:找出启动过程中所有类及依赖类,强制放入classes.dex中!

这么做要求启动相关的类不能太多(实际上大部分App从启动Application到进入MainActivity也就几个相关类),同时尽量让主界面和二级界面充分解耦。

如果不想对现有代码做太多改动,可以用反射方式调用二级界面中的Activity(反射可以避免依赖),不过调用时得要先判断classes2.dex是否加载完,以防某些二级界面相关代码在classes2.dex中而引起Crash,这么做虽然对功能实现并无影响,但可能导致代码可维护性降低。

另外,我们可以控制哪些类在classes.dex中,但无法控制哪些类分到classes2.dex中,以反射方式调用二级界面activity可以增大二级界面相关类分到classes2.dex中的概率。

寻找启动类

如何找出App启动到主界面显示这个过程中的所有类?

网上能够找得到的方法比较少,美团有自己的脚本程序找启动依赖类,但人家没开!源!!啦!!!还好Google到了CDA(Class Dependency Analyzer),通过这个工具,基本能找到启动过程中所有Activity、Application等相关依赖类,通常会有一定偏差(会将某些系统方法也找出来了)。

这时还需结合App的所有类来作进一步优化(获取App所有类只需反编译dex文件形成jar,解压jar包,再用shell相关工具处理即可得到),取两者的交集基本就能找出所有启动依赖类了。这里有一点需注意:必须以debug版本的App来分析,下面会讲到为什么。

Release版本寻找启动类

为什么要将Release版本单独拿出来说呢?

对,就是因为混淆!

混淆可能会导致每次编译形成的class文件名不同,代码的增加或减少也会对混淆结果产生影响,这可能导致每次编译所需的启动类名都不一样,而Debug版本往往不会做代码混淆,因此启动过程中的类名基本变化不大。

那么问题来了,如何确定Release版本启动依赖类呢?

build日志!!

通过build日志,我们发现,proguardRelease这个task在createReleaseMainDexClassList这个task之前执行,这意味着,在形成maindexlist之前,我们能够确切的知道哪些类进行了混淆以及混淆之后的类名!如何获知?proguard的产物给出了答案:

build/outputs/mapping/release/目录下的4个txt文件就是proguard的产物:

dump.txt:所有class文件的内部结构
mapping.txt:源码与混淆之后的类、方法、属性名字之间的一一映射关系
seeds.txt:未被混淆的类和属性
usage.txt:从Apk中剥离的代码

这里mapping.txt文件正是我们需要的,至于另外的3个文件有兴趣的可以研究下。我们简单了解下mapping.txt中文本的结构:

android.support.ActivityManagerCompat -> android.support.a:
    48:52:int getLargeMemoryClass() -> a
    62:83:boolean isHighEndGfx(android.content.Context) -> a

从上述信息中,我们知道经过代码混淆,android.support.ActivityManagerCompat在release版中最终打包为android.support.a类,并且对其中的方法、属性也进行了混淆。

并且注意到,文本中对类混淆的行以”:”结尾。

这下问题就有解了:

1. 根据startup_keep_list_debug.txt文件中的每一行,在mapping.txt中寻找其是否被混淆。

2. 如果被混淆了,则读取经过混淆的类。

3. 如果没有被混淆,则直接获取该类。

通过以上几个步骤,即可形成最终Release版本的启动依赖类。

至此,寻找启动类工作基本完成,但不难发现一个问题,那就是build release版本是将会更加耗时,因为要从mapping.txt中查找混淆类,涉及两层循环,mapping.txt文件通常有上万行,这也是这种方法最大的缺陷之一。

构建得到APK之后,点击icon,貌似一切正常work!

但,但,但,重要的事说三遍,至此并非所有事情都做完了,仍然可能会遗留一些问题!

通过以上方法找到的启动依赖类并非100%正确,几千上万个类中遗漏几个毕竟不是小概率事件,解决方法还是得多次启动,通过adb logcat获取启动日志,在日志中查找NoClassDefFoundError、Could not find class、Could not find method等warning。

有必要的话仍需将这些形成warning的类添加到startup_keep_list_debug.txt文件中,多次启动,直到没有相关的warning,这么做是为了减小未知风险。

至此,这种MultiDex实现方法基本也就完成了,后续会寻求其他更好的解决方案,比如动态加载dex方式等等。

MultiDex使用小结

以上基本就是我实现MultiDex的整个过程,中间有多少坑只有实现了才知道!个人认为无必要和绝对把握还是远离它比较好,特别是针对用户量大的App,任何线上ANR/Crash的影响范围可想而知。

  • 提高设计与代码质量应该可以不必被方法数限制困扰,据我观察微信目前最新的v6.3版本就没有超出这个限制,避免不必要的功能与代码非常重要。

  • 多试验首次启动App,以观察启动log是必须的,除了测试MultiDex是否会对首次启动时间产生明显影响,最重要的还是查看启动过程中是否有找不到的类。

  • 通常多次云测也是必须的,毕竟测试能覆盖到的机型有限,云测也节省了测试工作量。

我目前的方案,不尽完美但却能够解决当下问题,也仍然在寻求最优的解决方案,当你看到这篇文章时,如果有更好的建议或者意见,可以加我微信交流:XDRush,请不吝赐教!

相关参考

developer.android.com/tools/build…
blog.waynell.com/2015/04/19/…
tech.meituan.com/mt-android-…