Android Dex 分包指南

一、背景

随着业务规模发展,不断的加入新的功能,添加新的类库,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自动拆包带来的问题:

  1. 在冷启动时因为需要安装DEX文件,如果DEX文件过大时,处理时间过长,很容易引发ANR(Application Not Responding);
  2. 采用MultiDex方案的应用可能不能在低于Android 4.0 (API level 14) 机器上启动,这个主要是因为Dalvik linearAlloc的一个bug ;
  3. 采用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个:

  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如何解决?
    只需将该类完整路径添加到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类,并且对其中的方法、属性也进行了混淆。
    并且注意到,文本中对类混淆的行以”:”结尾。
    这下问题就有解了:
  4. 根据startup_keep_list_debug.txt文件中的每一行,在mapping.txt中寻找其是否被混淆。
  5. 如果被混淆了,则读取经过混淆的类。
  6. 如果没有被混淆,则直接获取该类。
    通过以上几个步骤,即可形成最终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系统时并未发现问题。
    后期继续对动态分包进行调研
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,117评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,328评论 1 293
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,839评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,007评论 0 206
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,384评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,629评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,880评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,593评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,313评论 1 243
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,575评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,066评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,392评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,052评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,082评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,844评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,662评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,575评论 2 270

推荐阅读更多精彩内容