Android MultiDex 分包及加载原理

4,390 阅读7分钟

Problem

日常开发中,一旦项目变的庞大起来,很容易遇到如下的编译错误:

trouble writing output:
Too many field references: 131000; max is 65536.
You may try using --multi-dex option.

//低版本编译会遇到类似这种
Conversion to Dalvik format failed:
Unable to execute dex: method ID not in [0, 0xffff]: 65536

错误信息也很明确,表示单个Dex文件内可以包含的方法引用数不能超过65536,正好是2的16次方64Kb,有时候也叫“64K引用限制”。

如何规避

遇到以上问题,第一反应当然是精简代码:

  • 检查应用的直接和传递依赖项:简单说就是一个类能解决的问题不要引入一个库,这种也是日常开发中最常见的,很多时候我们为了用到某一个轮子,而引入了一整辆马车。这种可以通过精简一些第三方库、support包等。
  • 通过代码压缩、移除未使用的代码:很多代码年久失修,其实可以重构或者删除掉。

即使如此,上述策略还是无法彻底解决64K引用的问题,官方提供了将一个Dex拆分为多个Dex的库来越过这一限制,这就是MultiDex。

引入MultiDex

MultiDex可以理解为一个工具集,一方面在编译打包时将你的代码从之前的生成一个Classes.dex 变为生成Classes.dex、Classes1.dex...ClassesN.dex多个Dex文件;另一方面它也提供了应用运行时对这多个Dex的加载。

Android 5.0之前版本支持多Dex

Android5.0之前编译版本要支持编译时对Dex进行分包,需要如下配置:

android {
	defaultConfig {
		...
		minSdkVersion 15
		targetSdkVersion 28
		//启用多Dex
		multiDexEnabled true
	}
	...
}
dependencies {
  implementation 'com.android.support:multidex:1.0.3'
}

Android 5.0之前使用Dalvik执行应用代码,默认情况下,Dalvik限制每个APK只能使用一个Classes.dex,所以要支持运行时多Dex加载,需要配置当前Application类,要么继承MultiDexApplication,要么在当前Application中调用如下方法:

@Override
protected void attachBaseContext(Context base) {
	super.attachBaseContext(base);
	//运行时多Dex加载, 继承MultiDexApplication最终也是调用这个方法
	MultiDex.install(this);
}

Android 5.0之后版本支持多Dex

Android 5.0之后的版本使用ART运行时,它本身支持从APK文件中加载多个Dex文件。并且ART在应用安装时执行预编译,会扫描所有的ClassesN.dex, 统一优化为.oat文件。并且编译时如果minSdkVersion>=21, 则默认情况下支持分包,不需要引入上述support库。

综上,Android 5.0之前需要引入对应的support库来支持编译时分包和运行时加载多Dex,而Android 5.0之后由于使用ART虚拟机,运行时本身支持加载多Dex,minSdkVersion >=21 编译期也本身支持分包,因此不必引入相关配置。

MultiDex 分包原理

引入 multiDexEnabledtrue 之后,就可以支持打包生成多个Dex文件,因此,这一过程肯定是在编译期间发生,从官方的打包流程图也可以看出,最终是通过dex工具将class文件转换为Dex文件,

在这里插入图片描述
dx实际上是个脚本,其执行对应的jar包路径为 /sdk/build-tools/27.0.x/lib/dx.jar ,我们可以将其导入AndroidStudio,分析其源码:

//找到对应的入口类
//com.android.dx.command.Main.java
public class Main {
	public static void main(String[] args) {
		//读取入参args
		if (arg.equals("--dex")) {
			com.android.dx.command.dexer.Main.main(without(args, i));
			break;
		}
		...
	}
}

//com.android.dx.command.dexer.Main.java
public static void main(String[] argArray) throws IOException {
	DxContext context = new DxContext();
	//封装入参, Arguments构造函数中指定了maxNumberOfIdxPerDex=65536
	Main.Arguments arguments = new Main.Arguments(context);
	arguments.parse(argArray);
	//执行
	int result = (new Main(context)).runDx(arguments);
	if (result != 0) {
		System.exit(result);
	}
}

public int runDx(Main.Arguments arguments) throws IOException {
	//一堆分装参数,初始化IO逻辑
	...
    int var3;
    try {
		 //gradle中enable MultiDex
        if (this.args.multiDex) {
            var3 = this.runMultiDex();
            return var3;
        }
        var3 = this.runMonoDex();
    } finally {
        this.closeOutput(humanOutRaw);
    }
    return var3;
}


private int runMultiDex() throws IOException {
	assert !this.args.incremental;
	//看来是去读一个关键文件 mainDexListFile(主Dex相关)
   if (this.args.mainDexListFile != null) {
		// 保存主Dex中需要打包的Classes
       this.classesInMainDex = new HashSet();
		// 从mainDexListFile中读取需要打包在MainDex中的类并保存
      readPathsFromFile(this.args.mainDexListFile, this.classesInMainDex);
    }
	
	//起一个线程池
    this.dexOutPool =Executors.newFixedThreadPool(this.args.numThreads);
    if (!this.processAllFiles()) {
        return 1;
    } else if (!this.libraryDexBuffers.isEmpty()) {
        throw new DexException("Library dex files are not supported in multi-dex mode");
    } else {
			//提交对应任务,通过DexWriter将Class转化为Dex文件
        if (this.outputDex != null) {
            this.dexOutputFutures.add(this.dexOutPool.submit(new Main.DexWriter(this.outputDex)));
            this.outputDex = null;
        }
		 //
        if (this.args.jarOutput) {
           ...
        } else if (this.args.outName != null) {
            File outDir = new File(this.args.outName);
            assert outDir.isDirectory();
            for(int i = 0; i < this.dexOutputArrays.size(); ++i) {
				//getDexFileName(i)==>i  == 0 ? "classes.dex" : "classes" + (i + 1) + ".dex";
            FileOutputStream out = new FileOutputStream(new File(outDir, getDexFileName(i)));
             try {
                  out.write((byte[])this.dexOutputArrays.get(i));
              } finally {
                  this.closeOutput(out);
              }
           }
        }
        return 0;
    }
}
//每个提交的任务中对Class进行单独处理,包括进行校验方法引用数等,这里篇幅有限,不再深入,感兴趣的同学自行研究

上面提到的MainDex中的类主要是由mainDexListFile指定的,而mainDexListFile的生成是通过SDK中的mainDexClasses、mainDexClasses.rules、mainDexClassesNoAapt.rules等相关脚本生成,具体逻辑可以自行研究。

在这里插入图片描述
总结一下, MultiDex的分包是在编译期借助dx和mainDexClasses等脚本,确定主Dex(仅包含入口类和引用类)和其他Dex的具体字节码组成,并且生成对应文件的过程,篇幅所限,后续可对照相关源码深入研究。

MultiDex 加载原理

如果对Android ClassLoader比较熟悉的话,其实多Dex加载的原理也比较简单,后续的插件化和热修复也用到了类似思想,以下是源码的一些关键路径分析:


//MultiDex.java
public static void install(Context context) {
	//通过context拿到当前application信息
	...
	//sourceDir: data/app/com..xxxx/base.apk
	//dataDir: data/data/com.xxxx
	doInstallation(context,
		new File(applicationInfo.sourceDir),
		new File(applicationInfo.dataDir),
		CODE_CACHE_SECONDARY_FOLDER_NAME,
		NO_KEY_PREFIX,
		true);
}

private static void doInstallation(...) {
	...
	//拿到当前application对应Classloader
	ClassLoader loader = mainContext.getClassLoader(); //PathClassLoader
	//ClassesN.dex对应释放路径
	File dexDir = getDexDir(mainContext, dataDir, secondaryFolderName);
	//将目录下的base.apk解压提取classesN.dex,源码后续分析
	MultiDexExtractor extractor = new MultiDexExtractor(sourceApk, dexDir);	
	IOException closeException = null;
	try {
		List<? extends File> files =
			extractor.load(mainContext, prefsKeyPrefix, false);
		try {
			//重点代码
			installSecondaryDexes(loader, dexDir, files);
			//容错 Some IOException causes may be fixed by a clean extraction.
			} catch (IOException e) {
				if (!reinstallOnPatchRecoverableException) {
					throw e;
				}
				files = extractor.load(mainContext, prefsKeyPrefix, true);
				installSecondaryDexes(loader, dexDir, files);
			}
		} finally {
			...
		}
}

//
private static void installSecondaryDexes(ClassLoader loader, File dexDir,List<? extends File> files) {
	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);
		}
	}
}

//以V19为例
private static final class V19 {
	static void install(ClassLoader loader..) {
		//获取当前ClassLoader 的pathList
		Field pathListField = findField(loader, "pathList");
		Object dexPathList = pathListField.get(loader);
		
		//通过调用DexPathList.makeDexElements(ArrayList<File> files, File optimizedDirectory); 传入之前释放出来的Classes1.dex...ClassesN.dex所在路径,生成对应的DexElements, 然后和当前已加载主Dex的Classloader对应的DexPathList中的DexElement合并,之后再通过发射设置给当前ClassLoader对应的DexPthList,这样,当前ClassLoader就拥有一个包含所有DexElement的dexPathList,也就可以访问其他多个Dex的
		expandFieldArray(dexPathList, "dexElements", 			makeDexElements(dexPathList,
			new ArrayList <File> (additionalClassPathEntries), 			optimizedDirectory,
			suppressedExceptions));
	}
}

//反射替换
private static void expandFieldArray(Object instance, String fieldName,
	Object[] extraElements) {
	Field jlrField = findField(instance, fieldName);
	Object[] original = (Object[]) jlrField.get(instance);
	Object[] combined = (Object[]) Array.newInstance(
	original.getClass().getComponentType(), original.length + 	extraElements.length);
	System.arraycopy(original, 0, combined, 0, original.length);
	System.arraycopy(extraElements, 0, combined, original.length, 		extraElements.length);
	jlrField.set(instance, combined);
}

//构造DexClement[]
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);
}

对照源码可以看出,MultiDex的加载原理比较简单,主要是从ClassLoader入手,通过反射调用使得当前加载了主Dex文件的ClassLoader也可以读取到其他Dex。但我们从中可以看出这里有很多IO操作,容易出现ANR问题,这也决定了我们的分包Dex也不能过大。

MultiDex的局限性

  • 如果分包的Dex过大,上述install过程涉及IO等操作,容易触发ANR问题;

  • 当运行的版本低于 Android 5.0(API 级别 21)时,使用多 dex 文件不足以避开 linearalloc 限制(参考google:issuetracker.google.com/issues/3700… 此上限在 Android 4.0(API 级别 14)中有所提高,但这并未完全解决该问题。在低于 Android 4.0 的版本中,可能会在达到 DEX 索引限制之前达到 linearalloc 限制。因此,如果您的目标 API 级别低于 14,请在这些版本的平台上进行全面测试,因为您的应用可能会在启动时或加载特定类组时出现问题。代码压缩可以减少甚至有可能消除这些问题。

总结

由于Dex文件结构的限制,方法引用数不能超过64K,因此除了努力缩减代码之外,官方也提供了一套工具库,一方面支持编译时分包,一个APK中包含多个Dex,同时也利用ClassLoader原理巧妙的绕过了Dalvik加载APK时只加载一个Dex的限制。而Android 5.0 N之后引入ART,这些问题被巧妙的隐藏或者解决了,但MultiDex的加载原理ClassLoader在后续的热修复插件化等方案中应用的很广泛。

参考资料:

developer.android.com/studio/buil…

yangxiaobinhaoshuai.github.io/

在这里插入图片描述