前言
最近开发中我们发现,我们的产品在Android设备版本低于5.0以下第一次安装启动会出现黑屏、ANR等情况。而第二次,第三次,就不会出现这种情况。后来通过分析,我们确定了这是dex分包导致的。
首先要说的是,在我们项目中项目的方法数早已超过65535,也就是64k。我们已经在利用官方的教程启用分包并配置MultiDex。
本文暂不涉及LinearAlloc太小引起的 INSTALL_FAILED_DEXOPT 异常,因为。。我们的最低api为16,LinearAlloc都达到了8m或者16m。
本文是对dex分包的优化方案。
分析
apk构建流程
首先必须简单了解Android Apk的构建流程
如图所示,编译器将所有的.java文件编译成字节码最后打包成dex文件,然后和其他资源打包成apk,最后通过工具签名,部署到设备上。
在你的最大方法数超过65535时,如果不进行分包处理。那么在编译的时候,就会报如下异常:
Conversion to Dalvik format failed:Unable to execute dex: method ID not in [0, 0xffff]: 65536
注意:不同的gradle版本可能报的异常文案不太一样,但是大意都是说方法数超出了65536,一个dex文件无法放得下。
注意,当你的方法数超过单个dex方法数限制的时候(默认单个dex方法数为65535),gradle会构建出多个dex文件。其中一个为主dex文件,其它的为子dex文件。命名为classesN.dex,N为dex的顺序。
在我们新闻项目中,dex文件如下所示。
classes.dex为主dex,其余的为子dex。
上面说了,单个dex文件的最大方法数就为65535。为什么是65535呢,因为android系统以一个short链表的数据结构存储着方法的索引,short为在android系统中大小为2个字节,最大数也就是65535。(这其实是Google自己设计的坑。。)单个dex文件的最大方法数可以自己手动自定义,但是不能超过65535。
apk安装流程
从图中我们可以看到,一个apk中主要包含
Dex File
和
Resources & Native Code
。其中后者是交给虚拟机Native执行的,我们在这里不关心。我们只关心虚拟机如何处理加载
Dex File
。
从图中可以看到,虚拟机有
Dalvik
和
ART
两种实现。
ART
先说art,因为art比较简单。在Android5.0(包含)以上版本的虚拟机实现中,Google用ART虚拟机替代了Dalvik。在应用第一次安装过程中,注意是第一次安装过程中,并不是在应用运行时。PackageManagerService会调用dex2oat函数将所有的.dex文件经过一系列的处理,生成一个oat文件。而 oat 文件是 elf 文件,是可以在本地执行的文件,而 Android Runtime 替换掉了虚拟机读取的字节码转而用本地可执行代码,这就被叫做 AOT(ahead-of-time)。odex文件保存在/data/cache/dalvik_cache
目录下,而ART虚拟机实际执行过程中,加载运行的就是.oat文件。所以在android5.0以上不需要担心分包带来的麻烦。
Dalvik
在Android系统版本低于5.0的设备上,虚拟机实现为Dalvik。在应用第一次安装启动时,注意!!是第一次安装启动,Android系统的PackageManagerService调用dexopt函数,对dex文件进行优化,将dex的依赖文件以及一些辅助数据打包成odex文件,即Optimised Dex,存放在/data/cache/dalvik_cache
目录下。保存格式为apk路径 @ apk名 @ classes.dex
。执行 ODEX 文件的效率会比直接执行 Dex 文件的效率要高很多。
好了,坑来了。
上面我们说到了,android系统通过dexopt将我们的dex编译成了odex,但是!!android系统它只会将主dex编译成odex,不能将子dex也变成odex加载进内存中。所以,当你的在构建之后生成多个dex文件之后,你可以通过这种方式,在应用启动的回调方法中,将其他的子dex文件手动解压、编译、加载进内存中。这也是官方文档的做法。
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
if (PreloadProcessHelper.getInstance(this).attachBaseContext()) {
return;
}
}
而通过MultiDex源码可以看到,MultiDex的安装大概分为几步,第一步打开apk这个zip包,第二步把MultiDex的dex解压出来(除去Classes.dex之外的其他DEX,例如:classes2.dex, classes3.dex等等),因为android系统在启动app时只加载了第一个Classes.dex,其他的DEX需要我们人工进行安装,第三步通过反射进行安装。
这三步都是比较耗时,也比较容易引起ANR,甚至长时间的黑屏,影响用户体验。
好了。知道了原因。如何解决?
解决方案
在参考了众多的网上资料后,目前主流大概有这么几种方案解决。
微信:
首次加载在地球中页中, 并用线程去加载(但是 5.0 之前加载 dex 时还是会挂起主线程一段时间(不是全程都挂起))。
dex 形式
微信是将包放在assets
目录下的,在加载 dex 的代码时,实际上传进去的是zip
,在加载前需要验证MD5
,确保所加载的 dex文件 没有被篡改。
dex 类分包规则
分包规则即将所有Application
、ContentProvider
以及所有export
的Activity
、Service
、Receiver
的间接依赖集都必须放在主 dex。
加载 dex 的方式
加载逻辑这边主要判断是否已经 dexopt,若已经 dexopt,即放在attachBaseContext
加载,反之放于地球中用线程加载。怎么判断?因为在微信中,若判断 revision 改变,即将 dex 以及dexopt
目录清空。只需简单判断两个目录 dex 名称、数量是否与配置文件的一致。
总的来说,这种方案用户体验较好,缺点在于太过复杂,每次都需重新扫描依赖集,而且使用的是比较大的间接依赖集。
Facebook:
Facebook的思路是将 MultiDex.install()
操作放在另外一个经常进行的。
dex 形式
与微信相同。
dex 类分包规则
Facebook
将加载 dex 的逻辑单独放于一个单独的 nodex
进程中。
所有的依赖集为 Application、NodexSplashActivity 的间接依赖集即可。
加载 dex 的方式
因为NodexSplashActivity
的 intent-filter
指定为 Main 和 LAUNCHER ,所以一打开 App 首先拉起 nodex 进程,然后打开NodexSplashActivity
进行 MultiDex.install()
。如果已经进行了 dexpot 操作的话就直接跳转主界面,没有的话就等待 dexpot 操作完成再跳转主界面。
这种方式好处在于依赖集非常简单,同时首次加载 dex 时也不会卡死。但是它的缺点也很明显,即每次启动主进程时,都需先启动 nodex 进程。尽管 nodex 进程逻辑非常简单,这也需100ms以上。
美团加载方案:
dex 形式
在 gradle 生成 dex 文件的这步中,自定义一个 task 来干预 dex 的生产过程,从而产生多个 dex 。
tasks.whenTaskAdded { task ->
if (task.name.startsWith('proguard') && (task.name.endsWith('Debug') || task.name.endsWith('Release'))) {
task.doLast {
makeDexFileAfterProguardJar();
}
task.doFirst {
delete "${project.buildDir}/intermediates/classes-proguard";
String flavor = task.name.substring('proguard'.length(), task.name.lastIndexOf(task.name.endsWith('Debug') ? "Debug" : "Release"));
generateMainIndexKeepList(flavor.toLowerCase());
}
} else if (task.name.startsWith('zipalign') && (task.name.endsWith('Debug') || task.name.endsWith('Release'))) {
task.doFirst {
ensureMultiDexInApk();
}
}
}
dex 类分包规则
把 Service、Receiver、Provider 涉及到的代码都放到主 dex 中,而把 Activity 涉及到的代码进行了一定的拆分,把首页 Activity、Laucher Activity 、欢迎页的 Activity 、城市列表页 Activity 等所依赖的 class 放到了主 dex 中,把二级、三级页面的 Activity 以及业务频道的代码放到了第二个 dex 中,为了减少人工分析 class 的依赖所带了的不可维护性和高风险性,美团编写了一个能够自动分析 class 依赖的脚本, 从而能够保证主 dex 包含 class 以及他们所依赖的所有 class 都在其内,这样这个脚本就会在打包之前自动分析出启动到主 dex 所涉及的所有代码,保证主 dex 运行正常。
加载 dex 的方式
通过分析 Activity 的启动过程,发现 Activity 是由 ActivityThread 通过 Instrumentation 来启动的,那么是否可以在 Instrumentation 中做一定的手脚呢?通过分析代码 ActivityThread 和 Instrumentation 发现,Instrumentation 有关 Activity 启动相关的方法大概有:execStartActivity、 newActivity 等等,这样就可以在这些方法中添加代码逻辑进行判断这个 class 是否加载了,如果加载则直接启动这个 Activity,如果没有加载完成则启动一个等待的 Activity 显示给用户,然后在这个 Activity 中等待后台第二个 dex 加载完成,完成后自动跳转到用户实际要跳转的 Activity;这样在代码充分解耦合,以及每个业务代码能够做到颗粒化的前提下,就做到第二个 dex 的按需加载了。
美团的这种方式对主 dex 的要求非常高,因为第二个 dex 是等到需要的时候再去加载。重写Instrumentation 的 execStartActivity 方法,hook 跳转 Activity 的总入口做判断,如果当前第二个 dex 还没有加载完成,就弹一个 loading Activity等待加载完成。
综合
微信的方案需要将 dex 放于 assets 目录下,在打包的时候太过负责;美团的方案确实很 hack,但是对于项目已经很庞大,耦合度又比较高的情况下并不适合。
最后,我们采用了Facebook的解决方案!
Talk is cheap. Show me the code. --- Linus Torvalds
哈哈哈哈我就直接上部分关键代码了。
我们在应用启动的时候,默认在一个子进程中启动一个Activity。这里我们称为preload
进程。
在PreloadActivity
代码如下。
public class PreloadActivity extends Activity {
@Override
@SuppressWarnings("all")
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (!PreloadProcessHelper.getInstance(getApplication()).isAppFirstInstall()) {
//不是第一次安装启动应用
startSplashActivity();
releaseAndFinish();
return;
}
//启动加载dex分包任务
new LoadDexTask().execute();
}
...
}
我们在PreloadActivity
中,我们先判断是否第一次安装启动应用,当应用不是第一次安装启动时,我们直接启动闪屏页,并且结束掉子进程。
/**
* 启动SplashActivity
*/
private void startSplashActivity() {
Intent intent = new Intent(this, SplashActivity.class);
startActivity(intent);
overridePendingTransition(0, 0);
}
@SuppressWarnings("all")
class LoadDexTask extends PreloadAsyncTask {
@Override
protected Void doInBackground(Void... voids) {
try {
MultiDex.install(getApplication());
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
@Override
protected void onPostExecute(Void aVoid) {
try {
//将ipc数据序列化后写入文件
PreloadProcessHelper.writePreloadProcessTempFile(this);
} catch (IOException e) {
e.printStackTrace();
}
startSplashActivity();
listenFinishEvent();
}
}
我们可以看到,在分包结束之后,创建了一个新的文件,这是一个空文件。目的是用来做主进程和子进程IPC通讯用的。这里我们称这个文件为A文件。我执行了一个listenFinishEvent
方法。这是一个用来监听刚刚所创建A文件是否被删除。
/**
* 监听结束Activity以及此进程事件
*/
private void listenFinishEvent() {
try {
while (true) {
if (PreloadProcessHelper.getInstance(getApplication()).isExistPreloadProcessTempFile()) {
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
} else {
releaseAndFinish();
break;
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
如果监听到文件被删除。直接结束掉PreloadActivity
以及子进程。
然后。我们的SplashActivity
会在主进程打开,我们在SplashActivity
中的onResume
中检测是否存在A文件。如果存在文件,那么说明此次子进程还在继续运行,我们删除A文件。
/**
* 删除 预加载进程中的数据保存文件
* 文件用来IPC通知preload进程结束
*
* @throws IOException io
*/
public void deletePreloadProcessTempFile() throws IOException {
File file = getPreloadProcessTempFile(mApplication);
if (file.exists()) {
file.delete();
}
}
此时。子进程就会监听到A文件被删除。直接结束掉PreloadActivity
以及子进程。
可能有很多读者就有疑问了。为什么在分包结束之后,不直接结束掉PreloadActivity
以及子进程?
主要是为了防止在一些低端设备上可能会出现短暂的黑屏。因为在跨进程启动SplashActivity
的时候,系统需要做一些额外的工作。包括重新加载dex包以及SplashActivity
的初始化工作等。所以我们在SplashActivity
的onResume
回调中通知子进程结束。注意:PreloadActivity
必须取消window动画。
注意点
- 因为涉及到多进程,所以会初始化两次Application,需要在各个方法中进行判断。否则可能会造成你不想要的结果~
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
if (PreloadProcessHelper.getInstance(this).attachBaseContext()) {
return;
}
}
@Override
public void onCreate() {
super.onCreate();
if (PreloadProcessHelper.getInstance(this).onCreate()) {
return;
}
...
}
我这里对代码实行了封装。主要做的就是,判断当前进程是否在主进程,如果不在主进程直接return。判断是否在主进程代码如下:
public static String getCurrentProcessName(Context context) {
int pid = Process.myPid();
ActivityManager mActivityManager = (ActivityManager)context.getSystemService("activity");
Iterator var3 = mActivityManager.getRunningAppProcesses().iterator();
RunningAppProcessInfo appProcess;
do {
if(!var3.hasNext()) {
return null;
}
appProcess = (RunningAppProcessInfo)var3.next();
} while(appProcess.pid != pid);
return appProcess.processName;
}
/**
* 是否处于主线程环境
*
* @return
*/
private boolean isInMainProcess() {
return ProcessUtils.getCurrentProcessName(mApplication).equals(mApplication.getPackageName());
}
- 在较新的gradle task中,默认只将四大组件以及相对应的直接引用类放在主dex,注意,是直接引用类。
如果你在PreloadActivity
中还并行做了其他的操作,那么你要保证这些操作所引用到的所有的类要包含在主dex中。
你需要手动将引用类的全路径配置到响应文件中。如果不手动声明在主dex文件中的类,那么有可能造成NoClassDefFoundError
或者ClassNotFoundException
错误。
有两种配置方式。
-
multiDexKeepFile
方式
android {
buildTypes {
release {
multiDexKeepFile file 'multidex-config.txt'
...
}
}
}
在model同级目录下创建 multidex-config.txt
文件。格式如下:
com/example/MyClass.class
com/example/MyOtherClass.class
-
multiDexKeepProguard
方式
android {
buildTypes {
release {
multiDexKeepProguard 'multidex-config.pro'
...
}
}
}
在model同级目录下创建 multidex-config.pro
文件。格式如下:
-keep class com.example.MyClass
-keep class com.example.MyClassToo
multiDexKeepProguard 文件使用与 Proguard 相同的格式,并且支持整个 Proguard 语法。
你必须在主进程也执行一次
MultiDex.install(mApplication);
方法
可能很多人会问了。我不是在子进程执行分包了吗。什么在主进程还需要执行。注意。这是两个进程,MultiDex.install(mApplication);
的主要任务是把所有的子dex通过dexopt进行解压,编译后生成的odex文件保存在本地,这些步骤是非常耗时,主进程只需要将生成后的odex文件加载到内存中就可以。这个步骤不到10ms。是非常快了。就像子进程已经把所有的食材都处理加工好了,你只需要放下锅就好了。好了。我饿了。,继续优化
其实。还可以继续优化。很多应用在第一次打开需要初始化很多组件。比如,你可能需要从某个第三方服务商api接口中获取数据,然后保存在本地。或者,你可能某个组件初始化的优先级很高。
这时候,你可以把这些工作放到子进程中来,并行运行。
开启多个子线程,一个执行分包任务,也就是MultiDex.install(mApplication);
其他的线程执行额外的任务。需要注意的是。你必须把这些额外的任务说应用的类手动配置到主dex中。
如果你在子进程中要进行持久化数据的保存,不能使用SharedPreference
,因为SharedPreference
内存机制原因,无法实现同步。你可以将数据进行序列化后写入A文件,对!!就是那个在进程自己创建的临时文件,然后在主进程中把数据取出来再进行持久化保存。写了一早上,点个赞呗~