美文网首页app开发workAndroid知识
Multidex(一)之源码解析

Multidex(一)之源码解析

作者: 未来的理想 | 来源:发表于2016-12-15 18:03 被阅读5030次

    一、初识MultiDex

    开发Android应用的小伙伴,在经历了众多版本迭代、PM不断加入新功能、尝试新技术引入类库之后,产物Apk急剧膨胀;最终会遇到那个传说中的Android64K方法数问题;具体表现:

    Conversion to Dalvik format failed:Unable to execute dex: method ID not in [0, 0xffff]: 65536

    编译打包失败,不能愉快的开发了。

    超过最大方法数限制的问题,是由于Dex文件格式限制,一个Dex文件中method个数采用使用原生类型short来索引文件中的方法,2个字节共计最多表达65536个method,field/class的个数也均有此限制。生成Dex文件的过程,是将工程所需全部class文件合并且压缩到一个Dex文件期间,也就是Android打包的Dex过程中, 单个Dex文件可被引用的方法总数(自己开发的代码以及所引用的Android框架、类库的代码)被限制为65536;

    但是这种小问题怎么能难倒程序猿哥哥呢,相信大家或多或少都听说过Multidex:Google官方对64K方法数问题的一种补救措施,通俗的讲就是:既然你的代码这么多,一个Dex装不下,那就给你多个Dex来装呗。Multidex在构建打包阶段将Class拆分到多个Dex,使之不超过单Dex最大方法数的限制;这样打包就不会失败了。

    但是只解决分Dex打包的问题还不够,我们知道Dalvik虚拟机应用启动时默认只会装载classes.dex,那ClassLoader肯定是无法从别的Dex中查找Class的,从而程序运行过程中的各种ClassNotFoundException画面太美简直不敢想象。于是机智如Google又赋予MultiDex另外一项能力:在运行时动态装载别的非主Dex,于是乎一个看似完美的分Dex加载方案就诞生了。

    具体的使用指南可以异步官方文档;

    二、Multidex工作流程

    在分析源码之前,我们先来看一下MultiDex的工作流程,对它有一个初步的认识;

    屏幕快照 2016-12-14 下午8.41.00.png

    总结:

    • 运行时提取别的非主Dex出来,然后动态装载执行
    • 需要在源码分析过程中重点关注提取Dex以及动态装载这两个过程

    三、源码分析

    MultiDex入口:MultiDex.install();

    public static void install(Context context) {
        Log.i("MultiDex", "install");
        if(IS_VM_MULTIDEX_CAPABLE) {
            //判断VM是否支持Multidex,本身就支持的话MultiDex库则被禁用;
            Log.i("MultiDex", "VM has multidex support, MultiDex support library is disabled.");
        } else if(VERSION.SDK_INT < 4) {
            //最低兼容SDK版本是4,不过现在4以下的机器已经是纪念品了吧;
            throw new RuntimeException("Multi dex installation failed. SDK " + VERSION.SDK_INT + " is unsupported. Min SDK version is " + 4 + ".");
        } else {
            try {
                //获取Apk信息;
                ApplicationInfo e = getApplicationInfo(context);
                if(e == null) {
                    return;
                }
                Set var2 = installedApk;
                //加锁保证只执行一次;
                synchronized(installedApk) {
                    String apkPath = e.sourceDir;
                    if(installedApk.contains(apkPath)) {
                        return;
                    }
                    installedApk.add(apkPath);
                    if(VERSION.SDK_INT > 20) {
                        Log.w("MultiDex", "MultiDex is not guaranteed to work in SDK version " + VERSION.SDK_INT + ": SDK version higher than " + 20 + " 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") + "\"");
                    }
                    ClassLoader loader;
                    try {
                        //获取当前ClassLoader实例,提取出来的Dex需要通过ClassLoader真正的被加载执行;
                        loader = context.getClassLoader();
                    } catch (RuntimeException var9) {
                        Log.w("MultiDex", "Failure while trying to obtain Context class loader. Must be running in test mode. Skip patching.", var9);
                        return;
                    }
                    if(loader == null) {
                        Log.e("MultiDex", "Context class loader is null. Must be running in test mode. Skip patching.");
                        return;
                    }
                    try {
                        //清除OldDexDir的目录;此处有歧义,下面讲。
                        clearOldDexDir(context);
                    } catch (Throwable var8) {
                        Log.w("MultiDex", "Something went wrong when trying to clear old MultiDex extraction, continuing without cleaning.", var8);
                    }
                    //创建提取Dex缓存的路径。
                    File dexDir = new File(e.dataDir, SECONDARY_FOLDER_NAME);
    
                    //重要方法!!!提取除主Dex之外别的Dex出来;
                    List files = MultiDexExtractor.load(context, e, dexDir, false);
                    //校验提取出来的Dex文件的合法性;
                    if(checkValidZipFiles(files)) {
    
                        //重要方法!!!合法的话则进入安装过程;
                        installSecondaryDexes(loader, dexDir, files);
                    } else {
                        //校验不合法,强制重新执行一次Dex的提取,不抛弃、不放弃,哈哈。
                        Log.w("MultiDex", "Files were not valid zip files.  Forcing a reload.");
                        files = MultiDexExtractor.load(context, e, dexDir, true);
                        if(!checkValidZipFiles(files)) {
                            //提取出来再次校验,仍然不合法,不能忍,放弃!分分钟抛异常给你看!
                            throw new RuntimeException("Zip files were not valid.");
                        }
                        //合法的话则进入安装过程;
                        installSecondaryDexes(loader, dexDir, files);
                    }
                }
            } catch (Exception var11) {
                Log.e("MultiDex", "Multidex installation failure", var11);
                throw new RuntimeException("Multi dex installation failed (" + var11.getMessage() + ").");
            }
            Log.i("MultiDex", "install done");
        }
    }
    

    总结:

    • 进行各种预校验以及获取需要的信息;
    • 重要方法:MultiDexExtractor.load(context, e, dexDir, false),将Dex文件提取出来;
    • 重要方法:installSecondaryDexes(loader, dexDir, files),安装提取出来的Dex文件。

    提取器MultiDexExtractor.load

      //重要方法
      static List<File> load(Context context, ApplicationInfo applicationInfo, File dexDir, boolean forceReload) throws IOException {
        Log.i("MultiDex", "MultiDexExtractor.load(" + applicationInfo.sourceDir + ", " + forceReload + ")");
        File sourceApk = new File(applicationInfo.sourceDir);
        //获取Crc校验码,做文件完整性校验;
        long currentCrc = getZipCrc(sourceApk);
        List files;
        //是否是强制性提取或者源文件发生了变化
        if(!forceReload && !isModified(context, sourceApk, currentCrc)) {
            try {
                //非强制性提取,且源文件未发生变化,直接使用缓存的dex文件。
                files = loadExistingExtractions(context, sourceApk, dexDir);
            } catch (IOException var9) {
                Log.w("MultiDex", "Failed to reload existing extracted secondary dex files, falling back to fresh extraction", var9);
                //异常则重新执行强制性提取,并更新提取出来的Dex信息,存在SharedPreference中。
                files = performExtractions(sourceApk, dexDir);
                //缓存下来lastModified时间戳;Crc校验码,Dex的总数量等信息用于下次比对。
                putStoredApkInfo(context, getTimeStamp(sourceApk), currentCrc, files.size() + 1);
            }
        } else {
            //强制性提取,并更新提取出来的Dex信息,存在SharedPreference中。
            Log.i("MultiDex", "Detected that extraction must be performed.");
            files = performExtractions(sourceApk, dexDir);
            //缓存下来lastModified时间戳;Crc校验码,Dex的总数量等信息用于下次比对。
            putStoredApkInfo(context, getTimeStamp(sourceApk), currentCrc, files.size() + 1);
        }
    
        Log.i("MultiDex", "load found " + files.size() + " secondary dex files");
        return files;
    }
    

    总结:

    • 制性提取或者源文件发生变化则重新提取,否则直接使用缓存dex文件;

    performExtractions()

      //重要方法
      private static List<File> performExtractions(File sourceApk, File dexDir) throws IOException {
        //匹配的后缀;
        String extractedFilePrefix = sourceApk.getName() + ".classes";
        //准备Dex缓存的路径;
        prepareDexDir(dexDir, extractedFilePrefix);
        ArrayList files = new ArrayList();
        ZipFile apk = new ZipFile(sourceApk);
        try {
            int e = 2;
            for(ZipEntry dexFile = apk.getEntry("classes" + e + ".dex"); dexFile != null; dexFile = apk.getEntry("classes" + e + ".dex")) {
                String fileName = extractedFilePrefix + e + ".zip";
                //提取出来的文件,zip格式。
                File extractedFile = new File(dexDir, fileName);
                files.add(extractedFile);
                Log.i("MultiDex", "Extraction is needed for file " + extractedFile);
                int numAttempts = 0;
                boolean isExtractionSuccessful = false;
                //每个dex的提取都尝试三次;
                while(numAttempts < 3 && !isExtractionSuccessful) {
                    ++numAttempts;
                    //真正的提取。将源Apk解压,将非主Dex文件写为zip文件。
                    extract(apk, dexFile, extractedFile, extractedFilePrefix);
                    isExtractionSuccessful = verifyZipFile(extractedFile);
                    Log.i("MultiDex", "Extraction " + (isExtractionSuccessful?"success":"failed") + " - length " + extractedFile.getAbsolutePath() + ": " + extractedFile.length());
                    if(!isExtractionSuccessful) {
                        //提取出来的文件未校验通过则删除。
                        extractedFile.delete();
                        if(extractedFile.exists()) {
                            Log.w("MultiDex", "Failed to delete corrupted secondary dex \'" + extractedFile.getPath() + "\'");
                        }
                    }
                }
                if(!isExtractionSuccessful) {
                    throw new IOException("Could not create zip file " + extractedFile.getAbsolutePath() + " for secondary dex (" + e + ")");
                }
                ++e;
            }
        } finally {
            try {
                apk.close();
            } catch (IOException var16) {
                Log.w("MultiDex", "Failed to close resource", var16);
            }
        }
        return files;
    }
    

    总结:

    • 准备Dex缓存的目录,并且删除其中不是以name.apk.classes开头的文件;
    • 每个Dex的提取最多尝试三次;

    真实提取extract(apk, dexFile, extractedFile, extractedFilePrefix);

    /**
     * 重要方法
     * @param apk apk源文件:/data/app/apkName.apk;
     * @param dexFile apk源文件解压出来的Dex文件:classes2.dex等;
     * @param extractTo 提取出来的文件;
     * @param extractedFilePrefix 提取出来的文件前缀;
     * @throws IOException
     * @throws FileNotFoundException
     */
    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(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();
            }
            Log.i("MultiDex", "Renaming to " + extractTo.getPath());
            if(!tmp.renameTo(extractTo)) {
                throw new IOException("Failed to rename \"" + tmp.getAbsolutePath() + "\" to \"" + extractTo.getAbsolutePath() + "\"");
            }
        } finally {
            closeQuietly(in);
            tmp.delete();
        }
    }
    

    总结:

    • 将Apk源文件进行解压,将其中的非主Dex文件提取为zip文件。

    终于将非主Dex文件提取出来了,接下来就是令人激动的安装过程了。
    分析SDK19以上的为例:

      private static void installSecondaryDexes(ClassLoader loader, File dexDir, List<File> files) throws IllegalArgumentException, IllegalAccessException, NoSuchFieldException, InvocationTargetException, NoSuchMethodException, 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);
            }
        }
    }
    
    private static final class V19 {
        private V19() {
        }
    
        private static void install(ClassLoader loader, List<File> additionalClassPathEntries, File optimizedDirectory) throws IllegalArgumentException, IllegalAccessException, NoSuchFieldException, InvocationTargetException, NoSuchMethodException {
            //反射获取到应用ClassLoader的pathList字段;
            Field pathListField = MultiDex.findField(loader, "pathList");
            Object dexPathList = pathListField.get(loader);
            ArrayList suppressedExceptions = new ArrayList();
            //将刚刚提取出来的zip文件包装成Element对象,并扩展DexPathList中的dexElements数组字段;
            MultiDex.expandFieldArray(dexPathList, "dexElements", makeDexElements(dexPathList, new ArrayList(additionalClassPathEntries), optimizedDirectory, suppressedExceptions));
            if(suppressedExceptions.size() > 0) {
                Iterator suppressedExceptionsField = suppressedExceptions.iterator();
    
                while(suppressedExceptionsField.hasNext()) {
                    IOException dexElementsSuppressedExceptions = (IOException)suppressedExceptionsField.next();
                    Log.w("MultiDex", "Exception in makeDexElement", dexElementsSuppressedExceptions);
                }
    
                Field suppressedExceptionsField1 = MultiDex.findField(loader, "dexElementsSuppressedExceptions");
                IOException[] dexElementsSuppressedExceptions1 = (IOException[])((IOException[])suppressedExceptionsField1.get(loader));
                if(dexElementsSuppressedExceptions1 == null) {
                    dexElementsSuppressedExceptions1 = (IOException[])suppressedExceptions.toArray(new IOException[suppressedExceptions.size()]);
                } else {
                    IOException[] combined = new IOException[suppressedExceptions.size() + dexElementsSuppressedExceptions1.length];
                    suppressedExceptions.toArray(combined);
                    System.arraycopy(dexElementsSuppressedExceptions1, 0, combined, suppressedExceptions.size(), dexElementsSuppressedExceptions1.length);
                    dexElementsSuppressedExceptions1 = combined;
                }
    
                suppressedExceptionsField1.set(loader, dexElementsSuppressedExceptions1);
            }
        }
    
        private static Object[] makeDexElements(Object dexPathList, ArrayList<File> files, File optimizedDirectory, ArrayList<IOException> suppressedExceptions) throws IllegalAccessException, InvocationTargetException, NoSuchMethodException {
            //反射调用DexPathList对象中的makeDexElements方法,将刚刚提取出来的zip文件包装成Element对象;
            Method makeDexElements = MultiDex.findMethod(dexPathList, "makeDexElements", new Class[]{ArrayList.class, File.class, ArrayList.class});
            return (Object[])((Object[])makeDexElements.invoke(dexPathList, new Object[]{files, optimizedDirectory, suppressedExceptions}));
        }
    }
    
    /**
     * makeDexElements方法最终会调用到这个方法;
     * 其中会在Native执行dexopt的优化操作,生成odex文件,此是一个耗时的操作。
     * 
     * @param file dex文件
     * @param optimizedDirectory 优化后文件的保存路径
     * @return
     * @throws IOException
     */
    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);
        }
    }
    

    总结:

    • 反射获取ClassLoader中的pathList字段;
    • 反射调用DexPathList对象中的makeDexElements方法,将刚刚提取出来的zip文件包装成Element对象;
    • 将包装成的Element对象扩展到DexPathList中的dexElements数组字段里;
    • makeDexElements中有dexopt的操作,是一个耗时的过程,产物是一个优化过的odex文件。

    至此:提取出来的dex文件也被加到了ClassLoader里,而那些Class也就可以被ClassLoader所找到并使用。

    跟随源码一步步揭开了Multidex的神秘面纱,再回头看Multidex的工作流程图,就更加清晰明了。

    四、问题

    1、clearOldDexDir(context)是干嘛的?每一次都清除上一次提取并缓存的Dex?

    <br />No,如果只看multidex-1.0.1的代码,clearOldDexDir其实什么事情都没干,因为清除的是data/data/packageName/files/secondary-dexes文件夹下的文件,但是这个文件夹从始至终都没有被使用过。看最新MultiDex库文件Master分支的代码:
    获取缓存Dex目录的时候出现过,如果正常缓存目录创建失败,则data/data/packageName/files/secondary-dexes作为临时缓存目录。

    /**
     * 获取缓存Dex文件的目录
     *
     * @param context
     * @param applicationInfo
     * @return
     * @throws IOException
     */
    private static File getDexDir(Context context, ApplicationInfo applicationInfo)
            throws IOException {
        File cache = new File(applicationInfo.dataDir, CODE_CACHE_NAME);
        try {
            // 优先在data/data/pgn/code_cache/secondary-dexes目录创建;
            mkdirChecked(cache);
        } catch (IOException e) {
            //创建失败则在data/data/pgn/files/secondary-dexes目录下创建,作为临时存储目录。
            /* If we can't emulate code_cache, then store to filesDir. This means abandoning useless
             * files on disk if the device ever updates to android 5+. But since this seems to
             * happen only on some devices running android 2, this should cause no pollution.
             */
            cache = new File(context.getFilesDir(), CODE_CACHE_NAME);
            mkdirChecked(cache);
        }
        File dexDir = new File(cache, CODE_CACHE_SECONDARY_FOLDER_NAME);
        mkdirChecked(dexDir);
        return dexDir;
    }
    

    发布的multidex-1.0.1其实不会出现这个目录,而且这段的逻辑也不严谨,如果临时目录也创建失败了呢?

    2、动态装载Dex的过程为什么反射那些字段、方法就可以了?

    <br />这就涉及到Android中的Class加载机制了,ClassLoader加载Class调用的是BaseDexClassLoader中findClass方法,会调用到DexPathList的findClass方法,其中会对dexElements数组进行遍历,数组每一个元素对应了一个DexFile,真正的加载是在DexFile实现。而正是因为这个数组,使我们有机会将Dex包装成的Element对象扩展到其中。这样ClassLoader加载Class的时候就也会遍历调用到加进来的Dex,从而找到需要的Class。

    3、为什么上面写这是一个看似完美的分Dex加载方案?

    <br />①INSTALL_FAILED_DEXOPT;在部分机型会出现无法安装的问题没有解决。

    这是由于dexopt的LinearAlloc限制引起的,在Android版本不同分别经历了4M/5M/8M/16M限制,4.2.x系统上可能都已到16M, 在Gingerbread或者以下系统LinearAllocHdr分配空间只有5M大小的, 高于Gingerbread的系统提升到了8M。Dalvik linearAlloc是一个固定大小的缓冲区。在应用的安装过程中,系统会运行一个名为dexopt的程序为该应用在当前机型中运行做准备。dexopt使用LinearAlloc来存储应用的方法信息。Android 2.2和2.3的缓冲区只有5MB,Android 4.x提高到了8MB或16MB。当方法数量过多导致超出缓冲区大小时,会造成dexopt崩溃。

    也就是说,即便是方法数不超标,也不能保证一定能安装成功,因为DexOpt过程可能因为LinearAlloc的限制而失败。但是这个问题为什么之前没有提出呢?因为这个问题对目前的Android市场机型基本不存在,现在一般Android应用的最低兼容版本都是4.0,最可能出现这个问题的2.3之前的版本都不在考虑之列,而且目前5.0以上的机型占有率已经接近70%,低版4.0本机型已经越来越少,而且基本是4.0机型,也只是有可能触发这个限制,因此对目前来讲是个不是问题的问题。
    <br />②ANR的问题:从以上MultiDex的工作流程可以看到:MultiDex工作在主线程,而Dex的提取与DexOpt的过程都是耗时的操作,所以ANR的问题是必然存在;而且业务量越大,拆分出来的Dex越多,对应ANR的几率也就越高。

    五、结语

    既然ANR的问题这么严重,那MultiDex的方案还可以被用到实际场景吗?那必须,不给程序猿哥哥制造挑战的方案绝对不是好方案,经过优化之后依然可以是好同志嘛!
    <br />关于MultiDex的优化方案请关注后续文章Multidex(二)之Dex预加载优化<br />
    欢迎关注微信公众号:定期分享Java、Android干货!

    欢迎关注

    相关文章

      网友评论

      • 冉桓彬:赵凯强的博客:
        产生65535问题的原因

        单个Dex文件中,method个数采用使用原生类型short来索引,即2个字节最多65536个method,field、class的个数也均有此限制,关于如何解决由于引用过多基础依赖项目,造成field超过65535问题,请参考@寒江不钓的这篇文章『当Field邂逅65535』。

        对于Dex文件,则是将工程所需全部class文件合并且压缩到一个DEX文件期间,也就是使用Dex工具将class文件转化为Dex文件的过程中, 单个Dex文件可被引用的方法总数(自己开发的代码以及所引用的Android框架、类库的代码)被限制为65536。
      • HelloXu:很好奇, Google为什么不一开始采用 int 类型计数呢. 后来为什么也不改呢.
        未来的理想:@HelloXu 最初可能是脑门一拍的决定或者是顺手;之后改的话之前的版本仍然无法用。
      • sankemao:最近项目正好遇到了这个bug
      • 小马哥nice:大神,你为何如此牛逼

      本文标题:Multidex(一)之源码解析

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