美文网首页Android收藏集
一文了解MultiDex运行原理

一文了解MultiDex运行原理

作者: wanderingGuy | 来源:发表于2019-03-07 10:23 被阅读52次

    在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<File>记录一个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<File>集合。
      • 首次提取会调用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过程耗时。

    参考文章

    相关文章

      网友评论

        本文标题:一文了解MultiDex运行原理

        本文链接:https://www.haomeiwen.com/subject/cuiauqtx.html