类加载机制系列3——MultiDex原理解析

1 MultiDex的由来

Android中由于一个dex文件最多存储65536个方法,也就是一个short类型的范围,所以随着应用的类不断增加,当一个dex文件突破这个方法数的时候就会报出异常。虽然可以通过混淆等方式来减少无用的方法,但是随着APP功能的增多,突破方法数限制还是不可避免的。因此在Android5.0时,Android推出了官方的解决方案:MultiDex。打包的时候,把一个应用分成多个dex,例如:classes.dex、classes2.dex、classes3.dex...,加载的时候把这些dex都追加到DexPathList对应的数组中,这样就解决了方法数的限制。

5.0后的系统都内置了加载多个dex文件的功能,而在5.0之前,系统只可以加载一个主dex,其它的dex就需要采用一定的手段来加载。这也就是我们今天要讲的MultiDex。

MultiDex存放在android.support.multidex包下

2 MultiDex的使用

Gradle构建环境下,在主应用的build.gradle文件夹添加如下配置:

defaultConfig {
    ...
    multiDexEnabled true
    ...
}

dependencies {
    compile 'com.android.support:multidex:1.0.1'
    ...
}

现在最新的multidex版本是1.0.2。

在AndroidManifest.xml中的app节点下,使用MultiDexApplication作为应用入口。

package android.support.multidex;
...
public class MultiDexApplication extends Application {
  @Override
  protected void attachBaseContext(Context base) {
    super.attachBaseContext(base);
    MultiDex.install(this);
  }
}

当然了,大部分情况下,我们都会自定义一个自己的Application对应用做一些初始化。这种情况下,可以在我们自定义的Application中的attachBaseContext()方法中调用MultiDex.install()方法。

# 自定义的Applicaiton中
  @Override
  protected void attachBaseContext(Context base) {
    super.attachBaseContext(base);
    MultiDex.install(this);
  }

需要注意的是:MultiDex.install()方法的调用时机要尽可能的早,防止加载后面的dex文件中的类时报ClassNotFoundException。

3 MultiDex源码分析

分析MultiDex的的入口就是它的静态方法install()。
这个方法的作用就是把从应用的APK文件中的dex添加到应用的类加载器PathClassLoader中的DexPathList的Emlement数组中。

public static void install(Context context) {
    Log.i(TAG, "install");
    //判断Android系统是否已经支持了MultiDex,如果支持了就不需要再去安装了,直接返回
    if (IS_VM_MULTIDEX_CAPABLE) {
        Log.i(TAG, "VM has multidex support, MultiDex support library is disabled.");
        return;
    }

    // 如果Android系统低于MultiDex最低支持的版本就抛出异常
    if (Build.VERSION.SDK_INT < MIN_SDK_VERSION) {
        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);
        // 如果应用信息为空就返回,比如说运行在一个测试的Context下。
        if (applicationInfo == null) {
            // Looks like running on a test Context, so just return without patching.
            return;
        }
        // 同步方法
        synchronized (installedApk) {
            // 获取已经安装的APK的全路径
            String apkPath = applicationInfo.sourceDir;
            if (installedApk.contains(apkPath)) {
                return;
            }
            // 把路径添加到已经安装的APK路径中
            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 {
                // 获取ClassLoader,实际上是PathClassLoader
                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;
            }
            // 在某些测试环境下ClassLoader为null
            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 {
                // 清除老的缓存的Dex目录,来源的缓存目录是"/data/user/0/${packageName}/files/secondary-dexes"
                clearOldDexDir(context);
            } catch (Throwable t) {
                Log.w(TAG, "Something went wrong when trying to clear old MultiDex extraction, "
                        + "continuing without cleaning.", t);
            }
            // 新建一个存放dex的目录,路径是"/data/user/0/${packageName}/code_cache/secondary-dexes",用来存放优化后的dex文件
            File dexDir = new File(applicationInfo.dataDir, SECONDARY_FOLDER_NAME);
            // 使用MultiDexExtractor这个工具类把APK中的dex抽取到dexDir目录中,返回的files集合有可能为空,表示没有secondaryDex
            // 不强制重新加载,也就是说如果已经抽取过了,可以直接从缓存目录中拿来使用,这么做速度比较快
            List<File> files = MultiDexExtractor.load(context, applicationInfo, dexDir, false);
            if (checkValidZipFiles(files)) {
                // 如果抽取的文件是有效的,就安装secondaryDex
                installSecondaryDexes(loader, dexDir, files);
            } 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.
                // 如果抽取出的文件是无效的,那么就强制重新加载,这么做的话速度就慢了一点,有一些IO开销
                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");
}

关于dex文件抽取逻辑和校验逻辑我们先不管,我们看一下MultiDex是如何安装secondaryDex文件的。
由于不同版本的Android系统,类加载机制有一些不同,所以分为了V19、V14和V4等三种情况下的安装。V19、V14和V4都是MultiDex的private的静态内部类。V19支持Andorid19版本(20是只支持可穿戴设备的),V14支持14,、15、16、17 和 18版本,V4支持从4到13的版本。

# android.support.multidex.MultiDex
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);
        }
    }
}

我们来看一下V19的源码

/**
 * Installer for platform versions 19.
 */
private static final class V19 {
    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.
         */
        // 传递的loader是PathClassLoader,findFidld()方法是遍历loader及其父类找到pathList字段
        // 实际上就是找到BaseClassLoader中的DexPathList
        Field pathListField = findField(loader, "pathList");
        // 获取PathClassLoader绑定的DexPathList对象
        Object dexPathList = pathListField.get(loader);
        ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>();
        // 扩展DexPathList对象的Element数组,数组名是dexElements
        // makeDexElements()方法的作用就是调用DexPathList的makeDexElements()方法来创建dex元素
        expandFieldArray(dexPathList, "dexElements", makeDexElements(dexPathList,
                new ArrayList<File>(additionalClassPathEntries), optimizedDirectory,
                suppressedExceptions));
        // 后面就是添加一些IO异常信息,因为调用DexPathList的makeDexElements会有一些IO操作,相应的可能就会有一些异常情况
        if (suppressedExceptions.size() > 0) {
            for (IOException e : suppressedExceptions) {
                Log.w(TAG, "Exception in makeDexElement", e);
            }
            Field suppressedExceptionsField =
                    findField(loader, "dexElementsSuppressedExceptions");
            IOException[] dexElementsSuppressedExceptions =
                    (IOException[]) suppressedExceptionsField.get(loader);
            if (dexElementsSuppressedExceptions == null) {
                dexElementsSuppressedExceptions =
                        suppressedExceptions.toArray(
                                new IOException[suppressedExceptions.size()]);
            } else {
                IOException[] combined =
                        new IOException[suppressedExceptions.size() +
                                        dexElementsSuppressedExceptions.length];
                suppressedExceptions.toArray(combined);
                System.arraycopy(dexElementsSuppressedExceptions, 0, combined,
                        suppressedExceptions.size(), dexElementsSuppressedExceptions.length);
                dexElementsSuppressedExceptions = combined;
            }
            suppressedExceptionsField.set(loader, dexElementsSuppressedExceptions);
        }
    }
    /**
     * A wrapper around
     * {@code private static final dalvik.system.DexPathList#makeDexElements}.
     */
    // 通过反射的方式调用DexPathList#makeDexElements()方法
    // dexPathList 就是一个DexPathList对象
    private static Object[] makeDexElements(
            Object dexPathList, ArrayList<File> files, File optimizedDirectory,
            ArrayList<IOException> suppressedExceptions)
                    throws IllegalAccessException, InvocationTargetException,
                    NoSuchMethodException {
        // 获取DexPathList的makeDexElements()方法
        Method makeDexElements =
                findMethod(dexPathList, "makeDexElements", ArrayList.class, File.class,
                        ArrayList.class);
        // 调用makeDexElements()方法,根据外界传递的包含dex文件的源文件和优化后的缓存目录返回一个Element[]数组
        return (Object[]) makeDexElements.invoke(dexPathList, files, optimizedDirectory,
                suppressedExceptions);
    }
}

MultiDex的expandFieldArray()方法作用是扩展一个对象中的数组中的元素。实际上就是一个工具方法。简单看一下源码:

# android.support.multidex.MultiDex
private static void expandFieldArray(Object instance, String fieldName,
        Object[] extraElements) throws NoSuchFieldException, IllegalArgumentException,
        IllegalAccessException {
    Field jlrField = findField(instance, fieldName);
    Object[] original = (Object[]) jlrField.get(instance);
    Object[] combined = (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);
    jlrField.set(instance, combined);
}

V19的install()方法调用完毕之后,就把APK文件中的主dex文件之外的dex文件追加到PathClassLoader(也就是BaseClassLoader)中DexPathListde Element[]数组中。这样在加载一个类的时候就会遍历所有的dex文件,保证了打包的类都能够正常加载。

至于V14和V4中的install()方法,主要的思想都是一致的,在细节上有一些不同,有兴趣的可以自行查看相关源码。

小结一下:
MultiDex的install()方法实际上是先抽取出APK文件中的.dex文件,然后利用反射把这个.dex文件生成对应的数组,最后把这些dex路径追加到PathClassLoader加载dex的路径中,从而保证了APK中所有.dex文件中类都能够被正确的加载。

分析完了,MultiDex加载secondartDex的逻辑,我们再来看一下从APK文件中抽取出.dex文件的逻辑。
看一下MultiDexExtractor的load()方法:

# android.support.multidex.MultiDexExtractor
static List<File> load(Context context, ApplicationInfo applicationInfo, File dexDir,
        boolean forceReload) throws IOException {
    Log.i(TAG, "MultiDexExtractor.load(" + applicationInfo.sourceDir + ", " + forceReload + ")");
    // sourceDir 路径为"/data/app/}${packageName}-1/base.apk"
    final File sourceApk = new File(applicationInfo.sourceDir);
    // 获取APK文件的CRC(循环冗余校验)
    long currentCrc = getZipCrc(sourceApk);

    List<File> files;
    // 如果不需要重新加载并且文件没有被修改过
    // isModified()方法是根据SharedPreference中存放的APK文件上一次修改的时间戳和currentCrc来判断是否修改过文件
    if (!forceReload && !isModified(context, sourceApk, currentCrc)) {
        try {
            // 从缓存目录中加载已经抽取过的文件
            files = loadExistingExtractions(context, sourceApk, dexDir);
        } catch (IOException ioe) {
            Log.w(TAG, "Failed to reload existing extracted secondary dex files,"
                    + " falling back to fresh extraction", ioe);
            // 如果从缓存中加载失败就需要冲APK文件中去加载,这个过程时间会长一点
            files = performExtractions(sourceApk, dexDir);
            // 把抽取信息保存到SharedPreferences中,方便下次使用
            putStoredApkInfo(context, getTimeStamp(sourceApk), currentCrc, files.size() + 1);

        }
    } else {
        // 如果强制加载或者APK文件已经修改过就重新抽取dex文件
        Log.i(TAG, "Detected that extraction must be performed.");
        files = performExtractions(sourceApk, dexDir);
        putStoredApkInfo(context, getTimeStamp(sourceApk), currentCrc, files.size() + 1);
    }

    Log.i(TAG, "load found " + files.size() + " secondary dex files");
    return files;
}

根据前后顺序的话,App第一次运行的时候需要从APK冲抽取dex文件,我们先来看一下MultiDexExtractor的performExtractions()方法:

# android.support.multidex.MultiDexExtractor
private static List<File> performExtractions(File sourceApk, File dexDir)
        throws IOException {
    // 抽取出的dex文件名前缀是"${apkName}.classes"
    final String extractedFilePrefix = sourceApk.getName() + EXTRACTED_NAME_EXT;

    // Ensure that whatever deletions happen in prepareDexDir only happen if the zip that
    // contains a secondary dex file in there is not consistent with the latest apk.  Otherwise,
    // multi-process race conditions can cause a crash loop where one process deletes the zip
    // while another had created it.
    // 由于这个dexDir缓存目录可能不止一个APK在使用,在抽取一个APK之前如果有缓存过的与APK相关的dex文件就需要先删除掉,如果dexDir目录不存在就需要创建
    prepareDexDir(dexDir, extractedFilePrefix);

    List<File> files = new ArrayList<File>();

    final ZipFile apk = new ZipFile(sourceApk);
    try {

        int secondaryNumber = 2;
        // 获取"classes${secondaryNumber}.dex"格式的文件
        ZipEntry dexFile = apk.getEntry(DEX_PREFIX + secondaryNumber + DEX_SUFFIX);
        // 如果dexFile不为null就一直遍历
        while (dexFile != null) {
            // 抽取后的文件名是"${apkName}.classes${secondaryNumber}.zip"
            String fileName = extractedFilePrefix + secondaryNumber + EXTRACTED_SUFFIX;
            // 创建文件
            File extractedFile = new File(dexDir, fileName);
            // 添加到集合中
            files.add(extractedFile);

            Log.i(TAG, "Extraction is needed for file " + extractedFile);
            // 抽取过程中存在失败的可能,可以多次尝试,使用isExtractionSuccessful作为是否成功的标志
            int numAttempts = 0;
            boolean isExtractionSuccessful = false;
            while (numAttempts < MAX_EXTRACT_ATTEMPTS && !isExtractionSuccessful) {
                numAttempts++;

                // Create a zip file (extractedFile) containing only the secondary dex file
                // (dexFile) from the apk.
                // 抽出去apk中对应序号的dex文件,存放到extractedFile这个zip文件中,只包含它一个
                // extract方法就是一个IO操作
                extract(apk, dexFile, extractedFile, extractedFilePrefix);

                // Verify that the extracted file is indeed a zip file.   
                // 判断是够抽取成功
                isExtractionSuccessful = verifyZipFile(extractedFile);

                // Log the sha1 of the extracted zip file
                Log.i(TAG, "Extraction " + (isExtractionSuccessful ? "success" : "failed") +
                        " - length " + extractedFile.getAbsolutePath() + ": " +
                        extractedFile.length());
                if (!isExtractionSuccessful) {
                    // Delete the extracted file
                    extractedFile.delete();
                    if (extractedFile.exists()) {
                        Log.w(TAG, "Failed to delete corrupted secondary dex '" +
                                extractedFile.getPath() + "'");
                    }
                }
            }
            if (!isExtractionSuccessful) {
                throw new IOException("Could not create zip file " +
                        extractedFile.getAbsolutePath() + " for secondary dex (" +
                        secondaryNumber + ")");
            }
            // 继续下一个dex的抽取
            secondaryNumber++;
            dexFile = apk.getEntry(DEX_PREFIX + secondaryNumber + DEX_SUFFIX);
        }
    } finally {
        try {
            apk.close();
        } catch (IOException e) {
            Log.w(TAG, "Failed to close resource", e);
        }
    }

    return files;
}

当MultiDexExtractor的performExtractions()方法调用完毕的时候就把APK中所有的dex文件抽取出来,并以一定文件名格式的zip文件保存在缓存目录中。然后再把一些关键的信息通过调用putStoredApkInfo(Context context, long timeStamp, long crc, int totalDexNumber)方法保存到SP中。

当APK之后再启动的时候就会从缓存目录中去加载已经抽取过的dex文件。我们接着来看一下MultiDexExtractor的loadExistingExtractions()方法:

# android.support.multidex.MultiDexExtractor
private static List<File> loadExistingExtractions(Context context, File sourceApk, File dexDir)
        throws IOException {
    Log.i(TAG, "loading existing secondary dex files");
    // 抽取出的dex文件名前缀是"${apkName}.classes"
    final String extractedFilePrefix = sourceApk.getName() + EXTRACTED_NAME_EXT;
    // 从SharedPreferences中获取.dex文件的总数量,调用这个方法的前提是已经抽取过dex文件,所以SP中是有值的
    int totalDexNumber = getMultiDexPreferences(context).getInt(KEY_DEX_NUMBER, 1);
    final List<File> files = new ArrayList<File>(totalDexNumber);

    // 从第2个dex开始遍历,这是因为主dex由Android系统自动加载的,从第2个开始即可
    for (int secondaryNumber = 2; secondaryNumber <= totalDexNumber; secondaryNumber++) {
        // 文件名,格式是"${apkName}.classes${secondaryNumber}.zip"
        String fileName = extractedFilePrefix + secondaryNumber + EXTRACTED_SUFFIX;
        // 根据缓存目录和文件名得到抽取后的文件
        File extractedFile = new File(dexDir, fileName);
        // 如果是一个文件就保存到抽取出的文件列表中
        if (extractedFile.isFile()) {
            files.add(extractedFile);
            if (!verifyZipFile(extractedFile)) {
                Log.i(TAG, "Invalid zip file: " + extractedFile);
                throw new IOException("Invalid ZIP file.");
            }
        } else {
            throw new IOException("Missing extracted secondary dex file '" +
                    extractedFile.getPath() + "'");
        }
    }

    return files;
}

4 总结

分析到这,MultiDex安装多个dex的原理应该介绍清楚了,无非就是通过一定的方式把dex文件抽取出来,然后把这些dex文件追加到DexPathList的Element[]数组的后面,这个过程要尽可能的早,所以一般是在Application的attachBaseContext()方法中。
一些热修复技术,就是通过一定的方式把修复后的dex插入到DexPathList的Element[]数组前面,实现了修复后的class抢先加载。

参考

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,684评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,143评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,214评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,788评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,796评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,665评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,027评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,679评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,346评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,664评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,766评论 1 331
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,412评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,015评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,974评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,073评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,501评论 2 343

推荐阅读更多精彩内容