在整理 MultiDex 优化之前, 先了解一下 Apk 的编译流程, 这样有助于后面针对 MultiDex 优化.
一, Apk 编译流程
Android Studio 按下编译按钮后发生了什么?
1. 打包资源文件, 生成 R.java 文件(使用工具 aapt, 这个工具在 Android 使用 aapt 命令查看 apk 包名 https://www.cnblogs.com/renhui/p/11381634.html 提到过, 感兴趣的可以了解一下)
2. 处理 aidl 文件, 生成 java 代码(没有 aidl 则忽略)
3. 编译 java 文件, 生成对应. class 文件(java compiler)
4. class 文件转换成 dex 文件(dex)
5. 打包成没有签名的 apk(使用工具 apkbuilder)
6. 使用签名工具给 apk 签名(使用工具 Jarsigner)
在第 4 步, 将 class 文件转换成 dex 文件, 默认只会生成一个 dex 文件, 单个 dex 文件中的方法数不能超过 65536, 不然编译会报错, 但是我们在开发 App 时肯定会集成一堆库, 方法数一般都是超过 65536 的, 解决这个问题的办法就是: 一个 dex 装不下, 用多个 dex 来装, gradle 增加一行配置: multiDexEnabled true.
具体配置方案可以参考: Android 分包 MultiDex 策略总结 https://www.cnblogs.com/renhui/p/7738421.html .
二, MultiDex 原理
虽然配置好了 MultiDex 分包策略, 但是我们发现在 Android 4.4 的手机上仅执行 MultiDex.install(context) 就可能消耗 1 秒多的时间, 那么为什么会这么耗时呢? 这里先分析一下 MultiDex 的原理.
2.1 MultiDex 原理
首先我们来看一下 MultiDex.install()方法具体执行的内容:
- public static void install(Context context) {
- Log.i("MultiDex", "Installing application");
- if (IS_VM_MULTIDEX_CAPABLE) { //5.0 以上 VM 基本支持多 dex, 啥事都不用干
- Log.i("MultiDex", "VM has multidex support, MultiDex support library is disabled.");
- } else if (VERSION.SDK_INT <4) { //
- throw new RuntimeException("MultiDex installation failed. SDK" + VERSION.SDK_INT + "is unsupported. Min SDK version is" + 4 + ".");
- } else {
- ...
- doInstallation(context, new File(applicationInfo.sourceDir), new File(applicationInfo.dataDir), "secondary-dexes", "", true);
- ...
- Log.i("MultiDex", "install done");
- }
- }
从上面的源码可以看到, 如果虚拟机本身就支持加载多个 dex 文件, 那就啥都不用做; 如果是不支持加载多个 dex(5.0 以下是不支持的), 则走到 doInstallation 方法.
- private static void doInstallation(Context mainContext, File sourceApk, File dataDir, String secondaryFolderName, String prefsKeyPrefix, boolean reinstallOnPatchRecoverableException) throws IOException, IllegalArgumentException, IllegalAccessException, NoSuchFieldException, InvocationTargetException, NoSuchMethodException, SecurityException, ClassNotFoundException, InstantiationException {
- // 获取非主 dex 文件
- File dexDir = getDexDir(mainContext, dataDir, secondaryFolderName);
- MultiDexExtractor extractor = new MultiDexExtractor(sourceApk, dexDir);
- IOException closeException = null;
- try {
- // 1. 这个 load 方法, 第一次没有缓存, 会非常耗时
- List files = extractor.load(mainContext, prefsKeyPrefix, false);
- try {
- //2. 安装 dex
- installSecondaryDexes(loader, dexDir, files);
- }
- }
- }
看一下 1. MultiDexExtractor#load 具体都执行了哪些内容:
- List<? extends File> load(Context context, String prefsKeyPrefix, boolean forceReload) throws IOException {
- if (!this.cacheLock.isValid()) {
- throw new IllegalStateException("MultiDexExtractor was closed");
- } else {
- List files;
- if (!forceReload && !isModified(context, this.sourceApk, this.sourceCrc, prefsKeyPrefix)) {
- try {
- // 读缓存的 dex
- files = this.loadExistingExtractions(context, prefsKeyPrefix);
- } catch (IOException var6) {
- Log.w("MultiDex", "Failed to reload existing extracted secondary dex files, falling back to fresh extraction", var6);
- // 读取缓存的 dex 失败, 可能是损坏了, 那就重新去解压 apk 读取, 跟 else 代码块一样
- files = this.performExtractions();
- // 保存标志位到 sp, 下次进来就走 if 了, 不走 else
- putStoredApkInfo(context, prefsKeyPrefix, getTimeStamp(this.sourceApk), this.sourceCrc, files);
- }
- } else {
- // 没有缓存, 解压 apk 读取
- files = this.performExtractions();
- // 保存 dex 信息到 sp, 下次进来就走 if 了, 不走 else
- putStoredApkInfo(context, prefsKeyPrefix, getTimeStamp(this.sourceApk), this.sourceCrc, files);
- }
- Log.i("MultiDex", "load found" + files.size() + "secondary dex files");
- return files;
- }
- }
查找 dex 文件, 有两个逻辑, 有缓存就调用 loadExistingExtractions 方法, 没有缓存或者缓存读取失败就调用 performExtractions 方法, 然后再缓存起来. 使用到缓存, 那么 performExtractions 方法想必应该是很耗时的, 分析一下代码:
- private List<MultiDexExtractor.ExtractedDex> performExtractions() throws IOException {
- // 先确定命名格式
- String extractedFilePrefix = this.sourceApk.getName() + ".classes";
- this.clearDexDir();
- List<MultiDexExtractor.ExtractedDex> files = new ArrayList();
- ZipFile apk = new ZipFile(this.sourceApk); // apk 转为 zip 格式
- try {
- int secondaryNumber = 2;
- //apk 已经是改为 zip 格式了, 解压遍历 zip 文件, 里面是 dex 文件,
- // 名字有规律, 如 classes1.dex,class2.dex
- for(ZipEntry dexFile = apk.getEntry("classes" + secondaryNumber + ".dex"); dexFile != null; dexFile = apk.getEntry("classes" + secondaryNumber + ".dex")) {
- // 文件名: xxx.classes1.zip
- String fileName = extractedFilePrefix + secondaryNumber + ".zip";
- // 创建这个 classes1.zip 文件
- MultiDexExtractor.ExtractedDex extractedFile = new MultiDexExtractor.ExtractedDex(this.dexDir, fileName);
- //classes1.zip 文件添加到 list
- files.add(extractedFile);
- Log.i("MultiDex", "Extraction is needed for file" + extractedFile);
- int numAttempts = 0;
- boolean isExtractionSuccessful = false;
- while(numAttempts <3 && !isExtractionSuccessful) {
- ++numAttempts;
- // 这个方法是将 classes1.dex 文件写到压缩文件 classes1.zip 里去, 最多重试三次
- extract(apk, dexFile, extractedFile, extractedFilePrefix);
- ...
- }
- // 返回 dex 的压缩文件列表
- return files;
- }
这里的逻辑就是解压 apk, 遍历出里面的 dex 文件, 例如 class1.dex,class2.dex, 然后又压缩成 class1.zip,class2.zip..., 然后返回 zip 文件列表.
只有第一次加载才会执行解压和压缩过程, 第二次进来读取 sp 中保存的 dex 信息, 直接返回 file list, 所以第一次启动的时候比较耗时. dex 文件列表找到了, 回到上面 MultiDex#doInstallation 方法的注释 2, 找到的 dex 文件列表, 然后调用 installSecondaryDexes 方法进行安装, 怎么安装呢? 方法点进去看 SDK 19 以上的实现:
- private static final class V19 {
- private V19() {
- }
- static void install(ClassLoader loader, List<? extends File> additionalClassPathEntries, File optimizedDirectory) throws IllegalArgumentException, IllegalAccessException, NoSuchFieldException, InvocationTargetException, NoSuchMethodException, IOException {
- Field pathListField = MultiDex.findField(loader, "pathList");//1 反射 ClassLoader 的 pathList 字段
- Object dexPathList = pathListField.get(loader);
- ArrayList<IOException> suppressedExceptions = new ArrayList();
- // 2 扩展数组
- MultiDex.expandFieldArray(dexPathList, "dexElements", makeDexElements(dexPathList, new ArrayList(additionalClassPathEntries), optimizedDirectory, suppressedExceptions));
- ...
- }
- private static Object[] makeDexElements(Object dexPathList, ArrayList<File> files, File optimizedDirectory, ArrayList<IOException> suppressedExceptions) throws IllegalAccessException, InvocationTargetException, NoSuchMethodException {
- Method makeDexElements = MultiDex.findMethod(dexPathList, "makeDexElements", ArrayList.class, File.class, ArrayList.class);
- return (Object[])((Object[])makeDexElements.invoke(dexPathList, files, optimizedDirectory, suppressedExceptions));
- }
- }
1. 反射 ClassLoader 的 pathList 字段
2. 找到 pathList 字段对应的类的 makeDexElements 方法
3. 通过 MultiDex.expandFieldArray 这个方法扩展 dexElements 数组, 怎么扩展? 看下代码:
- private static void expandFieldArray(Object instance, String fieldName, Object[] extraElements) throws NoSuchFieldException, IllegalArgumentException, IllegalAccessException {
- Field jlrField = findField(instance, fieldName);
- Object[] original = (Object[])((Object[])jlrField.get(instance)); // 取出原来的 dexElements 数组
- Object[] combined = (Object[])((Object[])Array.newInstance(original.getClass().getComponentType(), original.length + extraElements.length)); // 新的数组
- System.arraycopy(original, 0, combined, 0, original.length); // 原来数组内容拷贝到新的数组
- System.arraycopy(extraElements, 0, combined, original.length, extraElements.length); //dex2,dex3... 拷贝到新的数组
- jlrField.set(instance, combined); // 将 dexElements 重新赋值为新的数组
- }
就是创建一个新的数组, 把原来数组内容 (主 dex) 和要增加的内容 (dex2,dex3...) 拷贝进去, 反射替换原来的 dexElements 为新的数组, 如下图:
Tinker 热修复的原理也是通过反射将修复后的 dex 添加到这个 dex 数组去, 不同的是热修复是添加到数组最前面, 而 MultiDex 是添加到数组后面. 这样讲可能还不是很好理解? 来看看 ClassLoader 怎么加载一个类的就明白了~
2.2 ClassLoader 加载类原理
不管是 PathClassLoader 还是 DexClassLoader, 都继承自 BaseDexClassLoader, 加载类的代码在 BaseDexClassLoader 中, 具体文件路径如下:/dalvik/src/main/java/dalvik/system/BaseDexClassLoader.java.
代码如图:
1. 构造方法通过传入 dex 路径, 创建了 DexPathList.
2. ClassLoader 的 findClass 方法最终是调用 DexPathList 的 findClass 方法
接下来看一下 DexPathList 源码 / dalvik/src/main/java/dalvik/system/DexPathList.java
DexPathList 里面定义了一个 dexElements 数组, findClass 方法中用到, 看下
findClass 方法逻辑很简单, 就是遍历 dexElements 数组, 拿到里面的 DexFile 对象, 通过 DexFile 的 loadClassBinaryName 方法加载一个类.
最终创建 Class 是通过 native 方法, 就不追下去了, 大家有兴趣可以看下 native 层是怎么创建 Class 对象的.
那么问题来了, 5.0 以下这个 dexElements 里面只有主 dex(可以认为是一个 bug), 没有 dex2,dex3...,MultiDex 是怎么把 dex2 添加进去呢?
答案就是反射 DexPathList 的 dexElements 字段, 然后把 dex2 添加进去, 当然, dexElements 里面放的是 Element 对象, 只有 dex2 的路径, 必须转换成 Element 格式才行, 所以反射 DexPathList 里面的 makeDexElements 方法, 将 dex 文件转换成 Element 对象即可.
dex2,dex3... 通过 makeDexElements 方法转换成要新增的 Element 数组, 最后一步就是反射 DexPathList 的 dexElements 字段, 将原来的 Element 数组和新增的 Element 数组合并, 然后反射赋值给 dexElements 变量, 最后 DexPathList 的 dexElements 变量就包含新加的 dex 在里面了.
makeDexElements 方法会判断 file 类型, 上面讲 dex 提取的时候解压 apk 得到 dex, 然后又将 dex 压缩成 zip, 压缩成 zip, 就会走到第二个判断里去. 仔细想想, 其实 dex 不压缩成 zip, 走第一个判断也没啥问题吧, 那谷歌的 MultiDex 为什么要将 dex 压缩成 zip 呢?
在 Android 开发高手课中看到张绍文也提到这一点:
也就是说, 这个压缩过程是多余的, 后面我们会介绍一下头条 App 参考谷歌的 MultiDex 优化这个多余的压缩过程, 后续会介绍一下头条的方案.
这里我们先总结一下 ClassLoader 的加载原理 <==> ClassLoader.loadClass -> DexPathList.loadClass -> 遍历 dexElements 数组 ->DexFile.loadClassBinaryName.
通俗点说就是: ClassLoader 加载类的时候是通过遍历 dex 数组, 从 dex 文件里面去加载一个类, 加载成功就返回, 加载失败则抛出 Class Not Found 异常.
2.3 MultiDex 原理总结
在明白 ClassLoader 加载类原理之后, 我们可以通过反射 dexElements 数组, 将新增的 dex 添加到数组后面, 这样就保证 ClassLoader 加载类的时候可以从新增的 dex 中加载到目标类, 经过分析后最终整理出来的原理图如下:
三, MultiDex 优化
我们了解了 MultiDex 原理之后, 就应该考虑如何优化 MultiDex 了.
MultiDex 的优化的重点在于解决 install 过程耗时, 耗时的原因主要是涉及到解压 apk 取出 dex, 压缩 dex, 将 dex 文件通过反射转换成 DexFile 对象, 反射替换数组.
想到优化此耗时问题, 首先我们会想到异步, 也就是开启一个子线程执行 install 操作, 但是这样做真的可行吗? 实践过后就发现, 方案存在很大的问题.
3.1 子线程 install(不推荐)
这个方案的思路为: 在闪屏页开一个子线程去执行 MultiDex.install, 然后加载完才跳转到主页. 需要注意的是闪屏页的 Activity, 包括闪屏页中引用到的其它类必须在主 dex 中, 不然在 MultiDex.install 之前加载这些不在主 dex 中的类会报错 Class Not Found.
如何保证闪屏页在主 dex 里面呢? 这里我们可以使用 Gradle 来配置:
- defaultConfig {
- // 分包, 指定某个类在 main dex
- multiDexEnabled true
- multiDexKeepProguard file('multiDexKeep.pro') // 打包到 main dex 的这些类的混淆规制, 没特殊需求就给个空文件
- multiDexKeepFile file('maindexlist.txt') // 指定哪些类要放到 main dex
- }
maindexlist.txt 文件指定哪些类要打包到主 dex 中, 内容格式如下
com/lanshifu/launchtest/SplashActivity.class
但是, 真正在已有项目中用使用这种方式, 会发现编译运行在 Android 4.4 的机器上, 启动闪屏页, 加载完准备进入主页直接报错 NoClassDefFoundError.NoClassDefFoundError 在这里出现知道就是主 dex 里面没有该类, 一般情况下, 这个方案的报错会出现在三方库的中, 尤其是 ContentProvider 相关的逻辑.
应用进程不存在的情况下, 从点击桌面应用图标, 到应用启动(冷启动), 大概会经历以下流程:
- Launcher startActivity
- AMS startActivity
Zygote fork 进程
- ActivityThread main()
- 4.1. ActivityThread attach
- 4.2. handleBindApplication
- 4.3 attachBaseContext
- 4.4. installContentProviders
- 4.5. Application onCreate
ActivityThread 进入 loop 循环
Activity 生命周期回调, onCreate,onStart,onResume...
整个启动流程我们能干预的主要是 4.3,4.5 和 6, 应用启动优化主要从这三个地方入手. 理想状况下, 这三个地方如果不做任何耗时操作, 那么应用启动速度就是最快的, 但是现实很骨感, 很多开源库接入第一步一般都是在 Application onCreate 方法初始化, 有的甚至直接内置 ContentProvider, 直接在 ContentProvider 中初始化框架, 不给你优化的机会.
子线程 install 的方案之所以出现问题也正是因为上述的原理所说, 即: ContentProvider 初始化太早了, 如果不在主 dex 中, 还没启动闪屏页就已经 crash 了.
总结一下这种方案的缺点:
1. MultiDex 加载逻辑放在闪屏页的话, 闪屏页中引用到的类都要配置在主 dex.
2. ContentProvider 必须在主 dex, 一些第三方库自带 ContentProvider, 维护比较麻烦, 要一个一个配置.
下面我们看一下今日头条是如何优化 MultiDex 的.
3.2 今日头条优化方案
1. 在主进程 Application 的 attachBaseContext 方法中判断如果需要使用 MultiDex, 则创建一个临时文件, 然后开一个进程(LoadDexActivity), 显示 Loading, 异步执行 MultiDex.install 逻辑, 执行完就删除临时文件并 finish 自己.
2. 主进程 Application 的 attachBaseContext 进入 while 代码块, 定时轮循临时文件是否被删除, 如果被删除, 说明 MultiDex 已经执行完, 则跳出循环, 继续正常的应用启动流程.
3.MultiDex 执行完之后主进程 Application 继续走, ContentProvider 初始化和 Application onCreate 方法, 也就是执行主进程正常的逻辑.
注意: LoadDexActivity 必须要配置在 main dex 中.
Android 项目优化(三):MultiDex 优化
来源: http://www.bubuko.com/infodetail-3255747.html