Kaede Akatsuki

中二病也要开发 Android

通过预安装给 MultiDex 加速

在 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) 方法里进行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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 包是否被修改过。

1
2
3
4
5
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)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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 文件失败,需要重新解压。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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//code_cache)。
  4. PreDex 路径为存储预解压得到的缓存文件的内部路径(/data/data//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