在Android Kikat及之前的Android系统上,构建或安装Apk会出现“65535方法数超标”以及“INSTALL_FAILED_DEXOPT”问题,MultiDex是Google为了解决这个问题问题而开发的一个Support库。MultiDex出现的具体背景、使用方式能够参考给App启用 MultiDex功能,而MultiDex Support库的工做机制、源码分析能够参考MultiDex工做原理分析和优化方案。java
MultiDex的使用虽然很简单便捷,可是有个比较蛋疼的问题,就是在App第一次冷启动的时候会产生明显的卡顿现象。通过测试和统计,根据Apk包的大小、Android系统版本的不一样,这个卡顿时间通常是2000到5000毫秒左右,极端的状况下甚至能够到20000+毫秒。经过以前的分析,咱们知道具体的卡顿产生在MultiDex解压、优化dex这两个过程,并且只在第一次冷启动的时候才会触发这两个过程。那么优化的方式也很简单,在安装Apk前先对新版本的Apk作好解压和优化工做,就能在安装后第一次冷启动的时候避开这两个耗时的过程了。android
在以前的章节里面讲到,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获得的缓存文件能够复用。app
固然,光Apk包没有修改过这一项条件还不够,接下来调用了这个判断主要是在MultiDexExtractor#loadExistingExtractions(Context, File, File)。ide
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异常的风险的。源码分析
到这里咱们的方案就清晰了:测试
在安装新Apk前,先作好dex的解压和优化,获得dex压缩包(.zip)列表和dexopt后的odex文件(.dex)列表。
把dex/odex文件保存到一个内部存储路径PATH_A,同时使用SP记录新版本Apk的CRC、dex数量,以及解压出来的每个dex的CRC值。
安装新版本Apk后,启动时在执行MultiDex前,把PATH_A路径上的缓存文件移动(rename)到MultiDex的缓存路径PATH_B上,同时保存当前Apk的CRC、修改时间以及dex数量到MultiDex对应的SP配置上。
执行原有MultiDex逻辑,让MultiDex觉得以前已经作过解压和优化dex工做,从而绕开第一次MultiDex时候的耗时。
第一次成功启动新Apk后,对dex进行校验工做,若是校验失败则清除dex缓存,强制让App在下一次启动的时候再执行一遍MultiDex。
注:
流程图的绿色部分为文件锁(FileLock)操做,主要是为了多进程同步。
红色部分为耗时的操做。
Dex路径为MultiDex过程当中用于存储解压出来的dex文件的路径(/data/data/<package>/code_cache)。
PreDex路径为存储预解压获得的缓存文件的内部路径(/data/data/<package>/code_cache_pre)。
MultiDex从Apk包解压出来的dex文件会被压缩成Zip包(.zip),而执行dexopt操做后生成的odex文件文件名为.dex,这两个容易搞混。
这个环节必须在升级Apk前,由旧版本的Apk进行,也就是要求App拥有自主更新的逻辑。
从旧版的Apk覆盖安装新的Apk后,第一次运行App时MultiDex主要的耗时过程。这时须要把在旧版本Apk预安装获得的dex缓存文件移动到MultiDex使用的存储路径上。
原有的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的缓存文件,禁用PreMultiDex功能,而且强制让App在下一次启动的时候再执行一遍MultiDex。
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的加速效果。
MultiDex其实并无刻意保留dexopt后的缓存,若是只保留dex文件,而不保留odex文件,那么下一次启动执行MultiDex的时候,不须要从新解压dex文件,可是依然须要dexopt并产生odex文件,这个过程大概会占用MultiDex总耗时的通常左右。若是odex文件存在,可是已经损坏了,或者是一个非法的odex文件,依然会触发dexopt工做。也就是说,加载dex文件并建立DexFile对象的时候,Android系统会判断odex的缓存,以及缓存文件是否正确,具体过程在dalvik_system_DexFile.cpp里实现,有兴趣的同窗能够找找dex文件结构分析的文章,这里就不挖坑了。
其实,若是dex文件和Apk的版本对不上的话,通常在启动App的时候就会出现ClassNotFound异常而致使App崩溃,接着再次启动因为没有从新MultiDex也会继续崩溃。而崩溃的时候,可能App崩溃上报系统还没来得及初始化,因此没有办法发现崩溃的问题。
为了防止这种问题,能够开发一个恢复模式或者安全模式的功能,当App出现连续的崩溃的时候,会进入恢复模式的状态,清空一些可能致使异常的数据(好比PreMultiDex的缓存),这样就能避免App由于连续崩溃而不能使用。至于怎么实现恢复,这已是另外一个领域的功能了,这里再也不展开。
参考连接:
Google Multidex
著做信息:
本文章出自 Kaede 的博客,原创文章若无特别说明,均遵循 CC BY-NC 4.0 知识共享许可协议4.0(署名-非商用-相同方式共享),能够随意摘抄转载,但必须标明署名及原地址。