XDRush

MultiDex实践

Why MultiDex?

Android应用65535方法数的限制一直为广大开发者所诟病,在应用功能越来越丰富、各种开源库越来越多的今天,65k方法数瓶颈俨然已是一大绊脚石。至于怎么解决这个问题,先来看看google官方给出的方案:

  1. 插件化

将应用的非核心功能做成单独的App,实现该App和插件App相关的接口即可,实现以及体验如何,大家不妨来体验下Dolphin浏览器(这里做个小广告)。

  1. 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支持

    1
    multiDexEnable 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启动流程:

1
2
Application.attachBaseContext()->Application.onCreate() -> MainActivity.onCreate ->
MainActivity.onStart() -> MainActivity.onResume()

不难发现,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稍作改动:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Override
protected void attachBaseContext(final Context base) {
super.attachBaseContext(base);
// TODO: your init code here.
initOperationBeforeDex2Installed();
boolean isAppFirstLaunch = FirstLaunchManager.getInstance().firstLaunch();
if (isAppFirstLaunch) { // 首次启动
new Thread(new Runnable() {
@Override
public void run() {
MultiDex.install(base);
// TODO: your init code here, some init operation of which some classes were divided into dex2.
initOperationAfterDex2Installed();
}
}).start();
} else { // 非首次启动
MultiDex.install(base);
// TODO: your init code here, some init operation of which some classes were divided into dex2.
initOperationAfterDex2Installed();
}
}

以上逻辑便是改动之后的初步实现,首次启动开启一个线程来加载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类问题引起的:

1
2
3
4
(1) NoClassDefFoundError
(2) Could not find class
(3) Could not find method
(4) NDK Error

这里需重点讨论下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日志为例:

1
2
3
4
5
6
MyProject:generateReleaseSources
MyProject:processReleaseJavaRes
MyProject:compileReleaseNdk UP-TO-DATE
MyProject:compileReleaseSources
MyProject:collectReleaseMultiDexComponents
MyProject:proguardRelease

日志中,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正是实现这个操作的关键,主要代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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")) {
tempFile = new File("project.rootDir/MyProject/keep_in_maindexlist_release.txt")
keepFile = new File("{project.buildDir}/intermediates/release/maindexlist.txt")
}
tempFile.eachLine("utf-8") { str, linenumber ->
keepFile.append(str + "\n")
}
}
}
}

这里将需要强制分到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的产物:

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

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

1
2
3
4
5
6
7
8
android.support.ActivityManagerCompat -> android.support.a:
48:52:int getLargeMemoryClass() -> a
62:83:boolean isHighEndGfx(android.content.Context) -> a
android.support.ClipboardManagerCompat -> android.support.b:
29:32:android.support.ClipboardManagerCompat getInstance(android.content.Context) -> a
java.lang.CharSequence getText() -> a
void setText(java.lang.CharSequence) -> a
boolean hasPrimaryClip() -> b

从上述信息中,我们知道经过代码混淆,android.support.ActivityManagerCompat在release版中最终打包为android.support.a类,并且对其中的方法也进行了混淆。并且注意到,文本中对类混淆的行已”:”结尾,这下问题就有解了,根据startup_keep_list_debug.txt文件中的每一行,在mapping.txt中寻找其是否被混淆,如果被混淆了,则读取经过混淆的类,如果没有被混淆,则直接获取该类,通过这几个步骤,即可形成最终Release版本的启动依赖类,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
if (task.name.startsWith("create") && task.name.endsWith("MainDexClassList")) {
task.doLast {
println "create MainDexClassList"
File debugStartupFile = new File("$project.rootDir/MyProject/startup_keep_list_debug.txt")
if (task.name.contains("Debug")) {
keepFile = new File("${project.buildDir}/intermediates/multi-dex/debug/maindexlist.txt")
// 情况maindexlist.txt
FileWriter fileWriter = new FileWriter(keepFile)
fileWriter.write("")
fileWriter.close()
// 写入启动依赖类到maindexlist.txt中
debugStartupFile.eachLine("utf-8") { str, linenumber ->
keepFile.append(str + "\n")
}
} else if (task.name.contains("Release") {
File startupFile = new File("${project.buildDir}/intermediates/multi-dex/release/maindexlist.txt")
FileWriter fileWriter = new FileWriter(startupFile)
fileWriter.write("")
fileWriter.close()
File mappingFile = new File("${project.buildDir}/outputs/mapping/release/mapping.txt")
debugStartupFile.eachLine("utf-8") { str, linenumber ->
boolean hasProguarded = false
mappingFile.eachLine("utf-8") { mapStr, linenumber1 ->
String string = mapStr.substring(0, mapStr.indexOf("->") - 1)
string = string.replace('.', '/')
string += ".class"
if (string.equals(str)) {
String strTmp = mapStr.substring(str.length() - 2)
strTmp = strTmp.replace('.', '/')
strTmp = strTmp.replace(':', '.class')
startupFile.append(strTmp + "\n")
hasProguarded = true
return
}
}
}
if (!hasProguarded) {
startupFile.append(str + "\n")
}
}
}
}

至此,寻找启动类工作基本完成,但不难发现一个问题,那就是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