2

在Android Kikat及以前的Android系统上,构建或安装Apk会出现“65535方法数超标”以及“INSTALL_FAILED_DEXOPT”问题,MultiDex是Google为了解决这个问题问题而开发的一个Support库。MultiDex出现的具体背景、使用方式可以参考给App启用 MultiDex功能,而MultiDex Support库的工作机制、源码分析可以参考MultiDex工作原理分析和优化方案

MultiDex的使用虽然很简单便捷,但是有个比较蛋疼的问题,就是在App第一次冷启动的时候会产生明显的卡顿现象。经过测试和统计,根据Apk包的大小、Android系统版本的不同,这个卡顿时间一般是2000到5000毫秒左右,极端的情况下甚至可以到20000+毫秒。通过之前的分析,我们知道具体的卡顿产生在MultiDex解压、优化dex这两个过程,而且只在第一次冷启动的时候才会触发这两个过程。那么优化的方式也很简单,在安装Apk前先对新版本的Apk做好解压和优化工作,就能在安装后第一次冷启动的时候避开这两个耗时的过程了。

MultiDex是如何判断是否需要重新解压和优化dex的

在之前的章节里面讲到,MultiDex在第一次做完解压和优化dex之后,会保留当前Apk的一些信息,下一次启动时候后读取这些配置信息再判断是否需要重新解压和优化dex文件。

这个判断主要是在MultiDexExtractor#load(Context, ApplicationInfo, File, boolean)方法里进行。

    static List<File> load(Context context, ApplicationInfo applicationInfo, File dexDir,
            boolean forceReload) throws IOException {
       
        try {
            ...
            if (!forceReload && !isModified(context, sourceApk, currentCrc)) {
                try {
                    files = loadExistingExtractions(context, sourceApk, dexDir);
                } catch (IOException ioe) {
                    ...
                    files = performExtractions(sourceApk, dexDir);
                    putStoredApkInfo(context,
                            getTimeStamp(sourceApk), currentCrc, files.size() + 1);
                }
            } else {
                ...
                files = performExtractions(sourceApk, dexDir);
                putStoredApkInfo(context, getTimeStamp(sourceApk), currentCrc, files.size() + 1);
            }
        }
        ...
        return files;
    }

第一次调用这个方法的时候,forceReload为false,则不需要强制重新解压dex。然后调用了isModified这个方法判断当前App的Apk包是否被修改过。

    private static boolean isModified(Context context, File archive, long currentCrc) {
        SharedPreferences prefs = getMultiDexPreferences(context);
        return (prefs.getLong(KEY_TIME_STAMP, NO_VALUE) != getTimeStamp(archive))
                || (prefs.getLong(KEY_CRC, NO_VALUE) != currentCrc);
    }

isModified方法主要是判断当前App的Apk包的CRC值是否和上一次解压dex时记录的Apk包CRC一样(CRC值可以认为是一个稀疏的MD5算法,它的时间复杂度低很多,但是计算结果容易产生冲突),以及Apk文件的修改时间(文件的Last Modified Time)是否一致。如果这两项都一致的话就认为Apk文件没有产生变化(没有覆盖安装过),因此上一次解压和优化dex得到的缓存文件可以复用。

当然,光Apk包没有修改过这一项条件还不够,接下来调用了这个判断主要是在MultiDexExtractor#loadExistingExtractions(Context, File, File)。

    private static List<File> loadExistingExtractions(Context context, File sourceApk, File dexDir)
            throws IOException {
            
        final String extractedFilePrefix = sourceApk.getName() + EXTRACTED_NAME_EXT;
        int totalDexNumber = getMultiDexPreferences(context).getInt(KEY_DEX_NUMBER, 1);
        final List<File> files = new ArrayList<File>(totalDexNumber);
        for (int secondaryNumber = 2; secondaryNumber <= totalDexNumber; secondaryNumber++) {
            String fileName = extractedFilePrefix + secondaryNumber + EXTRACTED_SUFFIX;
            File extractedFile = new File(dexDir, fileName);
            if (extractedFile.isFile()) {
                files.add(extractedFile);
                if (!verifyZipFile(extractedFile)) {
                    throw new IOException("Invalid ZIP file.");
                }
            } else {
                throw new IOException("Missing extracted secondary dex file '" +
                        extractedFile.getPath() + "'");
            }
        }
        return files;
    }

这里先通过SharePreference读取上一次MultiDex保存的Apk包的dex数量totalDexNumber,然后挨个加载预定的文件路径上的dex文件,加载文件的的同时还通过verifyZipFile方法判断dex文件的合法性。如果这个过程出现异常就认为获取上一次缓存的dex文件失败,需要重新解压。

    static boolean verifyZipFile(File file) {
        try {
            ZipFile zipFile = new ZipFile(file);
            try {
                zipFile.close();
                return true;
            } catch (IOException e) {
                Log.w(TAG, "Failed to close zip file: " + file.getAbsolutePath());
            }
        } catch (ZipException ex) {
            Log.w(TAG, "File " + file.getAbsolutePath() + " is not a valid zip file.", ex);
        } catch (IOException ex) {
            Log.w(TAG, "Got an IOException trying to open zip file: " + file.getAbsolutePath(), ex);
        }
        return false;
    }

verifyZipFile这个方法非常简单,解压dex文件的时候,解压出来的文件被保存成Zip包,这个方法这是检查缓存的dex文件是否是Zip包。感觉不靠谱,虽然检查MD5值比较耗时不适合这种情景,不过好歹也像检查Apk包的CRC值和修改时间一样,检查dex缓存文件的CRC和修改时间啊。不过读取SharePreference配置是一个IO操作,如果保存的数值太多的话,也是有增加耗时和IO异常的风险的。

到这里我们的方案就清晰了:

  1. 在安装新Apk前,先做好dex的解压和优化,得到dex压缩包(.zip)列表和dexopt后的odex文件(.dex)列表。

  2. 把dex/odex文件保存到一个内部存储路径PATH_A,同时使用SP记录新版本Apk的CRC、dex数量,以及解压出来的每一个dex的CRC值。

  3. 安装新版本Apk后,启动时在执行MultiDex前,把PATH_A路径上的缓存文件移动(rename)到MultiDex的缓存路径PATH_B上,同时保存当前Apk的CRC、修改时间以及dex数量到MultiDex对应的SP配置上。

  4. 执行原有MultiDex逻辑,让MultiDex以为之前已经做过解压和优化dex工作,从而绕开第一次MultiDex时候的耗时。

  5. 第一次成功启动新Apk后,对dex进行校验工作,如果校验失败则清除dex缓存,强制让App在下一次启动的时候再执行一遍MultiDex。

预解压(PreMultiDex)详细的流程图

注:

  1. 流程图的绿色部分为文件锁(FileLock)操作,主要是为了多进程同步。

  2. 红色部分为耗时的操作。

  3. Dex路径为MultiDex过程中用于存储解压出来的dex文件的路径(/data/data/<package>/code_cache)。

  4. PreDex路径为存储预解压得到的缓存文件的内部路径(/data/data/<package>/code_cache_pre)。

  5. MultiDex从Apk包解压出来的dex文件会被压缩成Zip包(.zip),而执行dexopt操作后生成的odex文件文件名为.dex,这两个容易搞混。

安装新Apk前先解压和优化dex

这个环节必须在升级Apk前,由旧版本的Apk进行,也就是要求App拥有自主更新的逻辑。

第一次运行新Apk时,移动预先安装好的dex文件

从旧版的Apk覆盖安装新的Apk后,第一次运行App时MultiDex主要的耗时过程。这时需要把在旧版本Apk预安装得到的dex缓存文件移动到MultiDex使用的存储路径上。

第一次运行新Apk后,检查dex文件是否正确

原有的MultiDex,dex文件时同步从Apk包里解压出来的,所以不存在dex文件和Apk版本对不上的问题。而PreMultiDex的方案的一个问题ui是,解压dex文件和使用dex文件这两个过程是分开的,无论版本控制做得再精确,理论上也存在版本出错的问题(比如从A版本解压得到了dex文件,而用户却选择覆盖安装了B版本,这时候由于代码逻辑的不严谨导致B版本的Apk使用了A版本解压出来的dex文件)。如果想要确保dex文件的正确性,需要对Apk包里面的dex文件和解压出来的dex文件做一下MD5值校验,而这个过程比较耗时,不适合在App启动的时候做,不然PreMultiDex就失去了意义。因此,需要在第一次运行新Apk后,启动dex的校验工作,在Worker线程对dex进行校验,如果校验失败则清除dex缓存,强制让App在下一次启动的时候再执行一遍MultiDex。

恢复MultiDex

在MultiDex校验失败后,需要清空MultiDex的缓存文件,禁用PreMultiDex功能,并且强制让App在下一次启动的时候再执行一遍MultiDex。

一些小细节

dex文件、odex文件?

dex文件是Android虚拟机使用的可执行文件(从Java类编译得到),相当于JVM虚拟机用的class文件。但是与class文件不同,Android系统并不能直接使用dex文件,需要先使用dexopt工具对dex文件进行一次优化工作(Optimize),优化得到的odex文件才能被虚拟机加载。不同的Android设备需要不同格式的odex文件,所以这个过程只能在Android设备上进行,而不能在构建Apk的时候就处理好。

dex文件在Apk包里的文件后缀名是.dex,MultiDex从Apk包里解压出dex文件后会压缩成Zip包,文件后缀名是.zip。对dex文件进行dexopt操作后,会生成相同文件名的odex文件,后缀名是.dex,odex文件会比dex文件大许多,不要搞混这些文件。

至于为什么MultiDex解压dex文件时会进行压缩工作,可能是因为压缩后的压缩包会占用比较小的内部存储空间,因为MultiDex本来就是给旧版本的Android系统使用,一些早期的Android设备拥有的内部存储空间非常有限,而这些dex文件对于App的运行时必须的,所以才需要尽量压缩dex的体积。压缩过程会有明显的耗时,经过测试,如果不进行压缩,直接从Apk里解压dex文件,则MultiDex过程会有大约1/3的加速效果。

dexopt缓存

MultiDex其实并没有刻意保留dexopt后的缓存,如果只保留dex文件,而不保留odex文件,那么下一次启动执行MultiDex的时候,不需要重新解压dex文件,但是依然需要dexopt并产生odex文件,这个过程大概会占用MultiDex总耗时的一般左右。如果odex文件存在,但是已经损坏了,或者是一个非法的odex文件,依然会触发dexopt工作。也就是说,加载dex文件并创建DexFile对象的时候,Android系统会判断odex的缓存,以及缓存文件是否正确,具体过程在dalvik_system_DexFile.cpp里实现,有兴趣的同学可以找找dex文件结构分析的文章,这里就不挖坑了。

关于dex文件校验

其实,如果dex文件和Apk的版本对不上的话,一般在启动App的时候就会出现ClassNotFound异常而导致App崩溃,接着再次启动由于没有重新MultiDex也会继续崩溃。而崩溃的时候,可能App崩溃上报系统还没来得及初始化,所以没有办法发现崩溃的问题。

为了防止这种问题,可以开发一个恢复模式或者安全模式的功能,当App出现连续的崩溃的时候,会进入恢复模式的状态,清空一些可能导致异常的数据(比如PreMultiDex的缓存),这样就能避免App因为连续崩溃而不能使用。至于怎么实现恢复,这已经是另一个领域的功能了,这里不再展开。

参考链接:
Google Multidex

著作信息:
本文章出自 Kaede 的博客,原创文章若无特别说明,均遵循 CC BY-NC 4.0 知识共享许可协议4.0(署名-非商用-相同方式共享),可以随意摘抄转载,但必须标明署名及原地址。


Kaede
1.8k 声望421 粉丝

Talk is cheap, let me show you the code.