Why MultiDex?
Android应用65535方法数的限制一直为广大开发者所诟病,在应用功能越来越丰富、各种开源库越来越多的今天,65k方法数瓶颈俨然已是一大绊脚石。至于怎么解决这个问题,先来看看google官方给出的方案:
- 插件化
将应用的非核心功能做成单独的App,实现该App和插件App相关的接口即可,实现以及体验如何,大家不妨来体验下Dolphin浏览器(这里做个小广告)。
- MultiDex 多dex实现,对大多数App,解压其apk,一般只有一个classes.dex文件,采用MultiDex的App解压可以看到有classes.dex,classes2.dex,…classes(N).dex,这样每个dex都可以最大承载65535个方法,很大限度的解决了单dex方法数限制。
下文将详细介绍MultiDex如何实现。
MultiDex初步实现
google官方MultiDex实现
Google官方MultiDex实现用起来比较简单:
gradle中添加MultiDex支持
1multiDexEnable true加载classes2.dex
AndroidManifest.xml的application中添加MultiDexApplication,或者如果已经重载Application,则在onAttachBaseContext()中执行MultiDex.install()即可加载dex2。
官方方案简单易用,但遗留的问题也不少,具体注意事项可以google官文有很详细的阐述,这里不再讨论,但使用时有一点需要注意,那就是只有在应用方法数接近65k(注意是接近65k而非确切的65k,之所以提到是因为这个坑导致了线上一个版本hotfix)时才会打包为2个dex文件,具体超过多少个方法数会打包多个dex可以通过设置dx的’–set-max-idx-number’来决定。
MultiDex初步应用
最初版本,App还只是超过65535大概上百个方法,就按google官方方案对App进行多dex支持,启动发现无任何异常(包括启动速度、启动ANR、Crash等),classes2.dex也只有带该二三十KB大小,暗自窃喜了一小阵子!好景不长,随着下一个版本一个新的SDK的引入,导致classes2.dex文件达到了200KB,测试发现不少机型启动ANR或者crash,或者启动时间过长,再次去看了google提出的MultiDex存在的问题,嗯,发现基本都是描述的那些问题!
接下来在上个版本的基础上稍作改动,先来看下App启动流程:
不难发现,Application.attachBaseContext是我们能控制的最早执行的代码,在这个方法里面执行MultiDex.install()无疑是最佳时机。还有一点我们需要了解,Dalvik虚拟机首次启动对classes.dex执行DEXOPT操作非常耗时,而执行MultiDex.install()必然会再次对classes2.dex执行DEXOPT等操作,所有这些操作必须在5s内完成,否则ANR给你看!非首次启动则直接从cache中读取已经执行过dexopt的文件ODEX,这个过程对启动并无太大影响(测试中发现首次启动dex2加载需要1~2s,非首次启动几十ms左右),这也大概就是为什么classes2.dex不能太大的一个原因。基于此,对attachBaseContext稍作改动:
|
|
以上逻辑便是改动之后的初步实现,首次启动开启一个线程来加载dex2,防止阻塞UI线程,非首次启动则同步执行;initOperationAfterDex2Installed()方法是根据Classes2.dex中结果,将涉及到的相关初始化工作移到dex2加载完之后执行,避免启动问题。当然这么做还不够,现在很多App在首次启动时引入闪屏页,我们的App首次启动也有一个FirstLaunchActivity来实现闪屏,闪屏结束之后就进入MainActivity,在进入MainActivity之前,运行一段阻塞代码来判断dex2是否加载完毕,如果dex加载完成,则立即进入MainActivity,如果尚未加载完毕,则阻塞等待dex2加载完毕,这么做主要还是为了避免启动过程中的ANR/Crash。
MultiDex引起的ANR/Crash
ANR!!Crash!!对,这就是MultiDex引起的坑!通过以上方法初步实现了MultiDex,但时不时QA就来找我:“这个机型ANR了”,“这个机型又ANR了”,“这个机型怎么又Crash了”,“这个手机启动不了”,简直令人抓狂!云测试通过率也没之前的高!实际上所有这些都是同一个问题导致的:dex2没加载完成之前,程序调用了dex2中的类或者方法!adb logcat看下,基本也就是4类问题引起的:
这里需重点讨论下NDK error,这种问题是在有JNI调用的时候才会发生,并且出现的比较诡异,我也是在云测试的时候才注意到这个问题的严重性,通过观察启动的log,查找SIGSEGV,died等关键字,发现在这些关键字附近会有诸如“unable to find **”,其实这里就是没有找到的类引起的NDK error的关键所在,解决方法当然就是将这些类放到classes.dex中。个人建议在用MultiDex时,多次启动看logcat,重点关注以上3个类型的信息!知道了哪些类引起的错误之后,只需将这些类强制分到classes.dex中即可。那么如何实现呢?这里还得简要了解下MultiDex编译过程以及涉及到分包的几个重要task。
MultiDex编译过程
gradle task简介
限于篇幅这里不对gradle、groovy作介绍,想了解的google上有不少好资料,这里只简要介绍gradle中的task以及MultiDex编译过程中重要的几个task,这是最终MultiDex实现的关键。
task,顾名思义就是任务的意思,是执行Project构建的基本单位,一个工程所有的构建最终是由一个个task来完成,这里我们来分析一个简单的build日志(PS:当我们输入gradle build按下Enter之后,屏幕上biu的一下多了不少日志信息,实际上这些日志信息就是一个个task的输出信息,阅读build日志对我们理解整个工程的构建大有裨益),以下面一段build日志为例:
日志中,generateReleaseSources、processReleaseJavaRes…、proguardRelease都是构建过程中依次执行的task任务,这些task分别完成不同的功能,欲知更多task信息,RTFSC无疑是不二选择,gradle相关task源码请移步这里。MultiDex编译过程最重要的task主要有3个:
collect{Release/Debug}MultiDexComponents
这个task扫描AndroidManifest.xml文件中的application、activity、receiver、provider、service等相关类,这将这些类信息写入到manifest_keep.txt文件中,该文件位于build/intermediates/multi-dex/{release/debug}目录下。
shrink{Release/Debug}MultiDexComponents
这个task会根据proguard规则以及manifest_keep.txt文件来进一步优化manifest_keep.txt,将其中没有用到的类删除,最终生成componentClasses.jar文件,该文件同样位于build/intermediates/multi-dex/{release/debug}目录下。
create{Release/Debug}MainDexClassList
这个task会根据上步中生成的componentClasses.jar文件中的类,递归扫描这些类所有相关的依赖类,最终形成maindexlist.txt文件,该文件也位于build/intermediates/multi-dex/{release/debug}目录下,这个文件中的类最终会打包进classes.dex中。需要注意的是,maindexlist.txt文件并没有完全列出有所的依赖类,如果发现要查找的那个class不在maindexlist中,也无需奇怪。如果一定要确保某个类分到主dex中,将该类的完整路径加入到maindexlist中即可,这里要注意,如果加入的类并不在project中,则gradle构建会忽略这个类,如果加入了多个相同的类,则只取其中一个。这3个task在build日志中都能找到。
MultiDex ANR/Crash解决方法
知道了上面的几个task,回到前面的问题:如何将某个类强制打包到classes.dex中?上面的3个task已经给出了答案!对,只需将该类完整路径添加到maindexlist.txt文件中即可!create{Debug/Release}MainDexClassList这个task正是实现这个操作的关键,主要代码如下:
这里将需要强制分到classes.dex中的类放在keep_inmaindexlist{debug/release}.txt,这种实现方式基本能够解决眼前问题,但现在看来还是略显too simple too navie!主要问题是不可控性,任何一次对代码的改动都有可能导致不同的分包结果,这就可能隐藏着不同的类导致首次启动失败,大量测试结果事实上也证明了这种方法的不可控性。作为开发,代码的不可控性无疑无法忍受,如何改进这种方法使得MultiDex可控呢?与Dev Lead交流之中间接找到了一种改进的方案,下文细述这个方法。
MultiDex的一种改进实现
那么该如何让MultiDex分包可控呢?我的做法是:找出启动过程中所有类及依赖类,强制放入到classes.dex中!这么做要求启动类不能太多(实际上大部分App从启动Application到进入MainActivity也就几个直接类),同时尽量让主界面和二级界面充分解耦,如果不想对现有代码做太多改动,一种做法是以反射方式调用二级界面中的Activity(因为反射找不到依赖关系),不过调用时得要先判断classes2.dex是否加载完,以防某些二级界面相关代码在classes2.dex中而引起的crash,这么做虽然对功能实现上并无影响,但可能导致代码可维护性降低。另外还有一点就是,我们可以控制哪些类在classes.dex中,但无法控制哪些类分到classes2.dex中(通过dx打包的方式就另当别论了),以反射方式调用二级界面activity可以增大二级界面相关类分到dex2中的概率。
寻找启动类
如何找出App启动到主界面显示这个过程中的所有类?网上能够找得到的方法比较少,美团有自己的脚本程序找启动依赖类,但人家没开!源!!啦!!!还好google找到了CDA(Class Dependency Analyzer),通过这个工具,基本都能找到启动过程中所有Activity、Application等相关依赖类,通常会有一定偏差(会将某些系统方法也找出来了),这时还需结合App的所有类来作进一步优化(获取App所有类只需反编译dex文件形成jar,解压jar包,再用shell相关工具处理即可得到),取两者的交集基本就能找出所有启动依赖类了。这里有一点需注意:必须以debug版本的App来分析,下文会讲到为什么。
Release版本寻找启动类
为什么要将Release版本单独拿出来说呢?对,就是因为混淆!混淆可能会导致每次编译形成的class文件名不同,代码的增加或者减小也会对混淆结果产生影响,这就可能导致每次编译所需的启动类名都不一样,而Debug版本因为不会进行代码混淆,因此启动过程中的类名基本变化不大。那么问题来了,如何确定Release版本启动依赖类呢?build日志!!对,通过编译日志,我们发现,proguard{Release/Debug}这个task在create{Release/Debug}MainDexClassList这个task之前执行,这意味着,在形成maindexlist之前,我们能够确切的知道哪些类进行了混淆以及混淆之后的类名!如何获知?proguard的产物给出了答案:build/outputs/mapping/release/目录下的4个txt文件就是proguard的产物:
这里mapping.txt文件正是我们需要的,至于另外的3个文件有兴趣的可以研究下。我们来看下mapping.txt中文本的结构:
从上述信息中,我们知道经过代码混淆,android.support.ActivityManagerCompat在release版中最终打包为android.support.a类,并且对其中的方法也进行了混淆。并且注意到,文本中对类混淆的行已”:”结尾,这下问题就有解了,根据startup_keep_list_debug.txt文件中的每一行,在mapping.txt中寻找其是否被混淆,如果被混淆了,则读取经过混淆的类,如果没有被混淆,则直接获取该类,通过这几个步骤,即可形成最终Release版本的启动依赖类,代码如下:
至此,寻找启动类工作基本完成,但不难发现一个问题,那就是build release版本是将会更加耗时,从上面gradle脚本中不难发现,涉及到2层循环,并且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的影响范围可想而知。
- 提高代码质量应该足以避开MultiDex,毕竟人家微信这么大个App也才只有65428个方法,人家还没超65536呢!
- 多次启动(指首次启动)查看启动log是必须的,一来测试MultiDex是否会对首次启动时间产生明显影响,最重要的还是查看启动过程中是否有找不到的类;
- 通常多次云测也是必须的,毕竟QA能够覆盖到的机型有限,云测也节省了QA工作量。
以上便是个人实现MultiDex的一种方式,不尽完美但却能够解决当下问题,但仍然在寻求最优的解决方法,当你看到这篇文章时,如果你有好的建议或者意见,请不吝赐教!
相关参考
[1] http://developer.android.com/tools/building/multidex.html
[2] http://blog.waynell.com/2015/04/19/android-multidex/
[3] http://tech.meituan.com/mt-android-auto-split-dex.html