美团 Android DEX自动拆包及动态加载简介
概述
作为一个 android 开发者,在开发应用时,随着业务规模发展到一定程度,不断地加入新功能、
添加新的类库,代码在急剧的膨胀,相应的 apk 包的大小也急剧增加, 那么终有一天,你会不幸遇
到这个错误:
生成的 apk 在 android 或之前的机器上无法安装,提示 INSTALL_FAILED_DEXOPT
方法数量过多,编译时出错,提示:
Conversion to Dalvik format failed:Unable to execute dex: method ID not in [0, 0xffff]:
65536
而问题产生的具体原因如下:
无法安装(Android INSTALL_FAILED_DEXOPT)问题,是由 dexopt 的 LinearAlloc 限制引起
的,在 Android 版本不同分别经历了 4M/5M/8M/16M 限制,目前主流 系统上可能都已到 16M,在
Gingerbread 或者以下系统 LinearAllocHdr 分配空间只有 5M 大小的, 高于 Gingerbread 的系统提
升到了 8M。Dalvik linearAlloc 是一个固定大小的缓冲区。在应用的安装过程中,系统会运行一个
名为 dexopt 的程序为该应用在当前机型中运行做准备。dexopt 使用 LinearAlloc 来存储应用的方法
信息。Android 和 的缓冲区只有 5MB,Android 提高到了 8MB 或 16MB。当方法数量过多
导致超出缓冲区大小时,会造成 dexopt 崩溃。
超过最大方法数限制的问题,是由于 DEX 文件格式限制,一个 DEX 文件中 method 个数采用使用
原生类型 short 来索引文件中的方法,也就是 4个字节共计最多表达 65536 个 method,field/class
的个数也均有此限制。对于 DEX 文件,则是将工程所需全部 class 文件合并且压缩到一个 DEX 文件期
间,也就是 Android 打包的 DEX 过程中, 单个 DEX 文件可被引用的方法总数(自己开发的代码以及
所引用的 Android 框架、类库的代码)被限制为 65536;
插件化? MultiDex?
解决这个问题,一般有下面几种方案,一种方案是加大 Proguard 的力度来减小 DEX 的大小和方
法数,但这是治标不治本的方案,随着业务代码的添加,方法数终究会到达这个限制,一种比较流行
的方案是插件化方案,另外一种是采用 google 提供的 MultiDex 方案,以及 google 在推出 MultiDex
之前 Android Developers 博客介绍的通过自定义类加载过程, 再就是 Facebook 推出的为 Android
应用开发的 Dalvik 补丁, 但 facebook 博客里写的不是很详细;我们在插件化方案上也做了探索和
尝试,发现部署插件化方案,首先需要梳理和修改各个业务线的代码,使之解耦,改动的面和量比较
巨大,通过一定的探讨和分析,我们认为对我们目前来说采用 MultiDex 方案更靠谱一些,这样我们
可以快速和简洁的对代码进行拆分,同时代码改动也在可以接受的范围内; 这样我们采用了 google
提供的 MultiDex 方式进行了开发。
插件化方案在业内有不同的实现原理,这里不再一一列举,这里只列举下 Google 为构建超过 65K
方法数的应用提供官方支持的方案:MultiDex。
首先使用 Android SDK Manager 升级到最新的 Android SDK Build Tools 和 Android Support
Library。然后进行以下两步操作:
1.修改 Gradle 配置文件,启用 MultiDex 并包含 MultiDex 支持:
android {
compileSdkVersion 21 buildToolsVersion ""
defaultConfig {
...
minSdkVersion 14
targetSdkVersion 21
...
// Enabling MultiDex support.
MultiDexEnabled true
}
...
}
dependencies { compile ':MultiDex:'
}
2.让应用支持多 DEX 文件。在官方文档中描述了三种可选方法:
在 的 application 中 声 明
;
如果你已经有自己的 Application 类,让其继承 MultiDexApplication;
如果你的 Application 类已经继承自其它类,你不想 /能修改它,那么可以重写
attachBaseContext()方法:
@Override
protected void attachBaseContext(Context base) {
(base);
(this);
}
并在 Manifest 中添加以下声明:
<?xml version="" encoding="utf-8"?>
<manifest xmlns:android="
package="">
<application
...
android:name="">
...
</application>
</manifest>
如果已经有自己的 Application,则让其继承 MultiDexApplication 即可.
Dex 自动拆包及动态加载
MultiDex 带来的问题
在第一版本采用 MultiDex 方案上线后,在 Dalvik 下 MultiDex 带来了下列几个问题:
在冷启动时因为需要安装 DEX 文件,如果 DEX 文件过大时,处理时间过长,很容易引发 ANR
(Application Not Responding);
采用 MultiDex 方案的应用可能不能在低于 Android (API level 14) 机器上启动,这个主要是
因为 Dalvik linearAlloc 的一个 bug (Issue 22586);
采用 MultiDex 方案的应用因为需要申请一个很大的内存,在运行时可能导致程序的崩溃,这个
主要是因为 Dalvik linearAlloc 的一个限制(Issue 78035). 这个限制在 Android (API level
14)已经增加了, 应用也有可能在低于 Android (API level 21)版本的机器上触发这个限制;
而在 ART 下 MultiDex 是不存在这个问题的,这主要是因为 ART 下采用 Ahead-of-time (AOT)
compilation 技术,系统在 APK 的安装过程中会使用自带的 dex2oat 工具对 APK 中可用的 DEX 文件进
行编译并生成一个可在本地机器上运行的文件,这样能提高应用的启动速度,因为是在安装过程中进
行了处理这样会影响应用的安装速度,对 ART 感兴趣的可以参考一下 ART 和 Dalvik 的区别.
MultiDex 的基本原理是把通过 DexFile 来加载 Secondary DEX,并存放在 BaseDexClassLoader
的 DexPathList 中。
下面代码片段是 BaseDexClassLoader findClass 的过程:
protected Class<?> findClass(String name) throws ClassNotFoundException {
List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
Class c = (name, suppressedExceptions);
if (c == null) {
ClassNotFoundException cnfe = new ClassNotFoundException("Didn't find class \""
+ name + "\" on path: " + pathList);
for (Throwable t : suppressedExceptions) {
(t);
}
throw cnfe;
}
return c;
}
下面代码片段为怎么通过 DexFile 来加载 Secondary DEX 并放到 BaseDexClassLoader 的
DexPathList 中:
private static void install(ClassLoader loader, List<File> additionalClassPathEntries,
File optimizedDirectory)
throws IllegalArgumentException, IllegalAccessException,
NoSuchFieldException, InvocationTargetException, NoSuchMethodException {
/* The patched class loader is expected to be a descendant of
* . We modify its
* pathList field to append additional DEX
* file entries.
*/
Field pathListField = findField(loader, "pathList");
Object dexPathList = (loader);
ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>();
expandFieldArray(dexPathList, "dexElements", makeDexElements(dexPathList,
new ArrayList<File>(additionalClassPathEntries), optimizedDirectory,
suppressedExceptions));
try {
if (() > 0) {
for (IOException e : suppressedExceptions) {
//(TAG, "Exception in makeDexElement", e);
}
Field suppressedExceptionsField =
findField(loader, "dexElementsSuppressedExceptions");
IOException[] dexElementsSuppressedExceptions =
(IOException[]) (loader);
if (dexElementsSuppressedExceptions == null) {
dexElementsSuppressedExceptions =
(
new IOException[()]);
} else {
IOException[] combined =
new IOException[() +
];
(combined);
(dexElementsSuppressedExceptions, 0, combined,
(), );
dexElementsSuppressedExceptions = combined;
}
(loader, dexElementsSuppressedExceptions);
}
} catch(Exception e) {
}
}
Dex 自动拆包及动态加载方案简介
通过查看 MultiDex 的源码,我们发现 MultiDex 在冷启动时容易导致 ANR 的瓶颈, 在 版本
之前的 Dalvik 的 VM 版本中, MultiDex 的安装大概分为几步,第一步打开 apk这个 zip 包,第二步
把 MultiDex 的 dex 解压出来(除去 之外的其他 DEX,例如:,
等等),因为 android 系统在启动 app 时只加载了第一个 ,其他的 DEX 需要我们人工进
行安装,第三步通过反射进行安装,这三步其实都比较耗时, 为了解决这个问题我们考虑是否可以
把 DEX 的加载放到一个异步线程中,这样冷启动速度能提高不少,同时能够减少冷启动过程中的 ANR,
对于 Dalvik linearAlloc 的一个缺陷(Issue 22586)和限制(Issue 78035),我们考虑是否可以人工
对DEX的拆分进行干预,使每个DEX的大小在一定的合理范围内,这样就减少触发Dalvik linearAlloc
的缺陷和限制; 为了实现这几个目的,我们需要解决下面三个问题:
在打包过程中如何产生多个的 DEX 包?
如果做到动态加载,怎么决定哪些 DEX 动态加载呢?
如果启动后在工作线程中做动态加载,如果没有加载完而用户进行页面操作需要使用到动态加载
DEX中的 class 怎么办?
我们首先来分析如何解决第一个问题,在使用 MultiDex 方案时,我们知道 BuildTool 会自动把
代码进行拆成多个 DEX 包,并且可以通过配置文件来控制哪些代码放到第一个 DEX 包中, 下图是
Android 的打包流程示意图:
为了实现产生多个 DEX 包,我们可以在生成 DEX 文件的这一步中, 在 Ant 或 gradle 中自定义一
个 Task 来干预 DEX 产生的过程,从而产生多个 DEX,下图是在 ant 和 gradle 中干预产生 DEX 的自定
task 的截图:
{ task ->
if (('proguard') && (('Debug') ||
('Release'))) {
{
makeDexFileAfterProguardJar();
}
{
delete "${}/intermediates/classes-proguard";
String flavor = ('proguard'.length(),
(('Debug') ? "Debug" : "Release"));
generateMainIndexKeepList(());
}
} else if (('zipalign') && (('Debug') || ('Release')))
{
{
ensureMultiDexInApk();
}
}
}
上一步解决了如何打包出多个 DEX 的问题了,那我们该怎么该根据什么来决定哪些 class 放到
Main DEX,哪些放到 Secondary DEX 呢(这里的 Main DEX 是指在 版本的 Dalvik VM 之前由 android
系统在启动apk时自己主动加载的,而Secondary DEX是指需要我们自己安装进去的DEX,
例如:, 等), 这个需要分析出放到 Main DEX 中的 class 依赖,需要
确保把Main DEX中 class所有的依赖都要放进来,否则在启动时会发生ClassNotFoundException, 这
里我们的方案是把 Service、Receiver、Provider 涉及到的代码都放到 Main DEX 中,而把 Activity
涉及到的代码进行了一定的拆分,把首页 Activity、Laucher Activity、欢迎页的 Activity、城市
列表页 Activity 等所依赖的 class 放到了 Main DEX 中,把二级、三级页面的 Activity 以及业务频
道的代码放到了 Secondary DEX 中,为了减少人工分析 class 的依赖所带了的不可维护性和高风险性,
我们编写了一个能够自动分析 Class 依赖的脚本, 从而能够保证 Main DEX 包含 class 以及他们所依
赖的所有 class 都在其内,这样这个脚本就会在打包之前自动分析出启动到 Main DEX 所涉及的所有
代码,保证 Main DEX 运行正常。
随着第二个问题的迎刃而解,我们来到了比较棘手的第三问题,如果我们在后台加载 Secondary
DEX 过程中,用户点击界面将要跳转到使用了在 Secondary DEX 中 class 的界面, 那此时必然发生
ClassNotFoundException, 那怎么解决这个问题呢,在所有的 Activity 跳转代码处添加判断
Secondary DEX 是否加载完成?这个方法可行,但工作量非常大; 那有没有更好的解决方案呢?我
们通过分析 Activity 的启动过程,发现 Activity 是由 ActivityThread 通过 Instrumentation 来启
动的,我们是否可以在 Instrumentation 中做一定的手脚呢?通过分析代码 ActivityThread 和
Instrumentation发现,Instrumentation有关Activity启动相关的方法大概有:execStartActivity、
newActivity 等等,这样我们就可以在这些方法中添加代码逻辑进行判断这个 Class 是否加载了,如
果加载则直接启动这个 Activity,如果没有加载完成则启动一个等待的 Activity 显示给用户,然后
在这个 Activity 中等待后台 Secondary DEX 加载完成,完成后自动跳转到用户实际要跳转的
Activity;这样在代码充分解耦合,以及每个业务代码能够做到颗粒化的前提下,我们就做到
Secondary DEX 的按需加载了, 下面是 Instrumentation 添加的部分关键代码:
public ActivityResult execStartActivity(Context who, IBinder contextThread, IBinder
token, Activity target,
Intent intent, int requestCode) {
ActivityResult activityResult = null;
String className;
if (() != null) {
className = ().getClassName();
} else {
ResolveInfo resolveActivity =
().resolveActivity(intent, 0);
if (resolveActivity != null && != null) {
className = ;
} else {
className = null;
}
}
if (!(className)) {
boolean shouldInterrupted = !();
if (() || (className)) {
shouldInterrupted = false;
}
if (shouldInterrupted) {
Intent interruptedIntent = new Intent(mContext, );
activityResult = execStartActivity(who, contextThread, token, target, interruptedIntent, requestCode);
} else {
activityResult = execStartActivity(who, contextThread, token, target, intent, requestCode);
}
} else {
activityResult = execStartActivity(who, contextThread, token, target, intent, requestCode);
}
return activityResult;
}
public Activity newActivity(Class<?> clazz, Context context, IBinder token,
Application application, Intent intent, ActivityInfo info,
CharSequence title, Activity parent, String id, Object lastNonConfigurationInstance)
throws InstantiationException, IllegalAccessException {
String className = "";
Activity newActivity = null;
if (() != null) {
className = ().getClassName();
}
boolean shouldInterrupted = !();
if (() || (className)) {
shouldInterrupted = false;
}
if (shouldInterrupted) {
intent = new Intent(mContext, );
newActivity = (clazz, context, token,
application, intent, info, title, parent, id,
lastNonConfigurationInstance);
} else {
newActivity = (clazz, context, token,
application, intent, info, title, parent, id,
lastNonConfigurationInstance);
}
return newActivity;
}
实际应用中我们还遇到另外一个比较棘手的问题, 就是 Field 的过多的问题,Field 过多是由
我们目前采用的代码组织结构引入的,我们为了方便多业务线、多团队并发协作的情况下开发,我们
采用的 aar 的方式进行开发,并同时在 aar 依赖链的最底层引入了一个通用业务 aar,而这个通用业
务 aar 中包含了很多资源,而 ADT14 以及更高的版本中对 Library 资源处理时,Library 的 R 资源不
再是 static final 的了,详情请查看 google 官方说明,这样在最终打包时 Library 中的 R没法做到
内联,这样带来了 R field 过多的情况,导致需要拆分多个 Secondary DEX,为了解决这个问题我们
采用的是在打包过程中利用脚本把 Libray 中 R field(例如 ID、Layout、Drawable 等)的引用替换
成常量,然后删去 Library 中 中的相应 Field。
总结
上面就是我们在使用 MultiDex 过程中进化而来的 DEX 自动化拆包的方案,这样我们就可以通过
脚本控制来进行自动化的拆分 DEX,然后在运行时自由的加载 Secondary DEX,既能保证冷启动速度,
又能减少运行时的内存占用。