文章

Multidex

Multidex

MultiDex

multidex 的产生

在 Android5.0 之前,每一个 Android 应用中只会含有一个 dex 文件,因为 Android 系统本身的 bug,使得这个 dex 的方法数量被限制在 65535 之内,这就是 64K(64x1024) 事件。为了解决这个问题,Google 官方推出了 support-library 库。用起来也会有一些坑。

Android Apk 文件包含 DEX(Dalvik Executable) 文件形式的可执行字节码文件,其中包含用来运行您的应用的已编译代码。Dalvik Executable 规范将可在单个 DEX 文件内可引用的方法总数限制在 65536,其中包括 Android 框架方法、库方法以及您自己代码中的方法,称之为 64K 引用限制。
Android5.0 之前版本的 Dalvik 可执行文件分包支持 Android5.0(API21)之前的平台版本使用 Dalvik 运行时来执行应用代码。默认情况下,Dalvik 限制应用的每个 apk 只能使用单个 classes.dex 字节码文件。要想绕过这一限制,可以使用 Dalvik 可执行文件分包支持库,它会成为您的应用主要 DEX 文件的一部分,然后管理对其他 DEX 文件及其包含代码的访问。
Android5.0 及更高的版本的 Dalvik 可执行文件分包支持 Android5.0 及更高版本使用名为 ART 的运行时,ART 原生支持从 APK 文件加载多个 DEX 文件。ART 在应用安装时执行预编译,扫描 classesN.dex 文件,并将它们编译成单个 .oat 文件,供 Android 设备执行。因此,如果你的 minSdkVersion 大于等于 21,不需要 Dalvik 可执行文件分包支持库。

如果将应用的 minSdkVerson 设置大于等于 21,使用 InstallRun 时,AS 会自动将应用配置为进行 Dalvik 可执行文件分包。由于 Install Run 仅适用于调试版本的应用,您仍需配置发布构建进行 Dalvik 可执行文件分包,以规避 64K 限制。

multiDexKeepFile 属性

multiDexKeepProguard 属性

multidex 产生背景

Android 系统安装一个应用的时候,有一步是对 Dex 进行优化,这个过程有一个专门的工具来处理,叫做 DexOpt。DexOpt 的执行过程是第一次加载 Dex 文件的时候执行的。这个过程会生成一个 ODEX 文件,即 Optimised Dex。执行 ODEX 文件效率会比直接执行 Dex 文件的效率要高的多。
但是在早期 Android 系统中,DexOpt 有一个问题,DexOpt 会把每一个类的方法 id 检索起来,存在一个链表结构里面。但是这个链表的长度是用一个 short 类型来保存的,导致了方法 id 的数目不能够超过 65536 个。当一个项目足够大的时候,显然这个方法数的上限是不够的。尽管在新版本的 Android 系统中,DexOpt 修复了这个问题,但是我们仍然需要对低版本的 Android 系统做兼容。需要将 dex 文件拆分成两个或多个。

multidex 原理总结

  1. apk 在 Application 实例化之后,会检查系统版本是否支持 MultiDex,判断二级 dex 是否需要安装
  2. 如果需要安装则会从 apk 中解压出 classes2.dex 并将其拷贝到应用的 /data/data/<package-name>/code_cache/secondary-dexes/ 目录下
  3. 通过反射将 classes2.dex 等注入到当前 ClassLoader 的 pathList 中,完成安装流程

multiDex 原理

MultiDex 工作流程分为 2 个部分,一个部分是打包构建 apk 的时候,将 dex 文件拆分若干个小的 dex 文件,这个 gradle 已经帮我们做了;另外一个部分是在启动 apk 的时候,同时加载多个 dex 文件(具体是加载 Dex 文件优化后的 odex 文件,不过文件名还是.dex),这一部分工作从 Android5.0 开始系统已经帮我们做了,但是 Android5.0 之前还是需要通过 MultiDex 库来支持。

我们来分析第二部分的,启动 apk 时加载多个 dex 文件。
从 MultiDex.install() 入手:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
 // MultiDex#install(Context)
 public static void install(Context context) {
    Log.i(TAG, "install");
    if (IS_VM_MULTIDEX_CAPABLE) {
        Log.i(TAG, "VM has multidex support, MultiDex support library is disabled.");
        return;
    }

    if (Build.VERSION.SDK_INT < MIN_SDK_VERSION) { // 最低版本兼容到Android1.6
        throw new RuntimeException("Multi dex installation failed. SDK " + Build.VERSION.SDK_INT
                + " is unsupported. Min SDK version is " + MIN_SDK_VERSION + ".");
    }

    try {
        ApplicationInfo applicationInfo = getApplicationInfo(context);
        if (applicationInfo == null) {
            // Looks like running on a test Context, so just return without patching.
            return;
        }

        synchronized (installedApk) {
            String apkPath = applicationInfo.sourceDir;
            if (installedApk.contains(apkPath)) {
                return;
            }
            installedApk.add(apkPath);

            if (Build.VERSION.SDK_INT > MAX_SUPPORTED_SDK_VERSION) {
                Log.w(TAG, "MultiDex is not guaranteed to work in SDK version "
                        + Build.VERSION.SDK_INT + ": SDK version higher than "
                        + MAX_SUPPORTED_SDK_VERSION + " should be backed by "
                        + "runtime with built-in multidex capabilty but it's not the "
                        + "case here: java.vm.version=\""
                        + System.getProperty("java.vm.version") + "\"");
            }

            /* The patched class loader is expected to be a descendant of
             * dalvik.system.BaseDexClassLoader. We modify its
             * dalvik.system.DexPathList pathList field to append additional DEX
             * file entries.
             */
            ClassLoader loader;
            try {
                loader = context.getClassLoader();
            } catch (RuntimeException e) {
                /* Ignore those exceptions so that we don't break tests relying on Context like
                 * a android.test.mock.MockContext or a android.content.ContextWrapper with a
                 * null base Context.
                 */
                Log.w(TAG, "Failure while trying to obtain Context class loader. " +
                        "Must be running in test mode. Skip patching.", e);
                return;
            }
            if (loader == null) {
                // Note, the context class loader is null when running Robolectric tests.
                Log.e(TAG,
                        "Context class loader is null. Must be running in test mode. "
                        + "Skip patching.");
                return;
            }

            try {
              clearOldDexDir(context);
            } catch (Throwable t) {
              Log.w(TAG, "Something went wrong when trying to clear old MultiDex extraction, "
                  + "continuing without cleaning.", t);
            }
            // MultiDex的二级dex文件将存放在/data/data/<package-name>/secondary-dexex目录下
            File dexDir = getDexDir(context, applicationInfo);
            // 从apk中查找并解压二级dex文件到/data/data/<package-name>/secondary-dexes目录下
            List<File> files = MultiDexExtractor.load(context, applicationInfo, dexDir, false);
            if (checkValidZipFiles(files)) { // 检查dex压缩文件的完整性
                installSecondaryDexes(loader, dexDir, files); // 开始安装dex
            } else {
                Log.w(TAG, "Files were not valid zip files.  Forcing a reload.");
                // Try again, but this time force a reload of the zip file. // 第一次失败,重试一次
                files = MultiDexExtractor.load(context, applicationInfo, dexDir, true); 

                if (checkValidZipFiles(files)) {
                    installSecondaryDexes(loader, dexDir, files);
                } else {
                    // Second time didn't work, give up
                    throw new RuntimeException("Zip files were not valid.");
                }
            }
        }

    } catch (Exception e) {
        Log.e(TAG, "Multidex installation failure", e);
        throw new RuntimeException("Multi dex installation failed (" + e.getMessage() + ").");
    }
    Log.i(TAG, "install done");
}

MultiDex 安装的整个流程:

  1. 检查虚拟机版本判断是否需要 MultiDex
    在 ART 虚拟机中 (部分 4.4 机器及 5.0 以上的机器),采用了 AOT(Ahead-of-time compilation) 技术,系统在 apk 的安装过程中,会使用自带的 dex2oat 工具对 apk 中可用的 dex 文件进行编译,并生成一个可在本地机器上运行的 odex(optimized dex) 文件,这样做会提高应用的启动速度(安装速度降低了)。
    若不需要使用 MultiDex,将使用 clearOldDexDir() 清除 /data/data/<package-name>/code-cache/secondary-dexes 目录下所有文件
  2. 根据 ApplicationInfo.sourceDir 的值获取安装的 apk 路径
    安装完成的 apk 路径为 /data/app/<package-name>.apk
  3. 检查 apk 是否执行 MultiDex.install),若已经安装直接退出
  4. 使用 MultiDexExtractor.load() 获取 apk 中可用的二级 dex 列表
    MultiDexExtractor.load() 会先判断是否需要从 apk 中解压 dex 文件,主要判断依据是:上次保存的 apk(zip 文件) 的 CRC 校验码和 last modify 日期与 dex 的总数量是否与当前 apk 相同,forceReload 也会决定是否需要重新解压。
    如果需要解压 dex 文件,将会使用 performExtractions() 将.dex 从 apk 中解压出来,解压路径为 /data/data/<package-name>/code_cache/secondary-dexes/<package-name>.apk.classN.zip(N>=2)。
    解压成功后,会保存本次解压所使用的 apk 信息,用于下次调用 MultiDexExtractor.load() 时判断是否需要重新解压;
    如果 apk 未被修改,将会调用 loadExistingExtractors() 方法,直接加载上一次解压出来的文件。
  5. 两次校验 dex 压缩包的完整性
    若第一次校验失败 (dex 文件损坏等),MultiDex 会重新调用 MultiDexExtractor.load() 方法重新查找加载二级 dex 文件列表,值得注意的是第二次查找 forceReload 的值为 true,会强制重新从 apk 中解压 dex 文件。
  6. 开始 dex 的安装
    经过上面的重重检验和解压,到了最关键的一步:
    将二级 dex 添加到我们的 ClassLoader 中。
1
2
3
4
5
6
7
8
9
10
11
12
13
private static void installSecondaryDexes(ClassLoader loader, File dexDir, List<File> files)
        throws IllegalArgumentException, IllegalAccessException, NoSuchFieldException,
        InvocationTargetException, NoSuchMethodException, IOException {
    if (!files.isEmpty()) {
        if (Build.VERSION.SDK_INT >= 19) {
            V19.install(loader, files, dexDir);
        } else if (Build.VERSION.SDK_INT >= 14) {
            V14.install(loader, files, dexDir);
        } else {
            V4.install(loader, files);
        }
    }
}

由于 SDK 版本不同,ClassLoader 中的实现存在差异,分析 V14 版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
 // MultiDex
 private static final class V14 {

    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
         * dalvik.system.BaseDexClassLoader. We modify its
         * dalvik.system.DexPathList pathList field to append additional DEX
         * file entries.
         */
        Field pathListField = findField(loader, "pathList");  // 通过反射找到BaseDexClassLoader的pathList字段
        Object dexPathList = pathListField.get(loader); // 获取pathList原有的值
        expandFieldArray(dexPathList, "dexElements", makeDexElements(dexPathList,
                new ArrayList<File>(additionalClassPathEntries), optimizedDirectory));
    }

    // 通过反射调用DexPathList#makeDexElements()方法
    private static Object[] makeDexElements(
            Object dexPathList, ArrayList<File> files, File optimizedDirectory)
                    throws IllegalAccessException, InvocationTargetException,
                    NoSuchMethodException {
        Method makeDexElements =
                findMethod(dexPathList, "makeDexElements", ArrayList.class, File.class);

        return (Object[]) makeDexElements.invoke(dexPathList, files, optimizedDirectory);
    }
}
// dexPathList,dexElements,makeDexElements()
private static void expandFieldArray(Object instance, String fieldName,
        Object[] extraElements) throws NoSuchFieldException, IllegalArgumentException,
        IllegalAccessException {
    Field jlrField = findField(instance, fieldName); // 找到dexElements字段
    Object[] original = (Object[]) jlrField.get(instance); // 获取到该字段的值,为Element[]数组
    Object[] combined = (Object[]) Array.newInstance(
            original.getClass().getComponentType(), original.length + extraElements.length);
    System.arraycopy(original, 0, combined, 0, original.length); // 拷贝原来dexElements数组中的元素到combined数组中
    System.arraycopy(extraElements, 0, combined, original.length, extraElements.length); // 拷贝要添加的元素到combined数组中去
    jlrField.set(instance, combined); // 然后重新设置dexElements的值:为原来的值+后面追加的值
}

MultiDex 在安装开始时,会先通过反射获取 BaseDexClassLoader 中 DexPathList 类型的字段 pathList

1
2
3
// BaseDexClassLoader
/** structured lists of path elements */
private final DexPathList pathList;

接着反射调用 DexPathListmakeDexElements() 方法,将上面解压得到的 additionalClassPathEntries(二级 dex 文件列表) 封装成 Element 数组,因为 dexElements 是通过 makeDexElements() 方法获取的,我们也通过该方法来构建 dexElements 数组的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// DexPathList
/** list of dex/resource (class path) elements */
private final Element[] dexElements;
public DexPathList(ClassLoader definingContext, String dexPath,
        String libraryPath, File optimizedDirectory) {
    // ...
    this.definingContext = definingContext;
    this.dexElements =
        makeDexElements(splitDexPath(dexPath), optimizedDirectory);
    this.nativeLibraryDirectories = splitLibraryPath(libraryPath);
}
/**
 * Makes an array of dex/resource path elements, one per element of
 * the given array.
 */
private static Element[] makeDexElements(ArrayList<File> files,
        File optimizedDirectory) {
}

makeDexElements 最终会去进行 dex2opt 操作,这是一个比较耗时的过程,如果全部放在 main 线程去处理的话,比较影响用户体验,甚至可能引起 ANR;dex2opt 后,/data/data//code_cache/secondary-dexes/目录下会出现优化后的文件:.apk.classes2.dex 等

最后调用 MultiDex.expandFieldArray(),通过反射调用,找到 DexPathList 中的 dexElements 字段,并将上一步生成的封装了二级 dex 的 Element 数组添加到 dexElements 以后,完成整个安装流程。

Reference

MultiDex 优化

multidex 问题描述

multidex 有个问题,就是会产生明显的卡顿问题,主要产生在解压 dex 文件优化 dex两个步骤。不过在 Application#attachBaseContext 中,UI 线程的阻塞不会引发 ANR 的,只不过这段长时间的卡顿(白屏)还是影响用户体验。

multidex 优化方案

PreMultiDex

在安装一个新的 apk 的时候,先在 worker 线程里做好 MultiDex 的解压和 optimize 工作,安装 apk 并启动后,直接使用之前 optimize 产生的 odex 文件,这样就可以避免第一次启动时候的 optimize 工作。

缺点:第一次安装的 apk 没有作用,而且事先需要使用内置的 apk 更新功能把新版本的 apk 文件下载下来后,才能做 PreMultiDex 工作。

异步 MultiDex 方案

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

本文由作者按照 CC BY 4.0 进行授权