一文了解Multidex运行原理

1,636 阅读10分钟

在Android 5.0(API 21)之前,系统不支持加载多个dex文件,其中一个dex文件中method数被short类型限制在65536个,随着业务逻辑的增多,必然导致构建时产生多个dex包,那么如何加载其他dex文件就成为一个重要的问题。为了填补这个漏洞google引入了MultiDex工具来解决在Android5.0之前系统加载secondary dex的问题。

引言

问题1:Android版都已经更新到9.0了,为何还去研究5.0版本以前的技术?

  1. 面向OTT开发,Android版本普遍都比较低。
  2. MultiDex加载机制与现在流行的动态化技术(热修复、插件化等)原理基本一致。
  3. 了解Dex加载原理是做Dex相关优化工作的基础。

问题2:类似的文章都烂大街了,还能写出个花来?

茉莉花@2x.png

疑问

在开始讲之前,有个问题你可能也思考过。 Android5.0平台以下应用安装完成后首次启动时间较长,MultiDex.install()方法就是罪魁祸首,那它内部到底耗时在哪里呢?带着这个疑问我们开始分析源码,如果你只想了解整体流程请直接看文末总结整体流程

入口

核心方法MultiDex.install(),通常会在application的attachBaseContext方法中调用。当然实际上只要在secondary-dex中的类使用前调用就可以。

以下源码基于multidex-1.0.2版本。

public static void install(Context context) {
    if(IS_VM_MULTIDEX_CAPABLE) {
        ...
    } else if(VERSION.SDK_INT < 4) {
        throw new RuntimeException("MultiDex installation failed. SDK " + VERSION.SDK_INT + " is unsupported. Min SDK version is " + 4 + ".");
    } else {
        try {
            ApplicationInfo applicationInfo = getApplicationInfo(context);
            doInstallation(context, new File(applicationInfo.sourceDir), new File(applicationInfo.dataDir), "secondary-dexes", "");
        } catch (Exception var2) {
            Log.e("MultiDex", "MultiDex installation failure", var2);
            throw new RuntimeException("MultiDex installation failed (" + var2.getMessage() + ").");
        }
    }
}

IS_VM_MULTIDEX_CAPABLE 属性表示虚拟机是否支持多包,内部的判断标准是虚拟机的版本号是否大于等于2.1。Android4.4平台使用的dalvik虚拟机版本为1.6.0,所以不支持多包加载,需要使用multidex加载其他dex包。

主包在应用安装时就已经提取并完成dex优化工作,产出的目录为默认路径/data/dalvik-cache/{apk文件名}@classes.dex

进入核心方法doInstallation

private static final Set<File> installedApk = new HashSet();

private static void doInstallation(Context mainContext, File sourceApk, File dataDir, String secondaryFolderName, String prefsKeyPrefix) {
    Set var5 = installedApk;
    synchronized(installedApk) {
        if(!installedApk.contains(sourceApk)) {
            installedApk.add(sourceApk);
            ClassLoader loader;
            try {
                loader = mainContext.getClassLoader();
            } catch (RuntimeException var11) {
                return;
            }

            if(loader == null) {
                ...
            } else {
                try {
                    clearOldDexDir(mainContext);
                } catch (Throwable var10) {
                    ...
                }

                File dexDir = getDexDir(mainContext, dataDir, secondaryFolderName);
                List<? extends File> files = MultiDexExtractor.load(mainContext, sourceApk, dexDir, prefsKeyPrefix, false);
                installSecondaryDexes(loader, dexDir, files);
            }
        }
    }
}

可以看到installedApk的作用是一个是多线程的锁对象,另一个是单进程防止多次调用install方法带来开销。

这个loader在application内部为PathClassLoader,作用是加载已安装过的apk的class,与之对应的是DexClassLoader它可加载外部的jar/dex/apk中的class。

clearOldDexDir作用是清除/data/data/com.bftv.fui.video/files/secondary-dexes目录及其子文件。猜测是Android历史版本曾经以这个目录为其他dex的输出目录。

接下来是本文的重点内容

  • getDexDir 准备dex目录
  • MultiDexExtractor.load提取apk文件中的次要dex
  • installSecondaryDexes 加载安装次要包

因此可以将MultiDex流程分为三个步骤——准备、提取、安装。

流程图.png

准备

来看getDexDir源码

private static File getDexDir(Context context, File dataDir, String secondaryFolderName) throws IOException {
    File cache = new File(dataDir, "code_cache");

    try {
        mkdirChecked(cache);
    } catch (IOException var5) {
        cache = new File(context.getFilesDir(), "code_cache");
        mkdirChecked(cache);
    }

    File dexDir = new File(cache, secondaryFolderName);
    mkdirChecked(dexDir);
    return dexDir;
}

这里的参数secondaryFolderName固定为secondary-dexes,此步创建了data/data/{packageName}/code_cache/secondary-dexes/目录。可以想见后续提取出来的dex文件会存放在此目录。

提取

来看MultiDexExtractor.load()方法

static List<? extends File> load(Context context, File sourceApk, File dexDir, String prefsKeyPrefix, boolean forceReload) throws IOException {
    long currentCrc = getZipCrc(sourceApk);
    File lockFile = new File(dexDir, "MultiDex.lock");
    RandomAccessFile lockRaf = new RandomAccessFile(lockFile, "rw");
    FileChannel lockChannel = null;
    FileLock cacheLock = null;

    List files;
    try {
        lockChannel = lockRaf.getChannel();
        cacheLock = lockChannel.lock();
        if(!forceReload && !isModified(context, sourceApk, currentCrc, prefsKeyPrefix)) {
            try {
                files = loadExistingExtractions(context, sourceApk, dexDir, prefsKeyPrefix);
            } catch (IOException var21) {
                files = performExtractions(sourceApk, dexDir);
                putStoredApkInfo(context, prefsKeyPrefix, getTimeStamp(sourceApk), currentCrc, files);
            }
        } else {
            files = performExtractions(sourceApk, dexDir);
            putStoredApkInfo(context, prefsKeyPrefix, getTimeStamp(sourceApk), currentCrc, files);
        }
    } finally {
    }
    ...
}

这里涉及到crc校验,本质上一种数据的完整性校验,如果数据被修改则校验不通过。有兴趣的同学戳这里crc简单介绍

随后创建一个MultiDex.lock文件,FileChannel是Java NIO中重要的类库,使用NIO有利于提高IO效率。

我们重点看下判断条件

!forceReload && !isModified(context, sourceApk, currentCrc, prefsKeyPrefix)

调用load方法传入forceReload为false,首次执行时isModified返回true,表示是一个新的apk;当应用升级或恶意篡改apk文件同样会返回true,原理也是基于crc校验。当应用分包加载完成后下次进程启动会返回false。

我们先来看看返回true时调用performExtractions方法和putStoredApkInfo方法。putStoredApkInfo方法将提取出来的dex的数量及各个dex的crc校验值写入名为multidex.version的SharedPreferences中。

multidex.version.xml

它不是重点,我们重点看一下performExtractions方法。

private static List<MultiDexExtractor.ExtractedDex> performExtractions(File sourceApk, File dexDir) throws IOException {
    String extractedFilePrefix = sourceApk.getName() + ".classes";
    prepareDexDir(dexDir, extractedFilePrefix);
    List<MultiDexExtractor.ExtractedDex> files = new ArrayList();
    ZipFile apk = new ZipFile(sourceApk);

    try {
        int secondaryNumber = 2;

        for(ZipEntry dexFile = apk.getEntry("classes" + secondaryNumber + ".dex"); dexFile != null; dexFile = apk.getEntry("classes" + secondaryNumber + ".dex")) {
            String fileName = extractedFilePrefix + secondaryNumber + ".zip";
            MultiDexExtractor.ExtractedDex extractedFile = new MultiDexExtractor.ExtractedDex(dexDir, fileName);
            files.add(extractedFile);
            Log.i("MultiDex", "Extraction is needed for file " + extractedFile);
            int numAttempts = 0;
            boolean isExtractionSuccessful = false;

            while(numAttempts < 3 && !isExtractionSuccessful) {
                ++numAttempts;
                extract(apk, dexFile, extractedFile, extractedFilePrefix);
				...              
            }
            ...
            ++secondaryNumber;
        }
    } finally {
   		...
    }
    ...
    return files;
}

重点看下返回值,它是一个List列表,MultiDexExtractor.ExtractedDex是对File类的简单封装,当做File处理即可,也就是最终返回了一个提取到的secondary-dexes文件列表。

prepareDexDir实际也是一步准备工作,它会缀遍历应用data目录/code_cache/secondary-dexes目录下所有不以apk文件的名称+".classes"为前缀的文件给并删除。在应用升级后此步骤会有实际作用。

随后将apk文件封装为一个ZipFile,调用extract执行实际的提取工作。

private static void extract(ZipFile apk, ZipEntry dexFile, File extractTo, String extractedFilePrefix) throws IOException, FileNotFoundException {
    InputStream in = apk.getInputStream(dexFile);
    ZipOutputStream out = null;
    File tmp = File.createTempFile("tmp-" + extractedFilePrefix, ".zip", extractTo.getParentFile());
    Log.i("MultiDex", "Extracting " + tmp.getPath());

    try {
        out = new ZipOutputStream(new BufferedOutputStream(new FileOutputStream(tmp)));

        try {
            ZipEntry classesDex = new ZipEntry("classes.dex");
            classesDex.setTime(dexFile.getTime());
            out.putNextEntry(classesDex);
            byte[] buffer = new byte[16384];

            for(int length = in.read(buffer); length != -1; length = in.read(buffer)) {
                out.write(buffer, 0, length);
            }

            out.closeEntry();
        } finally {
            out.close();
        }
        ...
        if(!tmp.renameTo(extractTo)) {
            throw new IOException("Failed to rename \"" + tmp.getAbsolutePath() + "\" to \"" + extractTo.getAbsolutePath() + "\"");
    }
    } finally {
        closeQuietly(in);
        tmp.delete();
    }
}

传入本方法的四个参数可以理解为:

  • zipFile .apk文件封装而成的ZipFile对象
  • dexFile apk文件中的classes2.dex等次要dex文件
  • extractedFile {packageName}-1.apk.classes2.zip
  • extractedFilePrefix {packageName}-1.apk.classes

大概流程就是创建一个临时zip文件并将apk包中的一个次要dex写入这个zip文件,最后重命名为 格式为{packageName}-1.apk.classes2.zip的文件。写入过程为IO操作,因此是分包过程中的一个耗时操作。循环执行完extract方法也就依次将apk中的各个次要dex文件写入到了secondary-dexes目录下的各个zip文件中(相当于压缩操作)。

那我们来看一下这个目录是不是已经写入了文件。

secondary-dexes目录.png

写是写了,可是其他的.dex文件又是什么呢?我们先搁置继续看看判断条件:

!forceReload && !isModified(context, sourceApk, currentCrc, prefsKeyPrefix)

如果未检测到修改则会执行loadExistingExtractions方法。

private static List<MultiDexExtractor.ExtractedDex> loadExistingExtractions(Context context, File sourceApk, File dexDir, String prefsKeyPrefix) throws IOException {
    String extractedFilePrefix = sourceApk.getName() + ".classes";
    SharedPreferences multiDexPreferences = getMultiDexPreferences(context);
    int totalDexNumber = multiDexPreferences.getInt(prefsKeyPrefix + "dex.number", 1);
    List<MultiDexExtractor.ExtractedDex> files = new ArrayList(totalDexNumber - 1);

    for(int secondaryNumber = 2; secondaryNumber <= totalDexNumber; ++secondaryNumber) {
        String fileName = extractedFilePrefix + secondaryNumber + ".zip";
        MultiDexExtractor.ExtractedDex extractedFile = new MultiDexExtractor.ExtractedDex(dexDir, fileName);

       	//crc校验
       	...

        files.add(extractedFile);
    }
    return files;
}

很简单,既然已经有个dex的压缩文件,直接封装到List中即可。

至此,提取过程完成。

安装

来看最后一步的installSecondaryDexes方法。

private static void installSecondaryDexes(ClassLoader loader, File dexDir, List<? extends File> files) throws 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);
        }
    }
}

可以看到安装过程对不同的Android版本做了不同的处理,以V19也就是Android4.4为例看一下install方法。

private static void install(ClassLoader loader, List<? extends File> additionalClassPathEntries, File optimizedDirectory) throws IllegalArgumentException, IllegalAccessException, NoSuchFieldException, InvocationTargetException, NoSuchMethodException {
    Field pathListField = MultiDex.findField(loader, "pathList");
    Object dexPathList = pathListField.get(loader);
    ArrayList<IOException> suppressedExceptions = new ArrayList();
    MultiDex.expandFieldArray(dexPathList, "dexElements", makeDexElements(dexPathList, new ArrayList(additionalClassPathEntries), optimizedDirectory, suppressedExceptions));
    //异常收集
    ...
}

来看外部核心方法MultiDex.expandFieldArray(),这里用到了反射,总得来说过程如下:

  • 获取PathClassLoader的 pathList成员变量类型为DexPathList
  • 获取pathList对象的dexElements属性,类型为Element数组
  • 将secondary-dex封装成Element数组,并把其中元素逐个添加到原有dexElements数组后面。

为什么要这么做呢?这涉及到Android系统中的类加载机制,它基于Java类加载机制的双亲委派模型,同时也热修复框架的基础。这里不做赘述,一篇好文送上Android动态加载之ClassLoader详解

为了验证数组已经添加成功,我们在MultiDex.install方法调用前后分别打印PathClassLoader对象得到如下log。

test_tag: install before classloader:dalvik.system.PathClassLoader[DexPathList[[zip file "/data/app/com.bftv.fui.video-1.apk"],nativeLibraryDirectories=[/data/app-lib/com.bftv.fui.video-1, /vendor/lib, /system/lib]]]

test_tag: install after classloader:dalvik.system.PathClassLoader[DexPathList[[zip file "/data/app/com.bftv.fui.video-1.apk", zip file "/data/data/com.bftv.fui.video/code_cache/secondary-dexes/com.bftv.fui.video-1.apk.classes2.zip", zip file "/data/data/com.bftv.fui.video/code_cache/secondary-dexes/com.bftv.fui.video-1.apk.classes3.zip", zip file "/data/data/com.bftv.fui.video/code_cache/secondary-dexes/com.bftv.fui.video-1.apk.classes4.zip"],nativeLibraryDirectories=[/data/app-lib/com.bftv.fui.video-1, /vendor/lib, /system/lib]]]

那么这个Elements数组又是怎么创建的呢?这要继续去看makeDexElements方法。

private static Object[] makeDexElements(
        Object dexPathList, ArrayList<File> files, File optimizedDirectory,
        ArrayList<IOException> suppressedExceptions)
                throws IllegalAccessException, InvocationTargetException,
                NoSuchMethodException {
    Method makeDexElements =
            findMethod(dexPathList, "makeDexElements", ArrayList.class, File.class,
                    ArrayList.class);

    return (Object[]) makeDexElements.invoke(dexPathList, files, optimizedDirectory,
            suppressedExceptions);
}

仍然是反射,我们看DexPathList的makeDexElements方法。

private static Element[] makeDexElements(ArrayList<File> files,
        File optimizedDirectory) {
    ArrayList<Element> elements = new ArrayList<Element>();
    for (File file : files) {
        ZipFile zip = null;
        DexFile dex = null;
        String name = file.getName();
        if (name.endsWith(DEX_SUFFIX)) {
            // Raw dex file (not inside a zip/jar).
            try {
                dex = loadDexFile(file, optimizedDirectory);
            } catch (IOException ex) {
                System.logE("Unable to load dex file: " + file, ex);
            }
        } else if (name.endsWith(APK_SUFFIX) || name.endsWith(JAR_SUFFIX)
                || name.endsWith(ZIP_SUFFIX)) {
            try {
                zip = new ZipFile(file);
            } catch (IOException ex) {
            	...
            }
            try {
                dex = loadDexFile(file, optimizedDirectory);
            } catch (IOException ignored) {
              	...
            }
        } else {
            System.logW("Unknown file type for: " + file);
        }
        if ((zip != null) || (dex != null)) {
            elements.add(new Element(file, zip, dex));
        }
    }
    return elements.toArray(new Element[elements.size()]);
}

这里需要注意一下传入参数

  • optimizedDirectory 表示优化后的dex文件存放目录。
  • files 表示被压缩的dex文件数组。

实际执行就是两步,首先调用loadDexFile方法,然后将返回的dex组装成Elements数组。

来看loadDexFile方法

private static DexFile loadDexFile(File file, File optimizedDirectory)
        throws IOException {
    if (optimizedDirectory == null) {
        return new DexFile(file);
    } else {
        String optimizedPath = optimizedPathFor(file, optimizedDirectory);
        return DexFile.loadDex(file.getPath(), optimizedPath, 0);
    }
}

由于optimizedDirectory不为空因此执行optimizedPathFor方法

private static String optimizedPathFor(File path,
        File optimizedDirectory) {
    String fileName = path.getName();
    if (!fileName.endsWith(DEX_SUFFIX)) {
        int lastDot = fileName.lastIndexOf(".");
        if (lastDot < 0) {
            fileName += DEX_SUFFIX;
        } else {
            StringBuilder sb = new StringBuilder(lastDot + 4);
            sb.append(fileName, 0, lastDot);
            sb.append(DEX_SUFFIX);
            fileName = sb.toString();
        }
    }
    File result = new File(optimizedDirectory, fileName);
    return result.getPath();
}

其实就是根据传入的.zip文件名,生成对应的.dex文件。

来看DexFile.loadDex方法

static public DexFile loadDex(String sourcePathName, String outputPathName,
    int flags) throws IOException {
    return new DexFile(sourcePathName, outputPathName, flags);
}

private DexFile(String sourceName, String outputName, int flags) throws IOException {
    mCookie = openDexFile(sourceName, outputName, flags);
    mFileName = sourceName;
    guard.open("close");
}

native private static int openDexFile(String sourceName, String outputName,
    int flags) throws IOException;

最终是调用了本地方法openDexFile,这里不再继续分析,有兴趣的同学可以参考DexClassLoader和PathClassLoader加载Dex流程。我们直接说结论,它主要是对dex文件进行了优化操作,然后将优化数据写入.dex文件中。这也就是为什么在secondary-dexes目录同是会出现一个.zip文件和一个.dex文件。

总结整体流程

  1. 检查系统是否支持多包(虚拟机版本>=2.1等)
  2. 调用doInstallation执行加载dex核心逻辑。
  3. 用一个Set记录一个apk是否执行过install,这样保证同一个进程多次调用install方法不会重复执行。
  4. 调用clearOldDexDir清除应用files目录的子目录secondary-dexes。
  5. 调用getDexDir方法创建用于存放原始dex文件(zip格式)和优化后的dex文件的目录data/data/{packageName}/code_cache/secondary-dexes。
  6. 调用MultiDexExtractor.load方法提取dex文件封装到一个List集合。
    • 首次提取会调用performExtractions方法从/data/app/{packageName}-{num}.apk文件中提取dex文件,并将dex文件压缩(.zip格式)拷贝到secondary-dexes目录。拷贝前通过prepareDexDir方法删除旧版本的dex文件。
    • 后续提取则会调用loadExistingExtractions方法直接在secondary-dexes目录查找dex文件。
  7. 调用installSecondaryDexes方法加载dex。
    • 通过反射DexPathList的makeDexElements方法执行dex优化并返回Element数组。
    • 通过expandFieldArray方法将上一步提取Element数组添加到DexPathList的成员变量pathList数组后面。

回答疑问

MultiDex内部哪个操作最耗时?通过上文分析,可以得到下面的结论。

  • dex文件的提取拷贝以及优化完成的dex文件写入操作,为了优化这个过程对dex文件进行了压缩操作、拷贝过程使用了java NIO。
  • 本地方法openDexFile,dexopt过程耗时。

参考文章