Dex分包原理与65535原因

官方问题描述

问题:项目方法超 65535 编译报错。
产生原因:

我们都知道 Android apk 构建中会把所有 class 文件都转化为 Dex 文件,而单个 Dex 中方法数不能超过 2 的 16 次方,即 65535。既然是编译构建 dex 过程中发生的错误,肯定是由转化 dex 的工具 dx 抛出来的异常。

By the way : 各个版本 SDK Build tools 都可以从这里下载


我们先来认识下 dx(build tools version 27.0.3 ,dx version 1.14) 工具,它在 SDK build tools 下。找到命令看看 Usage

命令行执行:

$ /Users/handsomeyang/Library/Android/sdk/build-tools/27.0.3/dx --help

输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
usage:
dx --dex [--debug] [--verbose] [--positions=<style>] [--no-locals]
[--no-optimize] [--statistics] [--[no-]optimize-list=<file>] [--no-strict]
[--keep-classes] [--output=<file>] [--dump-to=<file>] [--dump-width=<n>]
[--dump-method=<name>[*]] [--verbose-dump] [--no-files] [--core-library]
[--num-threads=<n>] [--incremental] [--force-jumbo] [--no-warning]
[--multi-dex [--main-dex-list=<file> [--minimal-main-dex]]
[--input-list=<file>] [--min-sdk-version=<n>]
[<file>.class | <file>.{zip,jar,apk} | <directory>] ...
Convert a set of classfiles into a dex file, optionally embedded in a
jar/zip. Output name must end with one of: .dex .jar .zip .apk or be a
directory.
Positions options: none, important, lines.
--multi-dex: allows to generate several dex files if needed. This option is
exclusive with --incremental, causes --num-threads to be ignored and only
supports folder or archive output.
--main-dex-list=<file>: <file> is a list of class file names, classes
defined by those class files are put in classes.dex.
--minimal-main-dex: only classes selected by --main-dex-list are to be put
in the main dex.
--input-list: <file> is a list of inputs.
Each line in <file> must end with one of: .class .jar .zip .apk or be a
directory.
--min-sdk-version=<n>: Enable dex file features that require at least sdk
version <n>.
dx --annotool --annotation=<class> [--element=<element types>]
[--print=<print types>]
dx --dump [--debug] [--strict] [--bytes] [--optimize]
[--basic-blocks | --rop-blocks | --ssa-blocks | --dot] [--ssa-step=<step>]
[--width=<n>] [<file>.class | <file>.txt] ...
Dump classfiles, or transformations thereof, in a human-oriented format.
dx --find-usages <file.dex> <declaring type> <member>
Find references and declarations to a field or method.
<declaring type> is a class name in internal form, like Ljava/lang/Object;
<member> is a field or method name, like hashCode.
dx -J<option> ... <arguments, in one of the above forms>
Pass VM-specific options to the virtual machine that runs dx.
dx --version
Print the version of this tool (1.14).
dx --help
Print this message.

可见 dx 工具描述为:把一系列 class 文件 或者 jar 包 转化为 dex 文件的工具。

顺便介绍一下 dx 的比较常见 options 参数,后面还是说到,这里简单介绍下:

  1. - - multi-dex :能够生成多个 dex files
  2. - -main-dex-list= : 参数 file 是列出 main dex (即 classes.dex) 中所包含的类信息。通俗点就是你想要主 Dex 中有哪些类不被分出去就写在 file 里,把 file 传进参数命令里; 通常用来解决分包造成的 classNotDefException 问题。
  3. - - minimal-main-dex : 只有- -main-dex-list= 指定的 file 中的类被保留在主Dex,用来减小 main dex 压力。

简单给大家演示一下 dx 工具命令行用法:(注意如果报错很可能是 jar 包的问题, 比如 classpath 之类的问题)

  • jar —> dex :
  • command : dx --dex --debug --verbose --output=/Users/handsomeyang/Desktop/demo /Users/handsomeyang/Desktop/Demo.jar

默认会生成 classes.dex ,当然是在如果你方法数、字段数没超 65535 的情况下,但是超了会发生什么呢:smiley:

  • Multidex : 注意要设置 --set-max-idx-number=1000 哦,默认应该是 jar 包方法数超过 65535 才会分包。而且这个--set-max-idx-number=1000 是对生成的每个 dex 文件都起限制的。
  • command : dx --dex --debug --verbose --multi-dex --set-max-idx-number=1000 --output=/Users/handsomeyang/Desktop/demo /Users/handsomeyang/Desktop/Demo.jar

看下控制台输出

1
2
3
4
5
6
7
...
// 目录和 resource 被忽略
ignored resource com/xxx/xxx/xxx/xxx/
processing com/xxx/xxxx/xxx/xxx/xxxx.class...
// 内部类算单独的类作为处理
processing com/xxx/xxx/xxx/xx/xxx/xxx$xxxx.class...
...

然后,就会在/Users/handsomeyang/Desktop/demo 目录下生成诸如 classes.dexcalssses2.dexclasses3.dex … 而且每个 dex 方法数都不超过 1000。


其实这个 dx 工具就是一个 bash 脚本(windows 下是batch), 用文本编辑器打开 dx (这个是bash):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
# Set up prog to be the path of this script, including following symlinks,
# and set up progdir to be the fully-qualified pathname of its directory.
prog="$0"
while [ -h "${prog}" ]; do
newProg=`/bin/ls -ld "${prog}"`
newProg=`expr "${newProg}" : ".* -> \(.*\)$"`
if expr "x${newProg}" : 'x/' >/dev/null; then
prog="${newProg}"
else
progdir=`dirname "${prog}"`
prog="${progdir}/${newProg}"
fi
done
oldwd=`pwd`
progdir=`dirname "${prog}"`
cd "${progdir}"
progdir=`pwd`
prog="${progdir}"/`basename "${prog}"`
cd "${oldwd}"
jarfile=dx.jar
libdir="$progdir"
if [ ! -r "$libdir/$jarfile" ]; then
# set dx.jar location for the SDK case
libdir="$libdir/lib"
fi
if [ ! -r "$libdir/$jarfile" ]; then
# set dx.jar location for the Android tree case
libdir=`dirname "$progdir"`/framework
fi
if [ ! -r "$libdir/$jarfile" ]; then
echo `basename "$prog"`": can't find $jarfile"
exit 1
fi
# By default, give dx a max heap size of 1 gig. This can be overridden
# by using a "-J" option (see below).
defaultMx="-Xmx1024M"
# The following will extract any initial parameters of the form
# "-J<stuff>" from the command line and pass them to the Java
# invocation (instead of to dx). This makes it possible for you to add
# a command-line parameter such as "-JXmx256M" in your scripts, for
# example. "java" (with no args) and "java -X" give a summary of
# available options.
javaOpts=""
while expr "x$1" : 'x-J' >/dev/null; do
opt=`expr "x$1" : 'x-J\(.*\)'`
javaOpts="${javaOpts} -${opt}"
if expr "x${opt}" : "xXmx[0-9]" >/dev/null; then
defaultMx="no"
fi
shift
done
if [ "${defaultMx}" != "no" ]; then
javaOpts="${javaOpts} ${defaultMx}"
fi
if [ "$OSTYPE" = "cygwin" ]; then
# For Cygwin, convert the jarfile path into native Windows style.
jarpath=`cygpath -w "$libdir/$jarfile"`
else
jarpath="$libdir/$jarfile"
fi
exec java $javaOpts -jar "$jarpath" "$@"

其实我们看注释就够了,dx 脚本只是对 dx.jar 的使用做了封装,实际上 dx 工具只做了两件事:一是找到同级目录下 libs 下 dx.jar 的 path,二是读取你设置的 Java 虚拟机内存配置参数(可选)。

这里我们留意个细节:

1
2
3
# By default, give dx a max heap size of 1 gig. This can be overridden
# by using a "-J" option (see below).
defaultMx="-Xmx1024M"

使用 dx 工具操作 apk 或者 dex ,有时候会报 OOM 提示 java heap size 之类关键字,就是执行 dx 命令 Java Process 内存最大参数太小了,可以调大试试。(应该只有我们产品用的到吧,因为我们产品有命令行工具用到了 dx 工具,而且出现过 OOM,所以有点印象,当提醒我自己哈哈)。

关键在这一行:exec java $javaOpts -jar "$jarpath" "$@" 其实就是:java -jar /Users/handsomeyang/Library/Android/sdk/build-tools/27.0.3/lib/dx.jar

你会发现跟直接执行 dx 命令 输出是一样的 :)


就是说 class 转 dex 实际上是由 dx.jar 来完成的。 既然是可运行的 jar 包, 里面肯定有 META-INF 目录,一般这个目录下面的 MANIFEST.MF 文件会保存 jar 包 启动类 路径。直接打开 MANIFESET.MF:

1
2
3
Manifest-Version: 1.0
Created-By: 1.8.0_45 (Oracle Corporation)
Main-Class: com.android.dx.command.Main

这里把 dx.jar 拷贝到 IDE 任何一个项目 libs 下,依赖上就能看到 dx.jar 源代码,而且支持跳转。

这里简单带大家梳理下 dx multidex 的工作流程,其他功能自己可以看看。

com.android.dx.command.Main 中的 Main 方法主要起一个枢纽的功能,把传进的参数做解析,就是上面 [- - xxx ] 之类的,其实就是一堆 if else 分支语句。不同功能,会把相应参数再次传到相应类的 Main 方法中。我们找我们关心的 dex 转换处理:

1
2
3
4
if(arg.equals("--dex")) {
com.android.dx.command.dexer.Main.main(without(args, i));
break;
}

找到下一个类 com.android.dx.command.dexer.Main , 其实无非也是先解析参数,把参数封装到一个 Arguments 的内部类中。

1
2
3
4
5
6
7
8
9
public static void main(String[] argArray) throws IOException {
DxContext context = new DxContext();
Main.Arguments arguments = new Main.Arguments(context);
arguments.parse(argArray);
int result = (new Main(context)).runDx(arguments);
if(result != 0) {
System.exit(result);
}
}

在解析并且封装 Main 方法中的参数的时候还有个小插曲要介绍:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static class Arguments {
....
// 输出文件是否是 .zip .apk .jar
public boolean jarOutput = false;
// 如果一个dex 中 String 引用超过了 65535 需要开启这个模式,才能引用到所有 String
public boolean forceJumbo = false;
// - -main-dex-list=<file>
public String mainDexListFile = null;
// - - minimal-main-dex
public boolean minimalMainDex = false;
// --multi-dex
public boolean multiDex = false;
// each dex 引用ID 上限,包括方法、字段、类等。
private int maxNumberOfIdxPerDex = 65536;
1
2
3
4
5
// 这里这个 --set-max-idx-number dx usage 中并没有给出,但是确实有这个用法
} else if(parser.isArg("--set-max-idx-number=")) {
// parser.getLastValue() 为获取 “=” 后面的数值
this.maxNumberOfIdxPerDex = Integer.parseInt(parser.getLastValue());
}

这里简单提出来 parse方法的一段 if else 分支,注释很清楚了,就是 转换 dex 的时候可以通过--set-max-idx-number= 来改 dex 默认的引用 id 上限(默认 65535),当然只能往小了改。

然后调用 runDx 方法,runDx 方法判断了是否需要 multidex 分多个dex 处理 ,需要 multidex 处理的走 runMultidex() 这个方法。我们看需要 multidex 处理的情况,这里贴一下代码块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
private int runMultiDex() throws IOException {
assert !this.args.incremental;
// 读取 - -main-dex-list=<file> 指定的 keep class
if(this.args.mainDexListFile != null) {
this.classesInMainDex = new HashSet();
readPathsFromFile(this.args.mainDexListFile, this.classesInMainDex);
}
this.dexOutPool = Executors.newFixedThreadPool(this.args.numThreads);
// 1.核心逻辑 class ——> dex
if(!this.processAllFiles()) {
return 1;
} else if(!this.libraryDexBuffers.isEmpty()) {
throw new DexException("Library dex files are not supported in multi-dex mode");
} else {
if(this.outputDex != null) {
// 2.这里把转化了格式的字节写进 dexFile 里,而对 方法数 是否超 65535 就是在这里判断的
this.dexOutputFutures.add(this.dexOutPool.submit(new Main.DexWriter(this.outputDex)));
this.outputDex = null;
}
try {
this.dexOutPool.shutdown();
if(!this.dexOutPool.awaitTermination(600L, TimeUnit.SECONDS)) {
throw new RuntimeException("Timed out waiting for dex writer threads.");
}
Iterator var1 = this.dexOutputFutures.iterator();
while(var1.hasNext()) {
Future<byte[]> f = (Future)var1.next();
// 这个 dexOutputArrays 就是 存放 dex bytes 的 set
this.dexOutputArrays.add(f.get());
}
} catch (InterruptedException var9) {
this.dexOutPool.shutdownNow();
throw new RuntimeException("A dex writer thread has been interrupted.");
} catch (Exception var10) {
this.dexOutPool.shutdownNow();
throw new RuntimeException("Unexpected exception in dex writer thread");
}
// 判断输出目录是否是 .zip .jar .apk
if(this.args.jarOutput) {
for(int i = 0; i < this.dexOutputArrays.size(); ++i) {
this.outputResources.put(getDexFileName(i), this.dexOutputArrays.get(i));
}
// 把 dex 添加至压缩文件
if(!this.createJar(this.args.outName)) {
return 3;
}
// 输出目录是一个 directory
} 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 代码块我贴下面
FileOutputStream out = new FileOutputStream(new File(outDir, getDexFileName(i)));
try {
// 把 classes.dex classes2.dex classes3.dex ... 写入 dex 文件
out.write((byte[])this.dexOutputArrays.get(i));
} finally {
this.closeOutput(out);
}
}
}
return 0;
}
}

(关键地方我都注释了)

classes.dex、classes2.dex、classes3.dex… 来历

1
2
3
private static String getDexFileName(int i) {
return i == 0?"classes.dex":"classes" + (i + 1) + ".dex";
}

上面的runMultidex 一共两个关键点:

  1. class —> dex 的逻辑;

  2. 65535 问题的产生;


我们先看 dex 转换过程:

processAllFiles 这个方法名字已经很见名知意了:

1
2
3
4
5
6
7
8
9
10
11
12
private boolean processAllFiles() {
...
try {
// 获取- -main-dex-list=<file> 的 keep 文件,虽然我们可能不会手动传,但是 sdk build tools 会自动生成 main dex 的 keep 文件。这个问题以后再聊,总之会走这里的分支。
if(this.args.mainDexListFile != null) {
FileNameFilter mainPassFilter = this.args.strictNameCheck?new Main.MainDexListFilter():new Main.BestEffortMainDexListFilter();
int i;
// 核心处理逻辑 :处理 inputFile 中的每一项
for(i = 0; i < fileNames.length; ++i) {
this.processOne(fileNames[i], (FileNameFilter)mainPassFilter);
}

经过一系列判读和操作,最终走到processClass 这个方法,看名字也知道是转换关键; processClass中启动了一个带返回值的线程 callable ,那我们看下call(相当于 Thread 的 run 方法) 方法。注意这里的 processClass 指的是每一个要被转换的 Class 字节。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
public Boolean call() throws Exception {
DirectClassFile cf = (DirectClassFile)this.dcff.get();
return this.call(cf);
}
private Boolean call(DirectClassFile cf) {
int maxMethodIdsInClass = 0;
int maxFieldIdsInClass = 0;
if(Main.this.args.multiDex) {
// 每个 java 类都有各自的 常量池
int constantPoolSize = cf.getConstantPool().size();
// 每个 class 中的 method count
maxMethodIdsInClass = constantPoolSize + cf.getMethods().size() + 2;
// 每个 class 中的 field count
maxFieldIdsInClass = constantPoolSize + cf.getFields().size() + 9;
synchronized(Main.this.dexRotationLock) {
int numMethodIds;
int numFieldIds;
synchronized(Main.this.outputDex) {
// dexFile 中已有的 methoud id count
numMethodIds = Main.this.outputDex.getMethodIds().items().size();
// dexFile 中已有的 field id count
numFieldIds = Main.this.outputDex.getFieldIds().items().size();
}
// 如果 method count 或者 field count 超过 maxNumberOfIdxPerDex 引用上限就 new 新的 dexFile
while(numMethodIds + maxMethodIdsInClass + Main.this.maxMethodIdsInProcess > Main.this.args.maxNumberOfIdxPerDex || numFieldIds + maxFieldIdsInClass + Main.this.maxFieldIdsInProcess > Main.this.args.maxNumberOfIdxPerDex) {
if(Main.this.maxMethodIdsInProcess <= 0 && Main.this.maxFieldIdsInProcess <= 0) {
if(Main.this.outputDex.getClassDefs().items().size() <= 0) {
break;
}
// new 一个新的 dexFile
Main.this.rotateDexFile();
} else {
try {
Main.this.dexRotationLock.wait();
} catch (InterruptedException var13) {
;
}
}
synchronized(Main.this.outputDex) {
numMethodIds = Main.this.outputDex.getMethodIds().items().size();
numFieldIds = Main.this.outputDex.getFieldIds().items().size();
}
}
Main.this.maxMethodIdsInProcess = Main.this.maxMethodIdsInProcess + maxMethodIdsInClass;
Main.this.maxFieldIdsInProcess = Main.this.maxFieldIdsInProcess + maxFieldIdsInClass;
}
}
// 用 Callable 对 class 进行转化,如:合并常量池,去除多余冗余信息等操作。
Future<ClassDefItem> cdif = Main.this.classTranslatorPool.submit(Main.this.new ClassTranslatorTask(this.name, this.bytes, cf));
Future<Boolean> res = Main.this.classDefItemConsumer.submit(Main.this.new ClassDefItemConsumer(this.name, cdif, maxMethodIdsInClass, maxFieldIdsInClass));
Main.this.addToDexFutures.add(res);
return Boolean.valueOf(true);
}

总之这段代码内容就是,先定义 一个 DexFile 输出文件,然后每次 add 一个 Class 字节做处理,如果加上这个 Class 的方法后者字段数超过了 定义的 DexFile id limit ,就再次 new 一个新的 DexFile。

理一下 multidex 分包转化 class 为 dex 的逻辑:runMultiDex —> processAllFiles —> 判断 inputFile 类型,如果是压缩类型,就解压后处理每一个 class 字节 —>processFileBytes—> 过滤掉resource 等字节,留下 class byte —> processClass —> 判断是否超过 DexFile id limit,超过就 new 新的 DexFile 继续处理字节 —> 最后把转化好格式的字节 写入多个 DexFile 中。具体的 class 转 Dex 格式就比较枯燥了,就不介绍了,了解下流程为我们分析 65535 做铺垫。


那我们来看runMultidex 中 65535 问题的产生根源。

如果不启用 multidex 功能,项目很容易产生 65535 ,但是开启了 multidex 有时候仍然会造成 65535 (这个以后分析),但是开启不开启 multidex 功能,程序丢 65535 异常的原因肯定是一样的,就是向 DexFile 写入字节的时候,方法数超过了 DexFile 的默认最大上限 65535。

1
2
3
4
// 这是之前 runMultidex 方法中的一个分支
// 这里就是开了个线程向 DexFile 中写字节
// 2.这里把生成的 dexFile 转化为字节,而对 方法数是否超 65535 就是在这里判断的
this.dexOutputFutures.add(this.dexOutPool.submit(new Main.DexWriter(this.outputDex)));

跟踪 DexWriter 方法最终到 DexFile 的 toDex0 方法,里面会对 Dex 文件格式每一个部分进行检查;比如:this.methodIds.prepare(); 猜测对 method count 判断在这里进行。跟踪进去后发现,MethodIdsSectionFiledIdsSection 的父类都有一个 getTooManyMembersMessage 方法,我贴下面(为了封装这一个共有方法而提的父类)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
protected void orderItems() {
int idx = 0;
if(this.items().size() > 65536) {
throw new DexIndexOverflowException(this.getTooManyMembersMessage());
} else {
for(Iterator var2 = this.items().iterator(); var2.hasNext(); ++idx) {
Object i = var2.next();
((MemberIdItem)i).setIndex(idx);
}
}
}
private String getTooManyMembersMessage()
...
try
{
// 丢出 65535 异常: “Too many field references: 131000; max is 65536.” 的地方
// max is %3$d. ===> Integer.valueOf(65536)
String memberType = this instanceof MethodIdsSection?"method":"field";
formatter.format("Too many %1$s references to fit in one dex file: %2$d; max is %3$d.%nYou may try using multi-dex. If multi-dex is enabled then the list of classes for the main dex list is too large.%nReferences by package:", new Object[]{memberType, Integer.valueOf(this.items().size()), Integer.valueOf(65536)});
Iterator var11 = membersByPackage.entrySet().iterator();
while(var11.hasNext()) {
Entry<String, AtomicInteger> entry = (Entry)var11.next();
formatter.format("%n%6d %s", new Object[]{Integer.valueOf(((AtomicInteger)entry.getValue()).get()), entry.getKey()});
}
...
}

错误信息提示:如果 Dex id 引用太多,你可以开启 multidex 模式,如果已经开启,main dex 中的引用太多了。

这就是在编译期间 Field 和 Method 会抛 Too many field references: 131000; max is 65536. 异常根源。