在Android 5.0(API 21)之前,系统不支持加载多个dex文件,其中一个dex文件中method数被short类型限制在65536个,随着业务逻辑的增多,必然导致构建时产生多个dex包,那么如何加载其他dex文件就成为一个重要的问题。为了填补这个漏洞google引入了MultiDex工具来解决在Android5.0之前系统加载secondary dex的问题。
引言
问题1:Android版都已经更新到9.0了,为何还去研究5.0版本以前的技术?
- 面向OTT开发,Android版本普遍都比较低。
- MultiDex加载机制与现在流行的动态化技术(热修复、插件化等)原理基本一致。
- 了解Dex加载原理是做Dex相关优化工作的基础。
问题2:类似的文章都烂大街了,还能写出个花来?
疑问
在开始讲之前,有个问题你可能也思考过。
Android5.0平台以下应用安装完成后首次启动时间较长,MultiDex.install()方法就是罪魁祸首,那它内部到底耗时在哪里呢?带着这个疑问我们开始分析源码,如果你只想了解整体流程请直接看文末总结整体流程。
入口
核心方法MultiDex.install(),通常会在application的attachBaseContext方法中调用。当然实际上只要在secondary-dex中的类使用前调用就可以。
以下源码基于multidex-1.0.2版本。
public static void install(Context context) {
if(IS_VM_MULTIDEX_CAPABLE) {
...
} 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);
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() + ").");
}
}
}
IS_VM_MULTIDEX_CAPABLE 属性表示虚拟机是否支持多包,内部的判断标准是虚拟机的版本号是否大于等于2.1。Android4.4平台使用的dalvik虚拟机版本为1.6.0,所以不支持多包加载,需要使用multidex加载其他dex包。
主包在应用安装时就已经提取并完成dex优化工作,产出的目录为默认路径/data/dalvik-cache/{apk文件名}@classes.dex
进入核心方法doInstallation
private static final Set installedApk = new HashSet();
private static void doInstallation(Context mainContext, File sourceApk, File dataDir, String secondaryFolderName, String prefsKeyPrefix) {
Set var5 = installedApk;
synchronized(installedApk) {
if(!installedApk.contains(sourceApk)) {
installedApk.add(sourceApk);
ClassLoader loader;
try {
loader = mainContext.getClassLoader();
} catch (RuntimeException var11) {
return;
}
if(loader == null) {
...
} else {
try {
clearOldDexDir(mainContext);
} catch (Throwable var10) {
...
}
File dexDir = getDexDir(mainContext, dataDir, secondaryFolderName);
List extends File> files = MultiDexExtractor.load(mainContext, sourceApk, dexDir, prefsKeyPrefix, false);
installSecondaryDexes(loader, dexDir, files);
}
}
}
}
可以看到installedApk的作用是一个是多线程的锁对象,另一个是单进程防止多次调用install方法带来开销。
这个loader在application内部为PathClassLoader,作用是加载已安装过的apk的class,与之对应的是DexClassLoader它可加载外部的jar/dex/apk中的class。
clearOldDexDir作用是清除/data/data/com.bftv.fui.video/files/secondary-dexes目录及其子文件。猜测是Android历史版本曾经以这个目录为其他dex的输出目录。
接下来是本文的重点内容
- getDexDir 准备dex目录
- MultiDexExtractor.load提取apk文件中的次要dex
- installSecondaryDexes 加载安装次要包
因此可以将MultiDex流程分为三个步骤——准备、提取、安装。
准备
来看getDexDir源码
private static File getDexDir(Context context, File dataDir, String secondaryFolderName) throws IOException {
File cache = new File(dataDir, "code_cache");
try {
mkdirChecked(cache);
} catch (IOException var5) {
cache = new File(context.getFilesDir(), "code_cache");
mkdirChecked(cache);
}
File dexDir = new File(cache, secondaryFolderName);
mkdirChecked(dexDir);
return dexDir;
}
这里的参数secondaryFolderName固定为secondary-dexes,此步创建了data/data/{packageName}/code_cache/secondary-dexes/目录。可以想见后续提取出来的dex文件会存放在此目录。
提取
来看MultiDexExtractor.load()方法
static List extends File> load(Context context, File sourceApk, File dexDir, String prefsKeyPrefix, boolean forceReload) throws IOException {
long currentCrc = getZipCrc(sourceApk);
File lockFile = new File(dexDir, "MultiDex.lock");
RandomAccessFile lockRaf = new RandomAccessFile(lockFile, "rw");
FileChannel lockChannel = null;
FileLock cacheLock = null;
List files;
try {
lockChannel = lockRaf.getChannel();
cacheLock = lockChannel.lock();
if(!forceReload && !isModified(context, sourceApk, currentCrc, prefsKeyPrefix)) {
try {
files = loadExistingExtractions(context, sourceApk, dexDir, prefsKeyPrefix);
} catch (IOException var21) {
files = performExtractions(sourceApk, dexDir);
putStoredApkInfo(context, prefsKeyPrefix, getTimeStamp(sourceApk), currentCrc, files);
}
} else {
files = performExtractions(sourceApk, dexDir);
putStoredApkInfo(context, prefsKeyPrefix, getTimeStamp(sourceApk), currentCrc, files);
}
} finally {
}
...
}
这里涉及到crc校验,本质上一种数据的完整性校验,如果数据被修改则校验不通过。有兴趣的同学戳这里crc简单介绍。
随后创建一个MultiDex.lock文件,FileChannel是Java NIO中重要的类库,使用NIO有利于提高IO效率。
我们重点看下判断条件
!forceReload && !isModified(context, sourceApk, currentCrc, prefsKeyPrefix)
调用load方法传入forceReload为false,首次执行时isModified返回true,表示是一个新的apk;当应用升级或恶意篡改apk文件同样会返回true,原理也是基于crc校验。当应用分包加载完成后下次进程启动会返回false。
我们先来看看返回true时调用performExtractions方法和putStoredApkInfo方法。putStoredApkInfo方法将提取出来的dex的数量及各个dex的crc校验值写入名为multidex.version的SharedPreferences中。
它不是重点,我们重点看一下performExtractions方法。
private static List performExtractions(File sourceApk, File dexDir) throws IOException {
String extractedFilePrefix = sourceApk.getName() + ".classes";
prepareDexDir(dexDir, extractedFilePrefix);
List 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);
...
}
...
++secondaryNumber;
}
} finally {
...
}
...
return files;
}
重点看下返回值,它是一个List列表,MultiDexExtractor.ExtractedDex是对File类的简单封装,当做File处理即可,也就是最终返回了一个提取到的secondary-dexes文件列表。
prepareDexDir实际也是一步准备工作,它会缀遍历应用data目录/code_cache/secondary-dexes目录下所有不以apk文件的名称+".classes"为前缀的文件给并删除。在应用升级后此步骤会有实际作用。
随后将apk文件封装为一个ZipFile,调用extract执行实际的提取工作。
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("tmp-" + 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();
}
...
if(!tmp.renameTo(extractTo)) {
throw new IOException("Failed to rename \"" + tmp.getAbsolutePath() + "\" to \"" + extractTo.getAbsolutePath() + "\"");
}
} finally {
closeQuietly(in);
tmp.delete();
}
}
传入本方法的四个参数可以理解为:
- zipFile .apk文件封装而成的ZipFile对象
- dexFile apk文件中的classes2.dex等次要dex文件
- extractedFile {packageName}-1.apk.classes2.zip
- extractedFilePrefix {packageName}-1.apk.classes
大概流程就是创建一个临时zip文件并将apk包中的一个次要dex写入这个zip文件,最后重命名为
格式为{packageName}-1.apk.classes2.zip的文件。写入过程为IO操作,因此是分包过程中的一个耗时操作。循环执行完extract方法也就依次将apk中的各个次要dex文件写入到了secondary-dexes目录下的各个zip文件中(相当于压缩操作)。
那我们来看一下这个目录是不是已经写入了文件。
写是写了,可是其他的.dex文件又是什么呢?我们先搁置继续看看判断条件:
!forceReload && !isModified(context, sourceApk, currentCrc, prefsKeyPrefix)
如果未检测到修改则会执行loadExistingExtractions方法。
private static List loadExistingExtractions(Context context, File sourceApk, File dexDir, String prefsKeyPrefix) throws IOException {
String extractedFilePrefix = sourceApk.getName() + ".classes";
SharedPreferences multiDexPreferences = getMultiDexPreferences(context);
int totalDexNumber = multiDexPreferences.getInt(prefsKeyPrefix + "dex.number", 1);
List files = new ArrayList(totalDexNumber - 1);
for(int secondaryNumber = 2; secondaryNumber <= totalDexNumber; ++secondaryNumber) {
String fileName = extractedFilePrefix + secondaryNumber + ".zip";
MultiDexExtractor.ExtractedDex extractedFile = new MultiDexExtractor.ExtractedDex(dexDir, fileName);
//crc校验
...
files.add(extractedFile);
}
return files;
}
很简单,既然已经有个dex的压缩文件,直接封装到List中即可。
至此,提取过程完成。
安装
来看最后一步的installSecondaryDexes方法。
private static void installSecondaryDexes(ClassLoader loader, File dexDir, List extends File> files) throws 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版本做了不同的处理,以V19也就是Android4.4为例看一下install方法。
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 suppressedExceptions = new ArrayList();
MultiDex.expandFieldArray(dexPathList, "dexElements", makeDexElements(dexPathList, new ArrayList(additionalClassPathEntries), optimizedDirectory, suppressedExceptions));
//异常收集
...
}
来看外部核心方法MultiDex.expandFieldArray(),这里用到了反射,总得来说过程如下:
- 获取PathClassLoader的 pathList成员变量类型为DexPathList
- 获取pathList对象的dexElements属性,类型为Element数组
- 将secondary-dex封装成Element数组,并把其中元素逐个添加到原有dexElements数组后面。
为什么要这么做呢?这涉及到Android系统中的类加载机制,它基于Java类加载机制的双亲委派模型,同时也热修复框架的基础。这里不做赘述,一篇好文送上Android动态加载之ClassLoader详解。
为了验证数组已经添加成功,我们在MultiDex.install方法调用前后分别打印PathClassLoader对象得到如下log。
test_tag: install before classloader:dalvik.system.PathClassLoader[DexPathList[[zip file "/data/app/com.bftv.fui.video-1.apk"],nativeLibraryDirectories=[/data/app-lib/com.bftv.fui.video-1, /vendor/lib, /system/lib]]]
test_tag: install after classloader:dalvik.system.PathClassLoader[DexPathList[[zip file "/data/app/com.bftv.fui.video-1.apk", zip file "/data/data/com.bftv.fui.video/code_cache/secondary-dexes/com.bftv.fui.video-1.apk.classes2.zip", zip file "/data/data/com.bftv.fui.video/code_cache/secondary-dexes/com.bftv.fui.video-1.apk.classes3.zip", zip file "/data/data/com.bftv.fui.video/code_cache/secondary-dexes/com.bftv.fui.video-1.apk.classes4.zip"],nativeLibraryDirectories=[/data/app-lib/com.bftv.fui.video-1, /vendor/lib, /system/lib]]]
那么这个Elements数组又是怎么创建的呢?这要继续去看makeDexElements方法。
private static Object[] makeDexElements(
Object dexPathList, ArrayList files, File optimizedDirectory,
ArrayList suppressedExceptions)
throws IllegalAccessException, InvocationTargetException,
NoSuchMethodException {
Method makeDexElements =
findMethod(dexPathList, "makeDexElements", ArrayList.class, File.class,
ArrayList.class);
return (Object[]) makeDexElements.invoke(dexPathList, files, optimizedDirectory,
suppressedExceptions);
}
仍然是反射,我们看DexPathList的makeDexElements方法。
private static Element[] makeDexElements(ArrayList files,
File optimizedDirectory) {
ArrayList elements = new ArrayList();
for (File file : files) {
ZipFile zip = null;
DexFile dex = null;
String name = file.getName();
if (name.endsWith(DEX_SUFFIX)) {
// Raw dex file (not inside a zip/jar).
try {
dex = loadDexFile(file, optimizedDirectory);
} catch (IOException ex) {
System.logE("Unable to load dex file: " + file, ex);
}
} else if (name.endsWith(APK_SUFFIX) || name.endsWith(JAR_SUFFIX)
|| name.endsWith(ZIP_SUFFIX)) {
try {
zip = new ZipFile(file);
} catch (IOException ex) {
...
}
try {
dex = loadDexFile(file, optimizedDirectory);
} catch (IOException ignored) {
...
}
} else {
System.logW("Unknown file type for: " + file);
}
if ((zip != null) || (dex != null)) {
elements.add(new Element(file, zip, dex));
}
}
return elements.toArray(new Element[elements.size()]);
}
这里需要注意一下传入参数
- optimizedDirectory 表示优化后的dex文件存放目录。
- files 表示被压缩的dex文件数组。
实际执行就是两步,首先调用loadDexFile方法,然后将返回的dex组装成Elements数组。
来看loadDexFile方法
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);
}
}
由于optimizedDirectory不为空因此执行optimizedPathFor方法
private static String optimizedPathFor(File path,
File optimizedDirectory) {
String fileName = path.getName();
if (!fileName.endsWith(DEX_SUFFIX)) {
int lastDot = fileName.lastIndexOf(".");
if (lastDot < 0) {
fileName += DEX_SUFFIX;
} else {
StringBuilder sb = new StringBuilder(lastDot + 4);
sb.append(fileName, 0, lastDot);
sb.append(DEX_SUFFIX);
fileName = sb.toString();
}
}
File result = new File(optimizedDirectory, fileName);
return result.getPath();
}
其实就是根据传入的.zip文件名,生成对应的.dex文件。
来看DexFile.loadDex方法
static public DexFile loadDex(String sourcePathName, String outputPathName,
int flags) throws IOException {
return new DexFile(sourcePathName, outputPathName, flags);
}
private DexFile(String sourceName, String outputName, int flags) throws IOException {
mCookie = openDexFile(sourceName, outputName, flags);
mFileName = sourceName;
guard.open("close");
}
native private static int openDexFile(String sourceName, String outputName,
int flags) throws IOException;
最终是调用了本地方法openDexFile,这里不再继续分析,有兴趣的同学可以参考DexClassLoader和PathClassLoader加载Dex流程。我们直接说结论,它主要是对dex文件进行了优化操作,然后将优化数据写入.dex文件中。这也就是为什么在secondary-dexes目录同是会出现一个.zip文件和一个.dex文件。
总结整体流程
- 检查系统是否支持多包(虚拟机版本>=2.1等)
- 调用doInstallation执行加载dex核心逻辑。
- 用一个Set
记录一个apk是否执行过install,这样保证同一个进程多次调用install方法不会重复执行。 - 调用clearOldDexDir清除应用files目录的子目录secondary-dexes。
- 调用getDexDir方法创建用于存放原始dex文件(zip格式)和优化后的dex文件的目录data/data/{packageName}/code_cache/secondary-dexes。
- 调用MultiDexExtractor.load方法提取dex文件封装到一个List
集合。 - 首次提取会调用performExtractions方法从/data/app/{packageName}-{num}.apk文件中提取dex文件,并将dex文件压缩(.zip格式)拷贝到secondary-dexes目录。拷贝前通过prepareDexDir方法删除旧版本的dex文件。
- 后续提取则会调用loadExistingExtractions方法直接在secondary-dexes目录查找dex文件。
- 调用installSecondaryDexes方法加载dex。
- 通过反射DexPathList的makeDexElements方法执行dex优化并返回Element数组。
- 通过expandFieldArray方法将上一步提取Element数组添加到DexPathList的成员变量pathList数组后面。
回答疑问
MultiDex内部哪个操作最耗时?通过上文分析,可以得到下面的结论。
- dex文件的提取拷贝以及优化完成的dex文件写入操作,为了优化这个过程对dex文件进行了压缩操作、拷贝过程使用了java NIO。
- 本地方法openDexFile,dexopt过程耗时。
参考文章
- 官方文档
- android Dex文件的加载
- openDexFileNative源码解析
- BaseDexClassLoader/DexPathList源码