热修复Hotfix系列(2)—MultiDex: 叫爸爸!

0x01 开篇

官方文档MultiDex解释:

1.Dalvik Executable (DEX)文件的总方法数限制在65536以内,其中包括Android framwork method, lib method,还有你的 code method ,所以请使用MultiDex。
2.对于5.0以下版本,请使用multidex support library。
3.而5.0及以上版本,由于ART模式的存在,app第一次安装之后会进行一次预编译(pre-compilation) ,如果这时候发现了classes(..N).dex文件的存在就会将他们最终合成为一个.oat的文件。

官方文档还写明了multiDex support lib 的使用局限:

1.在应用安装到手机上的时候dex文件的安装是复杂的(complex)有可能会因为第二个dex文件太大导致ANR。请用proguard优化你的代码。
2.使用了mulitDex的App有可能在4.0(api level 14)以前的机器上无法启动,因为Dalvik linearAlloc bug。用proguard优化你的代码将减少该bug几率。
3.使用了mulitDex的App在runtime期间有可能因为Dalvik linearAlloc limit Crash。该内存分配限制在 4.0版本被增大,但是5.0以下的机器上的Apps依然会存在这个限制。
4.主dex被dalvik虚拟机执行时候,哪些类必须在主dex文件里面这个问题比较复杂。build tools 可以搞定这个问题。但是如果你代码存在反射和native的调用也不保证100%正确。

虽然看起来好像有点坑的样子,但是不管了,先嫖再给钱,看看整个流程先~

0x02 好戏开场了

在android5.0之前,整个app里就一个dex,对这个dex的加载在安装的时候就加载好了,但是这个dex的方法数量被限制在65535之内。

引入MultiDex之后,会分为一个主dex和多个分dex(classes1.dex,classes2.dex,classesN.dex),应用首次安装会加载主dex,而第二个以及之后的dex则是在Application里面的attachBaseContext()里面调用MultiDex.install(this)的时候进行解压与安装【安装的概念就是对原始的apk文件中包含的dex包进行解压优化并进行优化目录归档,让应用下次启动可以在特定目录下找到这批dex包进行ClassLoader的初始化】

这个方法里面主要做了两件事,接下来我们通过源码来进行分析:

public static void install(Context context) {
        Log.i("MultiDex", "Installing application");
        if (IS_VM_MULTIDEX_CAPABLE) {
            Log.i("MultiDex", "VM has multidex support, MultiDex support library is disabled.");
        } 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);
                if (applicationInfo == null) {
                    Log.i("MultiDex", "No ApplicationInfo available, i.e. running on a test Context: MultiDex support library is disabled.");
                    return;
                }

                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() + ").");
            }

            Log.i("MultiDex", "install done");
        }
    }

首先通过Application获取到两个文件路径,分别是applicationInfo.sourceDir

【/data/app/com.hotfix.sample-JEOYk3NmgdLD31DnXH25EQ==/base.apk】

applicationInfo.dataDir

【/data/user/0/com.hotfix.sample】

然后调用方法doInstallation(),我们接着看doInstallation()方法。

private static void doInstallation(Context mainContext, File sourceApk, File dataDir, String secondaryFolderName, String prefsKeyPrefix) throws IOException, IllegalArgumentException, IllegalAccessException, NoSuchFieldException, InvocationTargetException, NoSuchMethodException {
        Set var5 = installedApk;
        synchronized(installedApk) {
            if (!installedApk.contains(sourceApk)) {
                installedApk.add(sourceApk);
                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实例,后面要做的工作,就是把其他dex文件加载后,把其DexFile对象添加到这个ClassLoader实例里
                ClassLoader loader;
                try {
                    loader = mainContext.getClassLoader();
                } catch (RuntimeException var11) {
                    Log.w("MultiDex", "Failure while trying to obtain Context class loader. Must be running in test mode. Skip patching.", var11);
                    return;
                }

                if (loader == null) {
                    Log.e("MultiDex", "Context class loader is null. Must be running in test mode. Skip patching.");
                } else {
                    try {
                        //清除/data/user/0/pkgName/files/code_cache/secondary-dexes的内容
                        clearOldDexDir(mainContext);
                    } catch (Throwable var10) {
                        Log.w("MultiDex", "Something went wrong when trying to clear old MultiDex extraction, continuing without cleaning.", var10);
                    }

                    //先使用/data/user/0/pkgName/code_cache/secondary-dexes目录
                    //如果失败使用/data/user/0/pkgName/files/code_cache/secondary-dexes
                    //这也是为什么上面需要进行clearOldDexDir的原因
                    File dexDir = getDexDir(mainContext, dataDir, secondaryFolderName);
                    //加载Dex文件列表
                    List<? extends File> files = MultiDexExtractor.load(mainContext, sourceApk, dexDir, prefsKeyPrefix, false);
                    //开始安装
                    installSecondaryDexes(loader, dexDir, files);
                }
            }
        }
    }

MultiDex.install(Context)的过程中,关键的步骤就是MultiDexExtractor#load方法和MultiDex#installSecondaryDexes方法,继续看看MultiDexExtractor#load方法做了些什么见不得人的事:

    static List<? extends File> load(Context context, File sourceApk, File dexDir, String prefsKeyPrefix, boolean forceReload) throws IOException {
        Log.i("MultiDex", "MultiDexExtractor.load(" + sourceApk.getPath() + ", " + forceReload + ", " + prefsKeyPrefix + ")");
        long currentCrc = getZipCrc(sourceApk);//获取当前Apk文件的crc值
        File lockFile = new File(dexDir, "MultiDex.lock");
        RandomAccessFile lockRaf = new RandomAccessFile(lockFile, "rw");
        FileChannel lockChannel = null;
        FileLock cacheLock = null;
        IOException releaseLockException = null;

        List files;
        try {
            lockChannel = lockRaf.getChannel();
            Log.i("MultiDex", "Blocking on lock " + lockFile.getPath());
            cacheLock = lockChannel.lock();//加上文件锁,防止多进程冲突
            Log.i("MultiDex", lockFile.getPath() + " locked");
            // 先判断是否强制重新解压,这里第一次会优先使用已解压过的dex文件,如果加载失败就强制重新解压
            // 此外,通过crc和文件修改时间,判断如果Apk文件已经被修改(覆盖安装),就会跳过缓存重新解压dex文件
            if (!forceReload && !isModified(context, sourceApk, currentCrc, prefsKeyPrefix)) {
                try {
                    // 加载缓存的dex文件
                    files = loadExistingExtractions(context, sourceApk, dexDir, prefsKeyPrefix);
                } catch (IOException var21) {
                    Log.w("MultiDex", "Failed to reload existing extracted secondary dex files, falling back to fresh extraction", var21);
                    //加载失败的话重新解压
                    files = performExtractions(sourceApk, dexDir);
                    //保存解压出来的dex文件的信息[SP保存]
                    putStoredApkInfo(context, prefsKeyPrefix, getTimeStamp(sourceApk), currentCrc, files);
                }
            } else {
                Log.i("MultiDex", "Detected that extraction must be performed.");
                files = performExtractions(sourceApk, dexDir);
                putStoredApkInfo(context, prefsKeyPrefix, getTimeStamp(sourceApk), currentCrc, files);
            }
        } finally {
            if (cacheLock != null) {
                try {
                    cacheLock.release();
                } catch (IOException var20) {
                    Log.e("MultiDex", "Failed to release lock on " + lockFile.getPath());
                    releaseLockException = var20;
                }
            }

            if (lockChannel != null) {
                closeQuietly(lockChannel);
            }

            closeQuietly(lockRaf);
        }

        if (releaseLockException != null) {
            throw releaseLockException;
        } else {
            Log.i("MultiDex", "load found " + files.size() + " secondary dex files");
            return files;
        }
    }

这个过程主要是获取可以安装的dex文件列表,可以是上次解压出来的缓存文件,也可以是重新从Apk包里面提取出来的。在performExtractions()方法中对Dex进行解压,这里会有明显的耗时,而且解压出来的dex文件,会被压缩成.zip压缩包,压缩的过程也会有明显的耗时(这里压缩dex文件可能是问了节省空间)。

如果dex文件是重新解压出来的,则会保存dex文件的信息,包括解压的apk文件的crc值、修改时间以及dex文件的数目,以便下一次启动直接使用已经解压过的dex缓存文件,而不是每一次都重新解压。

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);

                    try {
                        extractedFile.crc = getZipCrc(extractedFile);
                        isExtractionSuccessful = true;
                    } catch (IOException var19) {
                        isExtractionSuccessful = false;
                        Log.w("MultiDex", "Failed to read crc from " + extractedFile.getAbsolutePath(), var19);
                    }

                    Log.i("MultiDex", "Extraction " + (isExtractionSuccessful ? "succeeded" : "failed") + " - length " + extractedFile.getAbsolutePath() + ": " + extractedFile.length() + " - crc: " + extractedFile.crc);
                    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 (" + secondaryNumber + ")");
                }

                ++secondaryNumber;
            }
        } finally {
            try {
                apk.close();
            } catch (IOException var18) {
                Log.w("MultiDex", "Failed to close resource", var18);
            }

        }

        return files;
    }

那现在回到之前的MultiDex#install方法,通过一系列的处理,我们拿到了一批经过处理的dex【.zip】,接下来的事情就是把这批dex搞进去ClassLoader里面了,installSecondaryDexes(loader, dexDir, files);

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

这个方法做了android的版本判断,我们选择>=19的分支来看看

    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));
        if (suppressedExceptions.size() > 0) {
            Iterator var6 = suppressedExceptions.iterator();

            while(var6.hasNext()) {
                IOException e = (IOException)var6.next();
                Log.w("MultiDex", "Exception in makeDexElement", e);
            }

            Field suppressedExceptionsField = MultiDex.findField(dexPathList, "dexElementsSuppressedExceptions");
            IOException[] dexElementsSuppressedExceptions = (IOException[])((IOException[])suppressedExceptionsField.get(dexPathList));
            if (dexElementsSuppressedExceptions == null) {
                dexElementsSuppressedExceptions = (IOException[])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(dexPathList, dexElementsSuppressedExceptions);
        }

    }

调用DexPathList#makeDexElements方法,可以加载我们上面解压得到的dex文件,从代码也可以看出,DexPathList#makeDexElements其实也是通过调用DexFile#loadDex来加载dex文件并创建DexFile对象的。

V19中,通过反射调用DexPathList#makeDexElements方法加载我们需要的dex文件,在把加载得到的数组扩展到ClassLoader实例的"pathList"字段,从而完成dex文件的安装。如果在执行DexPathList#makeDexElements方法的过程中出现异常,后面使用反射的方式把这些异常记录进DexPathList的dexElementsSuppressedExceptions字段里面。

在创建DexFile对象的时候,都需要通过DexFile的Native方法openDexFile来打开dex文件。这个过程的主要目的是给当前的dex文件做Optimize优化处理并生成相同文件名的odex文件,App实际加载类的时候,都是通过odex文件进行的。

0x03 总结一下

MulitDex.install主要完成这样一个流程~
1.从data/app/pkgName-x/base.apk中加载其他分dex,存储目录首选为/data/user/0/pkgName/code_cache/secondary-dexes或者是备用目录/data/user/0/pkgName/files/code_cache/secondary-dexes。

在首次安装完成了除了主dex之外的其他n个dex包的解压并生成一个dex的文件列表对象dex-file-list。

2.MulitDex#installSecondaryDexes安装dex。上一步我们已经加载获取到dex-file-list,接下来做的就是makeDexElements【使用DexFile对dex进行加载并进行进一步的封装,为添加到ClassLoader的DexElments数组中做准备】,makeDexElements做的事就是将上一步处理完成的dex包装成结构体DexFile并插入到ClassLoader的DexElments中。

在创建DexFile对象的时候,都需要通过DexFile的Native方法openDexFile来打开dex文件,这个过程的主要目的是给当前的dex文件做Optimize优化处理并生成相同文件名的odex文件。主dex的优化我们已经在安装apk的时候完成了,其余的dex就是在MultiDex#installSecondaryDexes里面优化的,而后者也是MultiDex过程中,另外一个耗时比较多的操作。

0x04 我所遇到的问题

  1. 在学习dex包相关的时候,我对于apk中dex包的处理流程并不清楚,我所困惑的是,dex包从apk安装经历了什么处理?这些dex包最后去了哪里?现在回答一下上面的问题:

我们把apk当成一个zip压缩包,dalvik每次加载apk都要从中解压出class.dex文件,加载过程还涉及到dex的classes需要的杂七杂八的依赖库的加载,这是非常耗时的操作。于是Android决定优化一下这个问题,在app安装到手机之后,系统运行dexopt程序对dex进行优化,将dex的依赖库文件和一些辅助数据打包成odex文件。存放在cache/dalvik_cache目录下。保存格式为apk路径 @ apk名 @ classes.dex。这样以空间换时间大大缩短读取/加载dex文件的过程。那multidex方案在对除了主dex之外的其他dex进行解压优化之后应该也会存放到这个目录中,在MultiDex流程中有个步奏是获取odex的缓存文件,应该也是从这个目录下面加载一批dex的文件列表并装载进入ClassLoader中。

  1. 在学习Tinker相关原理的时候涉及到dex包的合成,那个时候有个问题不能理解的是,tinker进行了dex的合成,然后存在于哪里,加载时机又是怎么样的?

tinker的差分包会在apk原始路径下面找到差分的dex【比如我热修的类处于classes2.dex】,那会在/data/app/pkgname==/base.apk里面找到原始的dex包然后进行合并,我们把这个新生成的dex称为new_dex,这个new_dex是放在tinker的自定义目录下的,在启动load的过程中,我们在classloader加载完成基准包的dex列表之后生成classloader,再通过类似于MultiDex的方式对这个new_dex进行处理并放在DexElements的最前从而达到热修复的目的。

0x05 推荐阅读

你不知道的MultiDex有多坑:https://www.jianshu.com/p/a5353748159f

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