Android MultiDex 分包及加载原理

DebugCat
DebugCat
Nov 6 · 17 min read

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等相关脚本生成,具体逻辑可以自行研究。

mainDex相关脚本
mainDex相关脚本

总结一下, 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/

WX
Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade