一、背景
随着业务规模发展,不断的加入新的功能,添加新的类库,app的方法数已经超过65535,这样的情况下就会遇到以下这个错误
导致app无法安装,开发无法进行。
具体的原因是在早期的 Android 系统中,DexOpt 有两个问题。
- DexOpt 会把每一个类的方法 id 检索起来,存在一个链表结构里面,但是这个链表的长度是用一个 short 类型来保存的,导致了方法 id 的数目不能够超过65536个。当一个项目足够大的时候,显然这个方法数的上限是不够的。
- Dexopt 使用 LinearAlloc 来存储应用的方法信息。Dalvik LinearAlloc 是一个固定大小的缓冲区。在Android 版本的历史上,LinearAlloc 分别经历了4M/5M/8M/16M限制。Android 2.2和2.3的缓冲区只有5MB,Android 4.x提高到了8MB 或16MB。当方法数量过多导致超出缓冲区大小时,也会造成dexopt崩溃。
尽管在新版本的 Android 系统中,DexOpt 修复了方法数65K的限制问题,并且扩大了 LinearAlloc 限制,但是我们仍然需要对低版本的 Android 系统做兼容。
** 关于65535的问题 请参考由Android 65K方法数限制引发的思考**
关于这个问题可以采用分包的方案解决,简单的说,分包就是在打包时将应用的代码分成多个 dex,使得主 dex 的方法数和所需的 LinearAlloc 不超过系统限制。在应用启动或运行过程中,首先是主 dex 启动运行后,再加载从 dex,这样就绕开了这两个限制。但是方案就要解决两个问题:一是如何对 dex 进行拆分,二是如何加载从 dex。
目前的分包方案有Google官方方案和DEX 自动拆包和动态加载方案
Google 官方方案
Android官方MultiDex方案使用比较简单:
http://developer.android.com/intl/zh-cn/tools/building/multidex.htm
在gradle中添加MultiDex支持
加载classes2.dex
AndroidManifest.xml的application中添加MultiDexApplication,或者如果已经重载了Application,则在attachBaseContext()中执行MultiDex.install()即可。
MultiDex自动拆包带来的问题:
- 在冷启动时因为需要安装DEX文件,如果DEX文件过大时,处理时间过长,很容易引发ANR(Application Not Responding);
- 采用MultiDex方案的应用可能不能在低于Android 4.0 (API level 14) 机器上启动,这个主要是因为Dalvik linearAlloc的一个bug ;
- 采用MultiDex方案的应用因为需要申请一个很大的内存,在运行时可能导致程序的崩溃,这个主要是因为Dalvik linearAlloc 的一个限制,这个限制在 Android 4.0 (API level 14)已经增加了, 应用也有可能在低于 Android 5.0 (API level 21)版本的机器上触发这个限制
第一个坑:启动时间过长
在解决这些坑之前,先来简要看看App启动流程
不难发现,Application.attachBaseContext是我们能控制的最早执行的代码,在这个方法里面执行MultiDex.install()无疑是最佳时机。
还有一点我们需要了解,首次启动时Dalvik虚拟机会对classes.dex执行dexopt操作,生成ODEX文件,这个过程非常耗时,而执行MultiDex.install()必然会再次对classes2.dex执行dexopt等操作,所有这些操作必须在5秒内完成,否则就ANR;
非首次启动则直接从cache中读取已经执行过dexopt的ODEX文件,这个过程对启动并无太大影响。基于此,对attachBaseContext稍作改动:
首次启动开启一个线程来加载classes2.dex,防止阻塞UI线程,非首次启动则同步执行。
initAfterDex2Installed()方法是根据Classes2.dex中结果,将涉及到的相关初始化工作移到classes2.dex加载完之后执行,避免启动问题。
建议在classes2.dex加载完成前,设置一个启动等待界面,之后再进入主界面,确保用户体验。
第二个坑:ANR/Crash
实际上所有这些都是同一个问题导致的:classes2.dex没加载完成之前,程序调用了classes2.dex中的类或者方法!adb logcat看下,基本也就是3类问题:
那么具体如何实现呢?还得先简单了解下MultiDex编译过程。
要想完全了解MultiDex编译过程,需要对gradle, groovy有些了解,限于篇幅这里不对它们作过多介绍,只介绍MultiDex编译过程中关键的几个gradle task。
task,顾名思义就是任务的意思,是gradle build的基本单位,一个project所有的build最终是由一个个task来完成,以下面一段简单的build日志为例:
日志中,generateDebugSources、processDebugJavaRes…都是build过程中依次执行的task任务,将上面的Debug替换为Release即为Release build时的task,这个好理解,下面主要介绍Debug的task。
这些task分别完成不同的功能,最终完成整个build,其中与MultiDex编译过程相关的task主要有3个:
- collectDebugMultiDexComponents
先收集,这个task扫描AndroidManifest.xml中的application、activity、receiver、provider、service等相关类,并将这些类的信息写入到manifest_keep.txt文件中,该文件位于build/intermediates/multi-dex/debug目录下。 - shrinkDebugMultiDexComponents
再压缩,这个task会根据proguard规则以及manifest_keep.txt文件来进一步优化manifest_keep.txt,将其中没有用到的类删除,最终生成componentClasses.jar文件,该文件同样位于build/intermediates/multi-dex/debug目录下。 - 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如何解决?
只需将该类完整路径添加到maindexlist.txt中即可!createDebugMainDexClassList这个task正是实现这个操作的关键,主要代码如下:
这里将需要强制分到classes.dex中的类放在keepin_maindexlist_debug.txt,这种实现方式基本能够解决眼前问题;(此方法在实践中并未生效)
另一种方法
新建文件multiDexKeep.pro和multiDexKeep.txt,两个文件中加入你要打到mainexlist.txt文件中的类名
.pro文件写法与混淆配置文件中保护类的写法一致;
.txt文件中包路径+类名.class;
然后,在build.gradle中加入:
multiDexKeepProguard file('multiDexKeep.pro')// keep specific classes using proguard syntax multiDexKeepFile file('multiDexKeep.txt')// keep specific classes
最后,rebuild你的工程,重新构建完成你就可以在maindexlist.txt文件中找到响应的类;
但是这样还是有问题,主要问题是不可控,任何一次对代码的改动都有可能导致不同的分包结果,这就可能隐藏着不同的类导致首次启动失败,大量测试结果也证明了这种方法的不可控性。作为开发,代码不可控无疑无法忍受,如何改进这种方法使得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的产物:
这里mapping.txt文件正是我们需要的。我们简单了解下mapping.txt中文本的结构:
从上述信息中,我们知道经过代码混淆,android.support.ActivityManagerCompat在release版中最终打包为android.support.a类,并且对其中的方法、属性也进行了混淆。
并且注意到,文本中对类混淆的行以”:”结尾。
这下问题就有解了: - 根据startup_keep_list_debug.txt文件中的每一行,在mapping.txt中寻找其是否被混淆。
- 如果被混淆了,则读取经过混淆的类。
- 如果没有被混淆,则直接获取该类。
通过以上几个步骤,即可形成最终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方式等等
性能影响
Dex 分包后,如果是启动时同步加载,对应用的启动速度会有一定的影响,但是主要影响的是安装后首次启动。这是因为安装后首次启动时,Android 系统会对加载的从 dex 做 Dexopt 并生成 ODEX,而 Dexopt 是比较耗时的操作,所以对安装后首次启动速度影响较大。在非安装后首次启动时,应用只需加载 ODEX,这个过程速度很快,对启动速度影响不大。同时,从 dex 的大小也直接影响启动速度,即从dex 越小则启动越快。
查阅资料中看,dex 的原始大小在 1M 左右,经过测试,安装后首次启动时,在 GT-I8160(Android 2.3) 上加载耗时大约 1200ms,在 N i9250(Android 4.3) 上加载耗时大约 1000ms;非安装后首次启动时,在这两台测试手机上的加载速度分别为约 10ms 和 4ms。
目前凤凰金融app,分成两个dex,
主dex 7.8m,从dex 大约108kb,目前内存消耗问题不大;
另一种解决办法:
专门解决此问题的第三方库 TurboDex
https://github.com/asLody/TurboDex
总结:
目前凤凰金融Android端 解决方法数超过65535 ,考虑到时间,人力成本,可以采用官方方法,而且测试 低端机型酷派 4.3系统时并未发现问题。
后期继续对动态分包进行调研