Android下的Jack-Jill初探

好久没有更新博客了,最近实在是太忙了,到了年底,好不容易有一个空下来的周末,决定写一篇文章记录一下最近学的东西。

大家都知道Google早些时间推出了Android新一代的编译器Jack,希望用它代替传统的编译方式。之前在网上看了一下,关于Jack的文章还是比较少的,所以花了一点时间对其进行了研究,这篇文章算是对它来一个简单全面的介绍吧~

如何使用Jack

在使用Jack进行编译之前,我们想一下之前的方式是怎样的。首先我们的“原材料”是.java格式的文件,而我们需要做的就是把这些文件编译成.class格式的文件,然后再进行转换,将其变为.dex结尾的文件,最后用dex文件配合aapt,jarsigner等工具进行打包,简单的.java —> .dex的过程可具体总结为:

1
javac (.java –> .class) –> dx (.class –> .dex)

当然,这只是最简单的过程,在具体的使用中,我们还需要使用类似proguard这样的工具,才可以变成一个我们想要的dex文件,所以真正的流程可以总结为:

1
javac (.java –> .class) -> proguard ...... -> (Java bytecode(.class) -> Optimized Java bytecode(.class)) –> dx (.class –> .dex)

可以看到整体的流程还是非常的长的,不过好在有AndroidStudio这样的帮我们做了这一切。

那Jack又是怎么做的呢?在我第一次接触Jack的时候,我手动的用它编译了一个Android项目,我发现一切真的太简单了,不需要dx,不需要proguard,只需要一个java -jar jack.jar命令,一切ok。具体的命令大家可以自行查找,可以说使用jack能非常简便的进行手动打包,具体流程总结为:

1
Jack (.java –> .dex)

先说一句,在AndroidStudio中使用Jack进行编译需要满足如下条件:

首先compileSdkVersion需要在24以上,其次,需要在jackOptions中开启enabled,最简单的配置如下:

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
apply plugin: 'com.android.application'

android {
compileSdkVersion 24
buildToolsVersion "24.0.0"
defaultConfig {
applicationId "com.zjutkz.jacktest"
minSdkVersion 14
targetSdkVersion 23
versionCode 1
versionName "1.0"
jackOptions {
enabled true
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}

dependencies {
compile fileTree(dir: 'libs', include: ['*.jar'])
compile 'com.android.support:appcompat-v7:24.2.1'
testCompile 'junit:junit:4.12'
}

对比Jack和传统的编译流程,我们可以发现一个最明显的区别,就是使用Jack进行编译的情况下,完全没有.class文件的出现。也就是说Jack抛弃了传统的Java字节码文件,这可谓是一个非常大的突破。当然这就带来了两个问题:第一个,抛弃.class文件之后,那些操作Java字节码的动态hook工具改如何使用呢?第二个问题,库的依赖我们还是要以jar/aar的形式依赖的,那么必然会存在.class文件,那如果使用了Jack之后,如果进行库的依赖呢?

对于第二个问题,Google使用了另外一个工具——Jill,下面就让我们来看看这个工具到底做了什么吧。

Jill

首先我们先看两张图:

jack_compile

jill_compile

通过第一张图我们知道,在进行编译的过程中,Jack使用了Jill工具将依赖库中的.class文件转换成了.jack文件,然后再使用Jack工具将这些文件和Android过程的源代码一起编译为.dex文件,当然.dex文件的数量取决于是否开启multiDex。

通过第二张图我们知道,Jill工具具体的工作就是将jar/aar中的.class文件转化成.jayce格式的文件,并且再使用Jack做preDex操作。至于preDex的作用,其实就是一个预编译的作用,起到缓存的作用。

我们真正需要关注的是,Jill是如何将.class文件转换成.jayce格式的文件的。

首先我们来翻一翻gradle的源码,其中有一个Task叫JillTask,名字一目了然,就是一个用来做Jill操作的gradle task。它分为两种类型——jillPackagedTask和jillRuntimeTask,我们看jillPackagedTask就好。

jillOutput

从图中可以看出它的输出目录为intermediates/jill/type+flavors/packaged/,下面让我们看看这个目录下有什么:

jillDir

接着我们解压其中随意的一个jar包,看看里面是什么:

jayceJar

可以看到,里面其实就是该Android工程中依赖库的jayce格式文件,这也印证了前面的流程图:Jill的作用就是把依赖库中的.class文件转换成.jayce文件。那Jill具体是如何做的呢?还是看代码来的实在。

gradle task中执行任务是在@TaskAction这个注解下的方法中执行的,所以我们来看看JillTask的taskAction方法。

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
@TaskAction
public void taskAction(IncrementalTaskInputs taskInputs)
throws LoggedErrorException, InterruptedException, IOException {
Revision revision = getBuilder().getTargetInfo().getBuildTools().getRevision();
if (revision.compareTo(JackTask.JACK_MIN_REV, Revision.PreviewComparison.IGNORE) < 0) {
throw new RuntimeException(
"Jack requires Build Tools " + JackTask.JACK_MIN_REV.toString()
+ " or later");
}

final File outFolder = getOutputFolder();

// if we are not in incremental mode, then outOfDate will contain
// all th files, but first we need to delete the previous output
if (!taskInputs.isIncremental()) {
FileUtils.emptyFolder(outFolder);
}

final Set<String> hashs = Sets.newHashSet();
final WaitableExecutor<Void> executor = new WaitableExecutor<Void>();
final List<File> inputFileDetails = Lists.newArrayList();

final AndroidBuilder builder = getBuilder();

taskInputs.outOfDate(new Action<InputFileDetails>() {
@Override
public void execute(InputFileDetails change) {
inputFileDetails.add(change.getFile());
}
});

for (final File file : inputFileDetails) {
Callable<Void> action = new JillCallable(this, file, hashs, outFolder, builder);
executor.execute(action);
}

taskInputs.removed(new Action<InputFileDetails>() {
@Override
public void execute(InputFileDetails change) {
File jackFile = getJackFileName(outFolder, ((InputFileDetails) change).getFile());
//noinspection ResultOfMethodCallIgnored
jackFile.delete();
}
});

executor.waitForTasksWithQuickFail(false);
}

其中最重要的就是异步执行了JillCallable这个Callable。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Override
public Void call() throws Exception {
// TODO remove once we can properly add a library as a dependency of its test.
String hash = getFileHash(fileToProcess);

synchronized (hashs) {
if (hashs.contains(hash)) {
return null;
}

hashs.add(hash);
}

//noinspection GroovyAssignabilityCheck
File jackFile = getJackFileName(outFolder, fileToProcess);
//noinspection GroovyAssignabilityCheck
builder.convertLibraryToJack(fileToProcess, jackFile, options,
new LoggedProcessOutputHandler(builder.getLogger()),
isJackInProcess());

return null;
}

上面是JillCallable的call方法。步骤很简单,首先将输入文件,也就是依赖库进行哈希,然后使用这个哈希值作为输出文件的文件名,这也证明了为什么之前看到的jill目录下的输出文件的文件名都是哈希值了。然后,调用了AndroidBuilder的convertLibraryToJack,在这之中进行了真正的转换。

该方法最终调用了JackConversionCache的convertLibrary方法。在这个方法中,存在两种转换的方式:使用api或者命令行,我们就看api方式的转换,Jill具体的源码在这里

最终会调用到Jill.java的process方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public static void process(@Nonnull Options options) {
File binaryFile = options.getBinaryFile();
JavaTransformer jt = new JavaTransformer(getVersion().getVersion(), options);
if (binaryFile.isFile()) {
if (FileUtils.isJavaBinaryFile(binaryFile)) {
List<File> javaBinaryFiles = new ArrayList<File>();
javaBinaryFiles.add(binaryFile);
jt.transform(javaBinaryFiles);
} else if (FileUtils.isJarFile(binaryFile)) {
try {
jt.transform(new JarFile(binaryFile));
} catch (IOException e) {
throw new JillException("Fails to create jar file " + binaryFile.getName(), e);
}
} else {
throw new JillException("Unsupported file type: " + binaryFile.getName());
}
} else {
List<File> javaBinaryFiles = new ArrayList<File>();
FileUtils.getJavaBinaryFiles(binaryFile, javaBinaryFiles);
jt.transform(javaBinaryFiles);
}
}

其中调用了JavaTransformer的transform方法:

1
2
3
4
5
6
7
private void transform(@Nonnull ClassNode cn, @Nonnull OutputStream os) throws IOException {
JayceWriter writer = createWriter(os);
ClassNodeWriter asm2jayce =
new ClassNodeWriter(writer, new SourceInfoWriter(writer), options);
asm2jayce.write(cn);
writer.flush();
}

可以看到,使用了asm2jayce去把.class转换成了.jayce文件。没想到,Jack也使用了asm,这个框架可谓无处不在啊!

到这里,我们就弄清楚了Jill这个工具的作用以及运作原理,其实很简单,就是为了让依赖库在保持jar/aar依赖方式不变的情况下,也能使用Jack进行编译。

Jack

说完了Jill,让我们再说下一个工具——Jack。通过上面的文章我们可以知道,Jill存在的原因其实就是为了Jack服务的,我们从gradle的TaskManager中也可以印证这一点:

TaskManager

看的很清楚,JaskTask是依赖于JillTask的,因为JaskTask需要获取JillTask中生成的依赖库的.jayce文件才行。那JaskTask又做了什么呢?这里我们从简讲,毕竟这篇文章只是全面简单的了解一下而已,之后有机会可以深入的讲一下其中的原理,包括它和jarjar,multidex等库的关系。

首先和JillTask一样,JaskTask会调用AndroidBuilder去进行操作,并且判断是使用api形式还是命令行形式,api形式具体的方法是convertByteCodeUsingJackApis。这其中我们主要关注第二个参数——jackOutputFile,最终我们可以追溯到VariantScopeImpl中的getJackClassesZip方法:

JaskZip

就是/intermediates/package/type+flavors/class.zip文件,让我们解压看看这个文件里面是什么:

jackFile

这其中包含了.jayce格式的文件和prebuild目录,prebuild目录下就是经过predex生成的dex文件,用来加快下一次的编译速度。

最终,convertByteCodeUsingJackApis会调用到Jack.Java的run方法中,具体的源码在这里。在run方法中,就是去进行整个编译工作,其中会配合之前提到的jarjar,multidex等库一起进行。

对于class.zip这个文件,如果大家看过里面的内容,会发现它的jayce目录和prebuild目录下包含的内容是所有的依赖库和该android工程的文件,所以我们可以知道,Jack会把Jill所生成的所有依赖库的jayce文件做prebuild操作,生成jack文件,并且和工程文件一起合成最终的dex文件。最终的dex文件的产出目录是/intermediates/dex/type+flavors/classes1/2/3…./n.dex。

对于prebuild操作,生成的dex文件是可以复用的,意思就是如果两次编译之间的依赖库版本和数量不变,则Jack不会去对其进行重新的编译,而只专注于改变了的工程文件,从而加快整体的编译速度,这也就是我文章开头提到的[缓存]。

总结

分析到这里,我们基本大致的走通了Jill和Jack的整体流程。最后,我在真实的项目环境中使用了传统的编译方式和Jack都编译了一次,发现clean build的情况下,一个传统编译只需要2分钟的项目,而在Jack编译器下编译则需要10分钟!gradle task很明显的卡在了app:compileDebugJavaWithJack这个task上。

为什么会这样呢,在没有仔细的研究Jask源码之前,我们可以从整体的编译流程上去猜测。首先,Jack需要Jill将依赖库转换成需要.jayce格式的jar包,以便于Jack做prebuild,也就是预先生成dex文件,这一步无疑是比较耗时的,而这些操作在传统的编译模式下都是不需要的,也就造成了Jack在clean build的情况下的耗时。但是在之后的编译中,由于prebuild的存在,jack的速度会快起来,这也是rebuild存在的意义。

总的来说,Jack现在还不是非常的成熟,想要大规模的使用,Google还得加把劲优化才行啊!