HomHomLin

HomHomLin

HomHomLin's blog


美柚Android DEX分包演进之路

前言

相信绝大部分Android开发人员都遇到过方法数超过 65535 的问题。

65536实际上是单个 DEX 文件内可引用的方法总数,超过这个数字, 就会导致 DEX 无法打包成功,出现错误。

随着业务的不断发展,美柚也遇到了65535问题。

MultiDEX

最开始,我们的解决方案是采用Google的Multidex,正常使用了几个版本后,美柚再次爆出了新的问题:

1
Too many classes in --main-dex-list, main dex capacity exceeded

这是因为Multidex实际上是将原本的一个DEX拆分成主DEX和其他DEX,原本我们只有一个DEX超过65535,拆分后变成了主DEX没有超过65536,但随着美柚业务的不断拓展,许多新的库和代码被添加进项目中,我们的主DEX再次超过了限制,导致了这个错误,所以我们现在需要做的是对主DEX进行拆分。

ManifestKeep

实际上Multidex是根据maindexlist.txt的内容来决定哪些类要被放进主DEX内的,而这份maindexlist是编译期生成的。

maindexlist.txt包含以下几个东西:

1.mainifest_keep.txt所定义的类

2.mainifest_keep.txt所定义的类的相关依赖

3.其他被偷偷插入的类

又包含了mainifest_keep.txt文件所定义的类的相关依赖类。

正常情况下,Android提供另一个默认的keep配置,我们通过SDK内的rules文件可以看到这个默认配置是哪些内容。

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
  -keep public class * extends android.app.Instrumentation {
<init>();
}
-keep public class * extends android.app.Application {
<init>();
void attachBaseContext(android.content.Context);
}
-keep public class * extends android.app.Activity {
<init>();
}
-keep public class * extends android.app.Service {
<init>();
}
-keep public class * extends android.content.ContentProvider {
<init>();
}
-keep public class * extends android.content.BroadcastReceiver {
<init>();
}
-keep public class * extends android.app.backup.BackupAgent {
<init>();
}
# We need to keep all annotation classes because proguard does not trace annotation attribute
# it just filter the annotation attributes according to annotation classes it already kept.
-keep public class * extends java.lang.annotation.Annotation {
*;
}

我们可以看到mainifest_keep默认包含四大组件、注解以及其他东西。

所以我们想到是否可以替换这份keep文件来决定主DEX的内容?答案是可以的。

因此我们写了个gradle task,通过替换为自定义的keep文件来达到控制主DEX内容的目的。

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
afterEvaluate {
android.applicationVariants.each {
variant ->
def collectTask = tasks.findByName("collect${variant.name.capitalize()}MultiDexComponents")//collectZroTestDebugMultiDexComponents
if (collectTask != null) {
List<Action<? super Task>> list = new ArrayList<>()
list.add(new Action<Task>() {
@Override
void execute(Task task) {
println "collect${variant.name.capitalize()}MultiDexComponents action execute!---------XXXXXXX mini main dex生效了!!!!$projectDir"
def dir = new File("$projectDir/build/intermediates/multi-dex/${variant.dirName}");
if (!dir.exists()) {
println "$dir 不存在,进行创建"
dir.mkdirs()
}
def manifestkeep = new File(dir.getAbsolutePath() + "/manifest_keep.txt")
manifestkeep.delete()
manifestkeep.createNewFile()
println "先删除,后创建manifest_keep"
def backManifestListFile = new File("$projectDir/manifest_keep.txt")
backManifestListFile.eachLine {
line ->
manifestkeep << line << '\n'
}
}
})
collectTask.setActions(list)
}
}
}

通过替换,初步解决了主DEX过大的问题,减少了方法数量,我们也因此继续开发了一段时间,然而好景不长,近两个版本加入的SDK过多,导致application和lanucher页面直接依赖的方法是成功超越65535,再次暴出了too many classes in –main-dex-list;我们需要进一步优化。

外来之物

我们重新检查了替换内容,当执行完keep文件替换后,我们打开build/multi-dex/components.flags,这个文件是multi-dex的日志文件,通过该文件,我们看到在输出keep_list的时候,有如下keep内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

-keep public class * extends android.app.Instrumentation {
<init>();
}

-keep public class * extends android.app.Application {
<init>();
void attachBaseContext(android.content.Context);
}

-keep public class * extends android.app.backup.BackupAgent {
<init>();
}

-keep public class * extends java.lang.annotation.Annotation {
<fields>;
<methods>;
}

-keep class com.android.tools.fd.** {
<fields>;
<methods>;
}

这说明光是替换keep文件仍然没办法达到效果,编译时keep_list在其他地方被偷偷塞进一些奇怪的东西。

经过跟踪,我们发现,在gradle的MultiDexTransform中,有这么一段代码:

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
private void shrinkWithProguard(@NonNull File input) throws IOException, ParseException {
dontobfuscate();
dontoptimize();
dontpreverify();
dontwarn();
dontnote();
forceprocessing();

applyConfigurationFile(manifestKeepListProguardFile);
if (userMainDexKeepProguard != null) {
applyConfigurationFile(userMainDexKeepProguard);
}

// add a couple of rules that cannot be easily parsed from the manifest.
keep("public class * extends android.app.Instrumentation { <init>(); }");
keep("public class * extends android.app.Application { "
+ " <init>(); "
+ " void attachBaseContext(android.content.Context);"
+ "}");
keep("public class * extends android.app.backup.BackupAgent { <init>(); }");
keep("public class * extends java.lang.annotation.Annotation { *;}");
keep("class com.android.tools.fd.** {*;}"); // Instant run.

// handle inputs
libraryJar(findShrinkedAndroidJar());
inJar(input);

// outputs.
outJar(variantScope.getProguardComponentsJarFile());
printconfiguration(configFileOut);

// run proguard
runProguard();
}

可以看到,gradle会往keep文件内塞入一些它自己的keep内容(话说这段居然是硬编码!不能通过配置文件来修改,忍不住吐槽下。),原来偷偷被塞进的东西是在这里,罪魁祸首竟然是Multi-Dex本身,那么这该怎么办呢?

既然是gradle,那我们可以通过groovy对这段内容进行hook,将MultiDexTransform替换成我们自己写的Transform,而我们自己写的Transform则去掉这些硬编码的keep内容。

成功替换了Transform后,我们发现components.flags中的确只输出了我们的keep内容,看来效果达到了。

DEXWalila

通过上面的内容,我们知道主DEX是由maindexlist.txt决定的,是否可以通过替换maindexlist.txt来达到指定主DEX的内容?答案是可以的,但是这样我们需要替换整个DEX打包过程,包含依赖分析等,过于复杂,所以我们初步想到的是在各个阶段对最后生成的maindexlist做优化处理。

我们分析主DEX的内容,发现并不完全是maindexlist.txt的内容,还包括了一些其他类,那么那些类是哪里来的呢。

实际上DEX在被打包的时候,maindexlist的内容可能不能完全塞满主DEX,打包工具会认为主DEX还剩下这么多空间,太浪费了,所以还会往里面塞其他东西,这就是主DEX的内容和maindexlist的内容不完全一致的原因。

因此我们首先要做的是不让这些额外的类进入主DEX,在dx.jar的com.android.dx.command.Dexer中设置–minimal-main-dex参数就可以不让这些额外的类进入主DEX。

该参数是不暴露,不允许外部设置的,我们通过Anna(美柚架构组开发的一款Android hook工具)来hook Dexer中的main方法,更改入参为–minimal-main-dex。

hook前,我们的方法数为6W,hook后,我们的方法数为5W,成功瘦身1W。

但是进一步对比,发现主DEX的内容仍然无法和maindexlist.txt的内容对应,仍然存在一些其他类。

通过研读dx代码,我们发现在MainDexListBuilder中的这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ClassReferenceListBuilder mainListBuilder = new ClassReferenceListBuilder(path);
mainListBuilder.addRoots(jarOfRoots);
Iterator element = mainListBuilder.getClassNames().iterator();

while(true) {
if(!element.hasNext()) {
if(keepAnnotated) {
this.keepAnnotated(path);
var19 = false;
} else {
var19 = false;
}
break;
}

String className = (String)element.next();
this.filesToKeep.add(className + ".class");
}

jarOfRoots是maindexlist.txt所依赖的类的jar包,通过上面的代码,我们可以看到maindexlist遍历完成后执行了keepAnnotated方法,进入keepAnnotated方法继续看。

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
private void keepAnnotated(Path path) throws FileNotFoundException {
Iterator var2 = path.getElements().iterator();

label52:
while(var2.hasNext()) {
ClassPathElement element = (ClassPathElement)var2.next();
Iterator var4 = element.list().iterator();

while(true) {
label48:
while(true) {
String name;
do {
if(!var4.hasNext()) {
continue label52;
}

name = (String)var4.next();
} while(!name.endsWith(".class"));

DirectClassFile clazz = path.getClass(name);
if(this.hasRuntimeVisibleAnnotation(clazz)) {
this.filesToKeep.add(name);
} else {
MethodList methods = clazz.getMethods();

for(int fields = 0; fields < methods.size(); ++fields) {
if(this.hasRuntimeVisibleAnnotation(methods.get(fields))) {
this.filesToKeep.add(name);
continue label48;
}
}

FieldList var10 = clazz.getFields();

for(int i = 0; i < var10.size(); ++i) {
if(this.hasRuntimeVisibleAnnotation(var10.get(i))) {
this.filesToKeep.add(name);
break;
}
}
}
}
}
}

}

filesToKeep是最后主DEX的内容,我们发现该方法对Path进行了遍历,Path是项目中所有class的集合jar,该方法遍历了所有class,通过hasRuntimeVisibleAnnotation来决定class是否放入主DEX中。

1
2
3
4
private boolean hasRuntimeVisibleAnnotation(HasAttribute element) {
Attribute att = element.getAttributes().findFirst("RuntimeVisibleAnnotations");
return att != null && ((AttRuntimeVisibleAnnotations)att).getAnnotations().size() > 0;
}

hasRuntimeVisibleAnnotation返回这个类或者方法是否拥有可见注解,如果有就加入到maindexlist,至此,我们知道了maindexlist的内容有:

1.mainifest_keep.txt的相关依赖

2.依赖可见的annotation的类

因此我们项目主DEX爆炸的原因就是这两个类集合过多导致。

为什么要将可见的annotation相关类放进主DEX呢?

实际上,有很多库或者组件是通过注解实现的,比如Dagger等,这些组件依赖注解,DEX分包可能会对他们造成影响,因此默认将这些类放入主DEX,但是由于我们项目相关类太多,导致主DEX直接爆炸。

找到原因后,我们通过Anna hook到相关方法,修改其执行逻辑。

通过自定义keep文件成功的将主DEX内5W的方法缩减到了2W。

随后,我们编写了一个叫DEXWalila的gradle插件用于处理主DEX问题。

新的问题

通过DEXWalila,我们完成了对主DEX的内容控制,极大的缩减了主DEX的大小,使美柚的主DEX只有53KB,提升了APP启动的响应速度。但是实际使用中,我们发现APP虽然启动很快,但是第一次安装(或版本升级)后,APP进入欢迎界面的时间却很长,而后续启动却没有这个问题,经过观察发现实际上Dex优化前的APP以前版本第一次启动欢迎界面也会有较长时间,这是为什么呢?

要想知道为什么,首先我们要知道为什么这种情况会出现在第一次。

万恶的MultiDex

我们知道一旦业务多了,App就会变大,产生65535。用了MultiDex后,65535的问题得到解决,但应用的Dex数量也会变多,主要是MultiDex将原本的1个Dex切割成了几个Dex,编译打包时,这些dex会随主DEX被打入Apk中。

当用户安装了应用,点击icon启动应用时,系统只会加载主DEX,其他副Dex只有当主Dex启动后调用MultiDex.install才会被载入到PathClassLoader中,而MultiDex.install则是我们手动调用的,系统并不会主动调用,而副Dex被加载后,应用内相关的业务才算正式可以运行。

实际上Android系统默认并不是直接运行Apk的Dex文件的,而是有一个DEX优化过程,这个是dex提前解释的阶段,在Dalvik虚拟机下这个过程是dex2opt,输出odex文件,在ART虚拟机下这个过程是dex2oat,输出oat文件,这些文件会被缓存到Android系统目录中,Android系统实际上加载的是这些被优化过的dex。

一个新应用安装后(版本升级)初次启动,系统会先解压apk,提取主DEX,加载主DEX,然后根据信息到缓存目录寻找是否存在优化的dex文件,如果有则加载优化的dex,没有就将主DEX进行对应的优化,保存到缓存目录,再加载这个优化过的dex,启动App,app启动后当执行到MultiDex.install的时候,系统会查看apk中是否存在其他dex,然后将他们提取优化,也就是主DEX的那一系列步骤。

而MultiDex.install是无法被放到异步线程的,因为我们无法确保这个dex内的业务是否需要马上被使用,所以MultiDex.install只能放置在主线程,而这一步骤相对耗时,万一这一步骤执行较长,达到了5秒,那么App就会ANR,所以MultiDex.install是耗时的万恶之源。

美柚之前的思路

美柚采用的做法是当Application启动时,启动一个私有进程:mini,同时主进程阻塞轮询,mini进程进行MultiDex.install,完成后创建一个文件,主进程轮询到有文件后进行Application后续的操作打开。

所以我们可以模拟美柚启动的情况,用户启动APP,系统需要花费时间加载主DEX,由于初次安装(或升级)又花费了时间去做dex优化,(这里的加载和优化dex的时间根据主DEX大小而异,因为现在优化到53KB,所以这步变的很快,之前是7.3MB),主DEX启动后,Application启动,创建私有进程mini进行MultiDex.install开始加载其他副Dex,加载副Dex后又进行了Dex优化,导致整个时间较长,优化完毕后Application才进行后续的操作。

但是这个耗时仍然是无法避免的,统计发现这个时间在10s左右。

另外由于插件化是以apk打包的,所以如果我们使用插件化,那么插件中的dex也必然会耗费很长的加载时间,这显然是个必须解决的问题。

Dex瓦莉拉2.0

瓦莉拉1.0虽然减小了主DEX的大小,使App能达到毫秒级响应,但是没有对Dex加载速度进行优化。

那有没有什么办法来优化这个时间呢?

我们首先想到的是跳过系统优化DEX的时间。

主DEX的时间我们是没办法避免的,因为这是系统必须做的,我们唯一能优化主DEX加载时间的办法只有尽可能的让主DEX变小,好在主DEX目前已经能被瓦莉拉缩小到了53K,他的响应速度已经达到了毫秒级别,唯一需要处理的就是副DEX。

我们首先想到如果我们内置优化过的dex到apk中,主dex加载后释放这些优化过的dex到系统缓存目录是否就可行了呢?答案是不行的,因为这个DEX优化过程因设备而定,不同的设备优化文件有所不同。

但是我们找到了另一个优化办法,那就是不让系统进行dex优化,优化工作放到后台。

实际上在ART虚拟机下,系统是支持直接原始dex解释的,我们完全可以不需要让系统进行dex优化,但是我们却想使用这个系统优化的dex文件,所以我们可以这样做:应用启动,判断dex是否已经优化过,如果没有则跳过dex优化,直接将DEX载入执行Application后续操作,同时将优化放置到后台,下次启动时直接使用优化的dex文件,这样优化dex的时间就不会被安排到用户启动app的时间上,一切无感知。

要做到这点,我们需要明白dex优化的步骤在哪里。

ART虚拟机

通过对代码的跟踪,我们发现,dex优化虽然是MultiDex.install触发的,但是代码却不在MultiDex中,在ART虚拟机模式下,DEX优化新建一个进程通过/system/bin/dex2oat命令进入libc.so的execv方法中,主进程保持阻塞,待新建进程execv执行完毕后继续,所以我们只需要hook这个execv方法,判断如果是/system/bin/dex2oat发起就直接退出当前进程,让主进程不阻塞就可以了。

但是libc.so不在Java层,而在系统内,通过美柚安娜无法达到hook,所以只能通过Cydia进行hook,JNI hook代码如下。

1
2
3
4
5
6
7
8
9
10
11
12

# Walila.cpp
# lhh 2017/7/30

int meetyou_execv(const char *name, char **argv) {
if(isDex2oat()) {
//如果是dex2oat就直接退出
exit(0);
}

return origin_execv(name, argv);
}

hook完毕后,我们就可以跳过系统优化dex的时间,应用到美柚中,第一次启动MultiDex.install的返回耗时达到了毫秒级。

但是这只是跳过优化时间,我们还是需要系统优化过的dex,因此我们对hook方法进行了改造,当我们的Application启动时判断是否有优化的dex,没有则进行hook,同时创建一个私有进程进行优化,优化完毕后保存结果提供给Application下次启动时使用。

Dalvik虚拟机

Dalvik虚拟机默认情况下不支持Dex文件,但是在JNI层提供了一个openDexFile(byte *file)的方法,openDexFile没有对应的Java层,只能通过JNI HOOK。

结语

通过这一系列的处理,美柚的dex第一次安装加载速度从10s降低到了10ms,加载速度达到了真正意义的毫秒级,从主Dex内容的控制到Dex加载的控制,我们都能随心所欲。

目前这一系列技术已经在瓦莉拉2.0中实现。

以上就是美柚在DEX拆分上的解决方案,通过该方案,我们成功的控制了主DEX的内容,减少了主DEX的大小。

linhh 2017.07.24

招人

最后打个广告:招Android工程师!

美柚需要你

最近的文章

如何创建属于自己的GitHub博客

文章转自:http://www.jianshu.com/p/4eaddcbe4d12 1.创建账户在github创建账户,然后创建一个仓库,名字为 username.github.io注意:username必须是你github的用户名。 2.安装homebrew和git// 如果已安装HomeBre …

于 继续阅读
更早的文章

gradle-publish

http://blog.csdn.net/linhh90/article/details/50510725 ##介绍本项目包含一些Gradle脚本及属性文件,用于使用Gradle发布项目. bintray.gradle: 用于发布到JCenter的脚本。 build.gradle: 怎么使用它的De …

于 继续阅读