关于 Android中的插件化开发,dex分包,热修复(Tinker)的思考(二)

时间:2020-11-26 15:06:17

插件化开发的主要原理就是动态加载技术。上文已经对动态加载DexClassLoader进行了解析今天要讲的是动态加载技术的亲戚 —— MultiDex。他们的核心原理之一都是dex文件的加载。

先来理解概念
MultiDex

MultiDex是Google为了解决“65535方法数超标”以及“INSTALL_FAILED_DEXOPT”问题而开发的一个Support库 这篇博客主要是配合源码分析MultiDex的工作原理,使用方法,以及提供一些MultiDex优化的方案。

实现:
1、通过反射获取PathClassLoader中的DexPathList中的Element数组(已加载了第一个dex包,由系统加载)
2、通过反射获取DexClassLoader中的DexPathList中的Element数组(将第二个dex包加载进去)
3、将两个Element数组合并之后,再将其赋值给PathClassLoader的Element数组
谷歌提供的MultiDex支持库就是按照这个思路来实现的。下面看具体原理。

前言:Dex的工作机制

Android程序的每一个Class都是由ClassLoader#loadClass方法加载进内存的,更准确来说,一个ClassLoader实例会有一个或者多个DexFile实例,调用了ClassLoader#loadClass之后,ClassLoader会通过类名,在自己的DexFile数组里面查找有没有那个DexFile对象里面存在这个类,如果都没有就抛ClassNotFound异常。ClassLoader通过调用DexFile的一个叫defineClass的Native方法去加载指定的类,这点与JVM略有不同,后者是直接调用ClassLoader#defineCLass方法,反正最后实际加载类的方法都叫defineClass就没错了。
我们知道ClassLoader主要是通过DexFile.loadDex这个静态方法来创建它需要的DexFile实例的,这里创建DexFile的时候,保存了Dex文件的文件路径mFileName,同时调用了openDexFile的Native方法打开Dex文件并返回了一个mCookie的整型变量(我不知道这个干啥用的,我猜它是一个C++用的资源句柄,用于Native层访问具体的Dex文件)。在Native层的openDexFile方法里,主要做了检查当前创建来的Dex文件是否是有效的Dex文件,还是是一个带有Dex文件的压缩包,还是一个无效的Dex文件。
加载Dex文件里的类

加载类的时候,ClassLoader又是通过DexFile#loadClass这个方法来完成的,这个方法里调用了defineClass这个Native方法,看来DexFile才是加载Class的具体API,加载Dex文件和加载具体Class都是通过Native方法完成,ClassLoader有点名不副实。
MultiDex的工作机制

当一个Dex文件太肥的时候(方法数目太多、文件太大),在打包Apk文件的时候就会出问题,就算打包的时候不出问题,在Android 5.0以下设备上安装或运行Apk也会出问题。既然一个Dex文件不行的话,那就把这个硕大的Dex文件拆分成若干个小的Dex文件,刚好一个ClassLoader可以有多个DexFile,这就是MultiDex的基本设计思路。

使用dx进行拆包
这里还有两个两类:一个是自动拆包,一个手动拆包
–multi-dex:多 dex 打包的开关
–main-dex-list=:参数是一个类列表的文件,在该文件中的类会被打包在第一个 dex 中
–minimal-main-dex:只有在–main-dex-list 文件中指定的类被打包在第一个 dex,其余的都在第二个 dex 文件中

打包过程中如何产生多个的DEX包?
如果做到动态加载,怎么决定哪些DEX动态加载呢?
如果启动后在工作线程中做动态加载,如果没有加载完而用户进行页面操作需要使用到动态加载DEX中的class怎么办?
以上疑问,下方链接有解释。
http://yydcdut.com/2016/03/20/split-dex/index.html
http://tech.meituan.com/mt-android-auto-split-dex.html
工作流程

MultiDex的工作流程具体分为两个部分,一个部分是打包构建Apk的时候,将Dex文件拆分成若干个小的Dex文件,这个Android Studio已经帮我们做了(设置 “multiDexEnabled true”),另一部分就是在启动Apk的时候,同时加载多个Dex文件(具体是加载Dex文件优化后的Odex文件,不过文件名还是.dex),这一部分工作从Android 5.0开始系统已经帮我们做了,但是在Android 5.0以前还是需要通过MultiDex Support库来支持(MultiDex.install(Context))。
注意:就是Android中在分包之后会有多个dex,但是系统默认会先找到classes.dex文件然后自动加载运行,所以这里就有一个问题,我们需要将一些初始化的重要类放到classes.dex中,不然运行就会报错或者闪退。

首先来看看使用:
1、修改Gradle的配置,支持multidex:

android {
compileSdkVersion 21
buildToolsVersion "21.1.0"
defaultConfig {
...
minSdkVersion 14
targetSdkVersion 21
...
// Enabling multidex support.
multiDexEnabled true
}
...
}
dependencies {
compile 'com.android.support:multidex:1.0.0'
}

在manifest文件中,在application标签下添加MultidexApplication Class的引用,如下所示:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.ws.android.multidex.myapp">
<application
...
android:name="android.support.multidex.MultiDexApplication">
...
</application>
</manifest>

看看MultiDexApplication类。

public class MultiDexApplication extends Application {
public MultiDexApplication() {
}

protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
MultiDex.install(this);
}
}

进入MultiDex.install(this)方法。

public static void install(Context context) {
if(IS_VM_MULTIDEX_CAPABLE) {
Log.i("MultiDex", "VM has multidex support, MultiDex support library is disabled.");
// 可以看到,MultiDex不支持SDK版本小于4的系统
} else if(VERSION.SDK_INT < 4) {
throw new RuntimeException("Multi dex installation failed. SDK " + VERSION.SDK_INT + " is unsupported. Min SDK version is " + 4 + ".");
} else {
try {
// 获取到应用信息
ApplicationInfo e = getApplicationInfo(context);
if(e == null) {
return;
}

Set var2 = installedApk;
synchronized(installedApk) {
// 得到我们这个应用的apk文件路径
// 拿到这个apk文件路径之后,后面就可以从中提取出其他的dex文件
// 并且加载dex放到一个Element数组中
String apkPath = e.sourceDir;
if(installedApk.contains(apkPath)) {
return;
}
// 将这个apk文件路径放到一个set中
installedApk.add(apkPath);

// 得到classLoader,它就是PathClassLoader
// 后面就可以从这个PathClassLoader中拿到DexPathList中的Element数组
// 这个数组里面就包括由系统加载第一个dex包
ClassLoader loader;
try {
loader = context.getClassLoader();
} catch (RuntimeException var9) {
Log.w("MultiDex", "Failure while trying to obtain Context class loader. Must be running in test mode. Skip patching.", var9);
return;
}

// 得到apk解压后得到的dex文件的存放目录,放到应用的data目录下
File dexDir = new File(e.dataDir, SECONDARY_FOLDER_NAME);

// 这个方法就是从apk中提取dex文件,放到data目录下,就不展开了
List files = MultiDexExtractor.load(context, e, dexDir, false);
if(checkValidZipFiles(files)) {
// 这个方法就是将其他的dex文件注入到系统classloader中的具体操作
installSecondaryDexes(loader, dexDir, files);
} else {
files = MultiDexExtractor.load(context, e, dexDir, true);
installSecondaryDexes(loader, dexDir, files);
}
}
} catch (Exception var11) {
Log.e("MultiDex", "Multidex installation failure", var11);
throw new RuntimeException("Multi dex installation failed (" + var11.getMessage() + ").");
}
}
}

下面我们重点看看installSecondaryDexes方法。

// loader对应的就是PathClassLoader
// dexDir是dex的存放目录
// files对应的就是dex文件
private static void installSecondaryDexes(ClassLoader loader, File dexDir, List<File> files) throws IllegalArgumentException, IllegalAccessException, NoSuchFieldException, InvocationTargetException, NoSuchMethodException, IOException {
if(!files.isEmpty()) {
if(VERSION.SDK_INT >= 19) {
MultiDex.V19.install(loader, files, dexDir);
} else if(VERSION.SDK_INT >= 14) {
MultiDex.V14.install(loader, files, dexDir);
} else {
MultiDex.V4.install(loader, files);
}
}

}

可以看到不同的sdk版本实现是有差别的,因为它里面是使用反射实现的,所以会有不同,我们看看MultiDex.V14.install方法。

private static void install(ClassLoader loader, List<File> additionalClassPathEntries, File optimizedDirectory) throws IllegalArgumentException, IllegalAccessException, NoSuchFieldException, InvocationTargetException, NoSuchMethodException {
// 这个方法就是使用反射来得到loader的pathList字段
Field pathListField = MultiDex.findField(loader, "pathList");
// 得到loader的pathList字段后,我们就可以得到这个字段的值,也就是DexPathList对象
Object dexPathList = pathListField.get(loader);
// 这个方法就是将其他的dex文件Element数组和第一个dex的Element数组合并
// makeDexElements方法就是用来得到其他dex的Elements数组
MultiDex.expandFieldArray(dexPathList, "dexElements", makeDexElements(dexPathList, new ArrayList(additionalClassPathEntries), optimizedDirectory));
}

下面来看看合并的过程

// instance对应的就是pathList对象
// fieldName 对应的就是字段名,我们要得到的就是pathList对象里面的dexElements数组
// extraElements对应的就是其他dex对应的Element数组
private static void expandFieldArray(Object instance, String fieldName, Object[] extraElements) throws NoSuchFieldException, IllegalArgumentException, IllegalAccessException {
// 得到Element数组字段
Field jlrField = findField(instance, fieldName);
// 得到pathList对象里面的dexElements数组
Object[] original = (Object[])((Object[])jlrField.get(instance));
// 创建一个新的数组用来存放合并之后的结果
Object[] combined = (Object[])((Object[])Array.newInstance(original.getClass().getComponentType(), original.length + extraElements.length));
// 将第一个dex的Elements数组复制到创建的数组中去
System.arraycopy(original, 0, combined, 0, original.length);
// 将其他dex的Elements数组复制到创建的数组中去
System.arraycopy(extraElements, 0, combined, original.length, extraElements.length);
// 将得到的这个合并的新数组的值设置到pathList对象的Element数组字段上
jlrField.set(instance, combined);
}

局限
MultiDex的实现原理已经分析完了。但是目前MultiDex还是有很多局限

1.在应用安装到手机上的时候dex文件的安装是复杂的(complex)有可能会因为第二个dex文件太大导致ANR。请用proguard优化你的代码。
2.使用了mulitDex的App有可能在4.0(api level 14)以前的机器上无法启动,因为Dalvik linearAlloc bug(Issue 22586) 。请多多测试自祈多福。用proguard优化你的代码将减少该bug几率。
3.使用了mulitDex的App在runtime期间有可能因为Dalvik linearAlloc limit (Issue 78035) Crash。该内存分配限制在 4.0版本被增大,但是5.0以下的机器上的Apps依然会存在这个限制。
4.主dex被dalvik虚拟机执行时候,哪些类必须在主dex文件里面这个问题比较复杂。build tools 可以搞定这个问题。但是如果你代码存在反射和native的调用也不保证100%正确。

优化方案
MultiDex有个比较蛋疼的问题,就是会产生明显的卡顿现象,通过上面的分析,我们知道具体的卡顿产生在解压dex文件以及优化dex两个步骤。不过好在,在Application#attachBaseContext(Context)中,UI线程的阻塞是不会引发ANR的,只不过这段长时间的卡顿(白屏)还是会影响用户体验。

PreMultiDex方案
大致思路是,在安装一个新的apk的时候,先在Worker线程里做好MultiDex的解压和Optimize工作,安装apk并启动后,直接使用之前Optimize产生的odex文件,这样就可以避免第一次启动时候的Optimize工作。
安装dex的时候,核心是创建DexFile对象并使用其Native方法对dex文件进行opt处理,同时生产一个与dex文件(.zip)同名的已经opt过的dex文件(.dex)。如果安装dex的时候,这个opt过的dex文件已经存在,则跳过这个过程,这会节省许多耗时。所以优化的思路就是,下载Apk完成的时候,预先解压dex文件,并预先触发安装dex文件以生产opt过的dex文件。这样覆盖安装Apk并启动的时候,如果MultiDex能命中解压好的dex和odex文件,则能避开耗时最大的两个操作。
不过这个方案的缺点也是明显的,第一次安装的apk没有作用,而且事先需要使用内置的apk更新功能把新版本的apk文件下载下来后,才能做PreMultiDex工作。

异步MultiDex方案
这种方案也是目前比较流行的Dex手动分包方案,启动App的时候,先显示一个简单的Splash闪屏界面,然后启动Worker线程执行MultiDex#install(Context)工作,就可以避免UI线程阻塞。不过要确保启动以及启动MultiDex#install(Context)所需要的类都在主dex里面(手动分包),而且需要处理好进程同步问题。

参考:
http://blog.csdn.net/wangbaochu/article/details/51178881
http://blog.csdn.net/synaric/article/details/53540760
http://blog.csdn.net/hp910315/article/details/51681710