提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档
前言
App 在迭代过程中功能越来越丰富,代码量越来越多就会遇到一个构建错误表示方法数超出 65536 出现这个问题的原因是 dex 文件中对方法数的索引是 short 类型。在计算机科学领域,术语 K 表示1024(或2^10)。因为 65,536 等于 64 X 1024,所以这个限制称为 ‘64K引用限制’ 为了解决这个限制 Google 推出了 MultiDex
一、启用 MultiDex
Android 5.0 和之后的版本
Android 5.0(API 级别 21)及更高版本使用 Android Runtime (ART),它本身支持从 APK 文件加载多个 DEX 文件。ART 在应用安装时执行预编译,这会扫描查找 classesN.dex 文件,并将它们编译成单个 .oat 文件以供 Android 设备执行,所以如果项目的 minSdkVersion 大于等于 21 则不需要 MultiDex 并且系统会默认启用 MultiDex
Android 5.0 之前的版本
defaultConfig {
multiDexEnabled true
}
dependencies {
implementation 'androidx.multidex:multidex:2.0.1'
}
通过以上配置引入和开启 MultiDex 然后根据是否扩展 Application 配置 android:name
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.myapp">
<application
android:name="androidx.multidex.MultiDexApplication" >
...
</application>
</manifest>
如果扩展了 Application 则把 Application 更改为 MultiDexApplication 更多的做法是在 Application 的 attachBaseContext 方法中调用 MultiDex.install(this)
class MyApplication : SomeOtherApplication() {
override fun attachBaseContext(base: Context) {
super.attachBaseContext(base)
MultiDex.install(this)
}
}
之后再构建应用时 Android 构建工具会根据需要构建多个 dex 文件并且构建系统会将所有 DEX 文件都打包到 APK 中
二、MultiDex 的限制
- 在冷启动时因为需要安装 dex 文件,如果 dex 文件过大处理时间过长容易引发 ANR
- linearAlloc 限制,即使方法数没有超过 65536 能正常编译打包成 apk 在安装的时候也有可能会提示 INSTALL_FAILED_DEXOPT 而导致安装失败,这个一般就是因为 linearAlloc 的限制导致的。主要是因为 Dexopt 使用 LinearAlloc 来存储应用的方法信息。Dalvik LinearAlloc 是一个固定大小的缓冲区。在 Android 版本的历史上,LinearAlloc 分别经历了4M/5M/8M/16M限制。Android 2.2和2.3的缓冲区只有5MB,Android 4.x提高到了8MB 或16MB。当方法数量过多导致超出缓冲区大小时,也会造成 dexopt 崩溃.
针对问题一可通过在子线程或者其他进程初始化来解决 针对问题二可通过 --set-max-idx-number= 参数控制每一个dex 的最大方法个数,写小一点可以产生多个 dex 为了避免 2.3 机型 runtime 的 linearAlloclimit 最好保持每一个 dex 体积小于 4M 比如 value <= 48000 具体如下:
android.applicationVariants.all {
variant ->
dex.doFirst{
dex->
if (dex.additionalParameters == null) {
dex.additionalParameters = []
}
dex.additionalParameters += '--set-max-idx-number=48000'
}
}
代码缩减可以减少甚至有可能避开这些问题
三、在主 dex 文件中包含必要的类
如果 App 启动过程中需要的类没有包含在主 dex 文件中会发生 java.lang.NoClassDefFoundError 错误这个时候需要使用 multiDexKeepFile 或 multiDexKeepProguard 属性声明这些其他类,手动将这些类包含在主 dex 文件中
multiDexKeepFile 创建 multidex-config.txt 文件内容如下:
com/example/MyClass.class
com/example/MyOtherClass.class
然后引入该文件
android {
buildTypes {
release {
multiDexKeepFile file('multidex-config.txt')
...
}
}
}
multiDexKeepProguard 文件使用与 Proguard 相同的格式,并且支持全部 Proguard 语法,创建一个名为 multidex-config.pro 的文件内容如下:
-keep class com.example.MyClass
-keep class com.example.MyClassToo
-keep class com.example.** { *; } // All classes in the com.example package
然后应用该文件
android {
buildTypes {
release {
multiDexKeepProguard file('multidex-config.pro')
...
}
}
}
四、源码分析
Dalvik 虚拟机将 App 限制为每个 APK 只能使用一个 classes.dex 字节码文件,Android 4.4 及以下采用的是 Dalvik 虚拟机,在通常情况下,Dalvik 虚拟机只能执行做过 OPT 优化的 DEX 文件,也就是我们常说的 ODEX 文件。一个 APK 在安装的时候,其中的 classes.dex 会自动做 ODEX 优化,并在启动的时候由系统默认直接加载到 APP 的 PathClassLoader 里面,因此classes.dex 中的类肯定能直接访问,不需要我们操心。除它之外的 DEX 文件,也就是classes2.dex、classes3.dex、classes4.dex 等 DEX 文件(次 dex 文件),这些文件都需要靠我们自己进行 ODEX 优化,并加载到 ClassLoader 里,才能正常使用其中的类。否则在访问这些类的时候,就会抛出 ClassNotFound 异常从而引起崩溃,因此 Android 官方推出了 MultiDex 只需要在 APP 程序执行最早的入口,也就是Application.attachBaseContext 里面直接调 MultiDex.install 它会解开 APK 包,对第二个以后的 DEX 文件做 ODEX 优化并加载。这样,带有多个 DEX 文件的 APK 就可以顺利执行下去了。这个操作会在 APP 安装或者更新后首次冷启动的时候发生,正是由于这个过程耗时漫长,才导致了我们上面提到的 ANR 问题。下面开始分析源码:
public static void install(Context context) {
Log.i(TAG, "Installing application");
if (IS_VM_MULTIDEX_CAPABLE) {
Log.i(TAG, "VM has multidex support, MultiDex support library is disabled.");
return;
}
if (Build.VERSION.SDK_INT < MIN_SDK_VERSION) {
throw new RuntimeException("MultiDex installation failed. SDK " + Build.VERSION.SDK_INT
+ " is unsupported. Min SDK version is " + MIN_SDK_VERSION + ".");
}
try {
ApplicationInfo applicationInfo = getApplicationInfo(context);
if (applicationInfo == null) {
Log.i(TAG, "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),
CODE_CACHE_SECONDARY_FOLDER_NAME,
NO_KEY_PREFIX,
true);
} catch (Exception e) {
Log.e(TAG, "MultiDex installation failure", e);
throw new RuntimeException("MultiDex installation failed (" + e.getMessage() + ").");
}
Log.i(TAG, "install done");
}
做了一些校验工作调用 doInstallation 方法
private static void doInstallation(Context mainContext, File sourceApk, File dataDir,
String secondaryFolderName, String prefsKeyPrefix,
boolean reinstallOnPatchRecoverableException) throws IOException,
IllegalArgumentException, IllegalAccessException, NoSuchFieldException,
InvocationTargetException, NoSuchMethodException, SecurityException,
ClassNotFoundException, InstantiationException {
synchronized (installedApk) {
if (installedApk.contains(sourceApk)) {
return;
}
installedApk.add(sourceApk);
if (Build.VERSION.SDK_INT > MAX_SUPPORTED_SDK_VERSION) {
Log.w(TAG, "MultiDex is not guaranteed to work in SDK version "
+ Build.VERSION.SDK_INT + ": SDK version higher than "
+ MAX_SUPPORTED_SDK_VERSION + " 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 loader = getDexClassloader(mainContext);
if (loader == null) {
return;
}
try {
clearOldDexDir(mainContext);
} catch (Throwable t) {
Log.w(TAG, "Something went wrong when trying to clear old MultiDex extraction, "
+ "continuing without cleaning.", t);
}
File dexDir = getDexDir(mainContext, dataDir, secondaryFolderName);
MultiDexExtractor extractor = new MultiDexExtractor(sourceApk, dexDir);
IOException closeException = null;
try {
List<? extends File> files =
extractor.load(mainContext, prefsKeyPrefix, false);
try {
installSecondaryDexes(loader, dexDir, files);
} catch (IOException e) {
if (!reinstallOnPatchRecoverableException) {
throw e;
}
Log.w(TAG, "Failed to install extracted secondary dex files, retrying with "
+ "forced extraction", e);
files = extractor.load(mainContext, prefsKeyPrefix, true);
installSecondaryDexes(loader, dexDir, files);
}
} finally {
try {
extractor.close();
} catch (IOException e) {
closeException = e;
}
}
if (closeException != null) {
throw closeException;
}
}
}
先看一下提取 dex 文件的过程
List<? extends File> load(Context context, String prefsKeyPrefix, boolean forceReload)
throws IOException {
Log.i(TAG, "MultiDexExtractor.load(" + sourceApk.getPath() + ", " + forceReload + ", " +
prefsKeyPrefix + ")");
if (!cacheLock.isValid()) {
throw new IllegalStateException("MultiDexExtractor was closed");
}
List<ExtractedDex> files;
if (!forceReload && !isModified(context, sourceApk, sourceCrc, prefsKeyPrefix)) {
try {
files = loadExistingExtractions(context, prefsKeyPrefix);
} catch (IOException ioe) {
Log.w(TAG, "Failed to reload existing extracted secondary dex files,"
+ " falling back to fresh extraction", ioe);
files = performExtractions();
putStoredApkInfo(context, prefsKeyPrefix, getTimeStamp(sourceApk), sourceCrc,
files);
}
} else {
if (forceReload) {
Log.i(TAG, "Forced extraction must be performed.");
} else {
Log.i(TAG, "Detected that extraction must be performed.");
}
files = performExtractions();
putStoredApkInfo(context, prefsKeyPrefix, getTimeStamp(sourceApk), sourceCrc,
files);
}
Log.i(TAG, "load found " + files.size() + " secondary dex files");
return files;
}
先看一下第一次提取的过程
private List<ExtractedDex> performExtractions() throws IOException {
final String extractedFilePrefix = sourceApk.getName() + EXTRACTED_NAME_EXT;
clearDexDir();
List<ExtractedDex> files = new ArrayList<ExtractedDex>();
final ZipFile apk = new ZipFile(sourceApk);
try {
int secondaryNumber = 2;
ZipEntry dexFile = apk.getEntry(DEX_PREFIX + secondaryNumber + DEX_SUFFIX);
while (dexFile != null) {
String fileName = extractedFilePrefix + secondaryNumber + EXTRACTED_SUFFIX;
ExtractedDex extractedFile = new ExtractedDex(dexDir, fileName);
files.add(extractedFile);
Log.i(TAG, "Extraction is needed for file " + extractedFile);
int numAttempts = 0;
boolean isExtractionSuccessful = false;
while (numAttempts < MAX_EXTRACT_ATTEMPTS && !isExtractionSuccessful) {
numAttempts++;
extract(apk, dexFile, extractedFile, extractedFilePrefix);
try {
extractedFile.crc = getZipCrc(extractedFile);
isExtractionSuccessful = true;
} catch (IOException e) {
isExtractionSuccessful = false;
Log.w(TAG, "Failed to read crc from " + extractedFile.getAbsolutePath(), e);
}
Log.i(TAG, "Extraction " + (isExtractionSuccessful ? "succeeded" : "failed")
+ " '" + extractedFile.getAbsolutePath() + "': length "
+ extractedFile.length() + " - crc: " + extractedFile.crc);
if (!isExtractionSuccessful) {
extractedFile.delete();
if (extractedFile.exists()) {
Log.w(TAG, "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++;
dexFile = apk.getEntry(DEX_PREFIX + secondaryNumber + DEX_SUFFIX);
}
} finally {
try {
apk.close();
} catch (IOException e) {
Log.w(TAG, "Failed to close resource", e);
}
}
return files;
}
看一下真正的做提取工作的方法
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, EXTRACTED_SUFFIX,
extractTo.getParentFile());
Log.i(TAG, "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[BUFFER_SIZE];
int length = in.read(buffer);
while (length != -1) {
out.write(buffer, 0, length);
length = in.read(buffer);
}
out.closeEntry();
} finally {
out.close();
}
if (!tmp.setReadOnly()) {
throw new IOException("Failed to mark readonly \"" + tmp.getAbsolutePath() +
"\" (tmp of \"" + extractTo.getAbsolutePath() + "\")");
}
Log.i(TAG, "Renaming to " + extractTo.getPath());
if (!tmp.renameTo(extractTo)) {
throw new IOException("Failed to rename \"" + tmp.getAbsolutePath() +
"\" to \"" + extractTo.getAbsolutePath() + "\"");
}
} finally {
closeQuietly(in);
tmp.delete();
}
}
就是将 dex 文件压缩为 zip 文件,将所有的 dex 文件都压缩为 zip 文件后返回保存信息到 multidex.version 文件中(sp)下一次直接加载存在的 zip 文件
private List<ExtractedDex> loadExistingExtractions(
Context context,
String prefsKeyPrefix)
throws IOException {
Log.i(TAG, "loading existing secondary dex files");
final String extractedFilePrefix = sourceApk.getName() + EXTRACTED_NAME_EXT;
SharedPreferences multiDexPreferences = getMultiDexPreferences(context);
int totalDexNumber = multiDexPreferences.getInt(prefsKeyPrefix + KEY_DEX_NUMBER, 1);
final List<ExtractedDex> files = new ArrayList<ExtractedDex>(totalDexNumber - 1);
for (int secondaryNumber = 2; secondaryNumber <= totalDexNumber; secondaryNumber++) {
String fileName = extractedFilePrefix + secondaryNumber + EXTRACTED_SUFFIX;
ExtractedDex extractedFile = new ExtractedDex(dexDir, fileName);
if (extractedFile.isFile()) {
extractedFile.crc = getZipCrc(extractedFile);
long expectedCrc = multiDexPreferences.getLong(
prefsKeyPrefix + KEY_DEX_CRC + secondaryNumber, NO_VALUE);
long expectedModTime = multiDexPreferences.getLong(
prefsKeyPrefix + KEY_DEX_TIME + secondaryNumber, NO_VALUE);
long lastModified = extractedFile.lastModified();
if ((expectedModTime != lastModified)
|| (expectedCrc != extractedFile.crc)) {
throw new IOException("Invalid extracted dex: " + extractedFile +
" (key \"" + prefsKeyPrefix + "\"), expected modification time: "
+ expectedModTime + ", modification time: "
+ lastModified + ", expected crc: "
+ expectedCrc + ", file crc: " + extractedFile.crc);
}
files.add(extractedFile);
} else {
throw new IOException("Missing extracted secondary dex file '" +
extractedFile.getPath() + "'");
}
}
return files;
}
现在两种路径都拿到了 zip 文件列表之后就是将这些文件加入到 classloader 了
private static void installSecondaryDexes(ClassLoader loader, File dexDir,
List<? extends File> files)
throws IllegalArgumentException, IllegalAccessException, NoSuchFieldException,
InvocationTargetException, NoSuchMethodException, IOException, SecurityException,
ClassNotFoundException, InstantiationException {
if (!files.isEmpty()) {
if (Build.VERSION.SDK_INT >= 19) {
V19.install(loader, files, dexDir);
} else if (Build.VERSION.SDK_INT >= 14) {
V14.install(loader, files);
} else {
V4.install(loader, files);
}
}
}
区分了版本这里只看一下 Android API 19 的处理
private static final class V19 {
static void install(ClassLoader loader,
List<? extends File> additionalClassPathEntries,
File optimizedDirectory)
throws IllegalArgumentException, IllegalAccessException,
NoSuchFieldException, InvocationTargetException, NoSuchMethodException,
IOException {
Field pathListField = findField(loader, "pathList");
Object dexPathList = pathListField.get(loader);
ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>();
expandFieldArray(dexPathList, "dexElements", makeDexElements(dexPathList,
new ArrayList<File>(additionalClassPathEntries), optimizedDirectory,
suppressedExceptions));
if (suppressedExceptions.size() > 0) {
for (IOException e : suppressedExceptions) {
Log.w(TAG, "Exception in makeDexElement", e);
}
Field suppressedExceptionsField =
findField(dexPathList, "dexElementsSuppressedExceptions");
IOException[] dexElementsSuppressedExceptions =
(IOException[]) suppressedExceptionsField.get(dexPathList);
if (dexElementsSuppressedExceptions == null) {
dexElementsSuppressedExceptions =
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);
IOException exception = new IOException("I/O exception during makeDexElement");
exception.initCause(suppressedExceptions.get(0));
throw exception;
}
}
private static Object[] makeDexElements(
Object dexPathList, ArrayList<File> files, File optimizedDirectory,
ArrayList<IOException> suppressedExceptions)
throws IllegalAccessException, InvocationTargetException,
NoSuchMethodException {
Method makeDexElements =
findMethod(dexPathList, "makeDexElements", ArrayList.class, File.class,
ArrayList.class);
return (Object[]) makeDexElements.invoke(dexPathList, files, optimizedDirectory,
suppressedExceptions);
}
}
在 App 安装过程中会通过 dexopt 工具(函数)对 classes.dex 做 odex 优化工作所以不需要对主 dex 文件做处理但是其他次 dex 文件的 odex 优化工作就需要 mutlidex 来做了,具体的工作在 dalvik.system.DexPathList#makeDexElements 方法里
private static Element[] makeDexElements(List<File> files, File optimizedDirectory,
List<IOException> suppressedExceptions, ClassLoader loader) {
return makeDexElements(files, optimizedDirectory, suppressedExceptions, loader, false);
}
private static Element[] makeDexElements(List<File> files, File optimizedDirectory,
List<IOException> suppressedExceptions, ClassLoader loader, boolean isTrusted) {
Element[] elements = new Element[files.size()];
int elementsPos = 0;
for (File file : files) {
if (file.isDirectory()) {
elements[elementsPos++] = new Element(file);
} else if (file.isFile()) {
String name = file.getName();
DexFile dex = null;
if (name.endsWith(DEX_SUFFIX)) {
try {
dex = loadDexFile(file, optimizedDirectory, loader, elements);
if (dex != null) {
elements[elementsPos++] = new Element(dex, null);
}
} catch (IOException suppressed) {
System.logE("Unable to load dex file: " + file, suppressed);
suppressedExceptions.add(suppressed);
}
} else {
try {
dex = loadDexFile(file, optimizedDirectory, loader, elements);
} catch (IOException suppressed) {
suppressedExceptions.add(suppressed);
}
if (dex == null) {
elements[elementsPos++] = new Element(file);
} else {
elements[elementsPos++] = new Element(dex, file);
}
}
if (dex != null && isTrusted) {
dex.setTrusted();
}
} else {
System.logW("ClassLoader referenced unknown path: " + file);
}
}
if (elementsPos != elements.length) {
elements = Arrays.copyOf(elements, elementsPos);
}
return elements;
}
上面是 9.0 的源码(查看老版本是没有最后一个 ClassLoader 参数的,但后续流程都是差不多的)
private static DexFile loadDexFile(File file, File optimizedDirectory, ClassLoader loader,
Element[] elements)
throws IOException {
if (optimizedDirectory == null) {
return new DexFile(file, loader, elements);
} else {
String optimizedPath = optimizedPathFor(file, optimizedDirectory);
return DexFile.loadDex(file.getPath(), optimizedPath, 0, loader, elements);
}
}
不管 optimizedDirectory 参数是否为 null 最后都会调用 DexFile 的构造方法,在构造方法里调用 openDexFile 方法再调用 openDexFileNative 方法在这个方法里做真正的 odex 的优化工作
最后一点为什么把 odex 文件加入到 dalvik.system.DexPathList#dexElements 总就可以了涉及到了 Android 的类加载
protected Class<?> findClass(String name) throws ClassNotFoundException {
List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
Class c = pathList.findClass(name, suppressedExceptions);
if (c == null) {
ClassNotFoundException cnfe = new ClassNotFoundException(
"Didn't find class \"" + name + "\" on path: " + pathList);
for (Throwable t : suppressedExceptions) {
cnfe.addSuppressed(t);
}
throw cnfe;
}
return c;
}
PathClassLoader 继承自 BaseDexClassLoader
上面是 BaseDexClassLoader 的加载 class 的方法可以看到直接调用了 dalvik.system.DexPathList#findClass 看一下这个方法
public Class<?> findClass(String name, List<Throwable> suppressed) {
for (Element element : dexElements) {
Class<?> clazz = element.findClass(name, definingContext, suppressed);
if (clazz != null) {
return clazz;
}
}
if (dexElementsSuppressedExceptions != null) {
suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
}
return null;
}
public Class<?> findClass(String name, ClassLoader definingContext,
List<Throwable> suppressed) {
return dexFile != null ? dexFile.loadClassBinaryName(name, definingContext, suppressed)
: null;
}
这个方法就是遍历的上面操作的 Element 数组在每一个 dex 文件里查找对应的 class 所以通过反射将 dex 文件加入到 elements 数组中就可以找到所有的 class 了
BoostMultiDex
以上就是 MultiDex 的基本流程了,但是 MultiDex 在第一次冷启动时做了大量的 io 操作导致启动缓慢,其中会先压缩 dex 文件为 zip 文件再解压 zip 文件进行 odex 优化最后加载优化后的 odex 启动,抖音针对这种情况发布了新的开源解决方案 BoostMultiDex 它做的事情主要是在首次启动时直接加载未优化的 dex 文件完成启动然后再开启一个后台线程去做 odex 的优化工作,之后再次启动的时候如果已经做完 odex 优化的工作则加载优化后的 dex 文件去运行,这样既可以保证第一次启动时的时长又可以在再次启动的时候加载优化后的 dex 保证性能
|