Gradle插件之Transform

Gradle插件之Tranform

Transform

修改一个class 得知道我们什么时候编译完成,并且要在class文件被转化成dex文件之前去修改,从1.5.0-beta1开始,android的gradle插件引入了com.android.build.api.transform.Transform,可以点击 transform-api 查看相关内容。

使用场景

  • 我们需要对编译class文件做自定义的处理。
  • 读取编译产生的class文件,做一些其他事情,

Transform原理

Tranform原理介绍

  • 从 Android Gradle 1.5.0-beta1 开始,引入了 com.android.build.api.transform.Transform,其实 Transform 也是一个 task,每新建一个 Transform,都有一个新的 task 和它对应

在1.5以下,preDex这个task会将依赖的module 编译后的class打成jar,然后Dex这个tasj则会将所有的class打包成dex
1.5以上,preDex和Dex这两个task已经消失,取而代之的是 TransfromClassesWithDexForDebug

  • 在 .java 文件 —-> .class 文件 —-> .dex 文件的过程中,是通过一个一个的 task 执行完成其中的每一个步骤的。Trasnform 注册之后,其执行的时机是在项目被打包成 dex 文件之前,正是操纵字节码的时机,多个 Transform 之间是串行执行的,执行流程如下图所示:

image.png

每一个Tranform 都有一个输入一个输出,上一个Transform的输出是下一个transform的输入

Tips:输出地址不是由你任意指定的。而是根据输入的内容、作用范围等由TransformOutputProvider生成,比如,你要获取输出路径:

1
2
String dest = outputProvider.getContentLocation(directoryInput.name,
directoryInput.contentTypes, directoryInput.scopes, Format.DIRECTORY)

Tranform原理之数据流动:

image.png


每个Tranform其实都是一个gradle task,Android编译器中的TaskManager将每个Tranform串联起来,第一个tranform接收来自javac编译的结果,以及已经拉取到本地的第三方依赖(jar、aar),还有resource 资源(这里的resource指的是asset目录下的资源)这些编译的中间产物,在tranform组成的链条上流动,每个tranform节点可以对class进行处理再传递给下一个tranform,我们常见的混淆、Desugar等逻辑,都是封装在一个个tranform中,而我们定义的tranform会插入到这个tranform链条的自前面。


上图指的是消费型 即当前的tranform需要将消费类型输出给下一个tranform,另一种是引用型,当前tranform可以读取这些输入而不需要输出给下一个tranform类型,比如instant run 就是这种方式,检查两次编译间的diff
TaskManager#createPostCompilationTasks

Tranfrom原理之数据过滤机制

ContenType

数据输入可以通过Scope和ContentType两个维度过滤
image.png


ContentType,顾名思义,就是数据类型,在插件开发中,我们一般只能使用CLASSES和RESOURCES两种类型,注意,其中的CLASSES已经包含了class文件和jar文件
image.png




从图中可以看到,除了CLASSES和RESOURCES,还有一些我们开发过程无法使用的类型,比如DEX文件,这些隐藏类型在一个独立的枚举类ExtendedContentType中,这些类型只能给Android编译器使用。另外,我们一般使用TransformManager中提供的几个常用的ContentType集合和Scope集合,如果是要处理所有class和jar的字节码,ContentType我们一般使用TransformManager.CONTENT_CLASS。

Scope

image.png


TransformManager有几个常用的Scope集合方便开发者使用。
如果是要处理所有class字节码,Scope我们一般使用TransformManager.SCOPE_FULL_PROJECT

集成Tranform类实现以下几个方法

  • String getName()

该方法表示当前Transform在task列表中的名字,返回值最终经过一系列的拼接,具体拼接实现在TransformManager的getTaskNamePrefix()方法中,拼接格式:transform${InputType1}And${InputType2}And${InputTypeN}And${name}For${flavor}${BuildType}

  • Set getInputTypes()

指定输入类型,可以指定要处理文件的类型

  • Set getScopes()

指定Transform的作用范围

  • boolean isIncremental()

该方法表示当前Transform是否支持增量编译

  • void transform(Context context, Collection inputs,

    Collection<TransformInput> referencedInputs,<br />                  TransformOutputProvider outputProvider, boolean isIncremental)
    

其中 // Transform的inputs有两种类型,一种是目录,一种是jar包,要分开遍历
_
在app->build->intermediates->transforms中,可以看到所有的Transform,包括我们刚才自定义的Transform。

核心api

如何获取class文件 TransformInvocation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Override
public Set<QualifiedContent.ContentType> getInputTypes() {
return TransformManager.CONTENT_CLASS;
}

@Override
public Set<? super QualifiedContent.Scope> getScopes() {
return TransformManager.SCOPE_FULL_PROJECT;
}

@Override
public boolean isIncremental() {
return true;
}

配置 Transform 的输入类型为 Class, 作用域为全工程。 这样在 transform(TransformInvocation transformInvocation) 方法中, transformInvocation.inputs 会传入工程内所有的 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
public interface TransformInvocation {
// 返回transform运行的上下文,在android gradle plugin中有唯一的实现类TransformTask
@NonNull
Context getContext();
// 获取transform的输入
@NonNull
Collection<TransformInput> getInputs();
/**
* Returns the referenced-only inputs which are not consumed by this transformation.
* @return the referenced-only inputs.
*/
@NonNull Collection<TransformInput> getReferencedInputs();
/**
* Returns the list of secondary file changes since last. Only secondary files that this
* transform can handle incrementally will be part of this change set.
* @return the list of changes impacting a {@link SecondaryInput}
*/
@NonNull Collection<SecondaryInput> getSecondaryInputs();
//TransformOutputProvider用于删除输出目录或者创建文件对应的生成目录
@Nullable
TransformOutputProvider getOutputProvider();
// transform过程是否支持增量编译
boolean isIncremental();
}

可以看出TransformInvovation包含了输入输出相关信息,
其输出内容是由TransformOutProvider来做处理,TransformOutputProvider#getContentLocation()方法可以获取文件的输出目录,如果目录存在的话直接返回,如果不存在就会重新创建一个在执行编译过程中会生成对应的目录,
例如在/app/build/intermediates/transforms目录下生成了一个名为ajx的目录,这个名称就是根据自定义的Transform类getName()方法返回的字符串来的。

inputs 包含两个部分:

1
2
3
4
public interface TransformInput {
Collection<JarInput> getJarInputs();
Collection<DirectoryInput> getDirectoryInputs();
}

看接口方法可知,包含了 jar 包和目录。子 module 的 java 文件在编译过程中也会生成一个 jar 包然后编译到主工程中

Transform的输入输出

可以通过TransformInvocation来获取输入,同时也获得了输出的功能

1
2
3
4
5
6
7
8
9
10
11
12
public void transform(TransformInvocation invocation) {
for (TransformInput input : invocation.getInputs()) {
input.getJarInputs().parallelStream().forEach(jarInput -> {
File src = jarInput.getFile();
JarFile jarFile = new JarFile(file);
Enumeration<JarEntry> entries = jarFile.entries();
while (entries.hasMoreElements()) {
JarEntry entry = entries.nextElement();
//处理
}
}
}

我们在做完自定义的处理后,如果想自己输出一些东西怎么办? 比如一个class文件,就可以通过TransformOutputProvider来完成。比如下面这段代码:

1
2
3
4
5
File dest = invocation.getOutputProvider().getContentLocation(
"susion",
TransformManager.CONTENT_CLASS,
ImmutableSet.of(QualifiedContent.Scope.PROJECT),
Format.DIRECTORY);

这段代码就是在本工程(ImmutableSet.of(QualifiedContent.Scope.PROJECT))下产生一个目录(Format.DIRECTORY), 目录的名字是(susion),里面的内容是TransformManager.CONTENT_CLASS
创建这个文件夹后,我们就可以向其中写入一些内容,比如class文件。

Transform 与 Gradle Task 之间的关系?

Gradle 包中有一个 TransformManager 的类,用来管理所有的 Transform。 在里面找到了这样的代码:

1
2
3
4
5
6
7
8
9
public <T extends Transform> Optional<AndroidTask<TransformTask>> addTransform(TaskFactory taskFactory, TransformVariantScope scope, T transform, ConfigActionCallback<T> callback) {
...
this.transforms.add(transform);
AndroidTask task1 = this.taskRegistry.create(taskFactory, new ConfigAction(scope.getFullVariantName(), taskName, transform, inputStreams, referencedStreams, outputStream, this.recorder, callback));
...
return Optional.ofNullable(task1);
}
}
}

addTransform 方法在执行过程中,会将 Transform 包装成一个 AndroidTask 对象。
所以可以理解为一个 Transform 就是一个 Task

如何得到文件的增量

再回到 TransformInput 这个接口,输入源分为 JarInput 和 DirectoryInput

1
2
3
public interface JarInput extends QualifiedContent {
Status getStatus();
}

Status 是一个枚举:

1
2
3
4
5
6
public enum Status {
NOTCHANGED,
ADDED,
CHANGED,
REMOVED;
}

所以在输入源中, 获取了 JarInput 的对象时,可以同时得到每个 jar 的变更状态。需要注意的是:比如先 clean 再编译时, jar 的状态是 NOTCHANGED
再看看 DirectoryInput:

1
2
3
public interface DirectoryInput extends QualifiedContent {
Map<File, Status> getChangedFiles();
}

changedFiles 是一个 Map,其中会包含所有变更后的文件,以及每个文件对应的状态。同样需要注意的是:先 clean 再编译时, changedFiles 是空的。
所以在处理增量时,只需要根据每个文件的状态进行相应的处理即可,不需要每次所有流程都重新来一遍。

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
public class CustomTransform extends Transform {
public static final String TAG = "CustomTransform";
public CustomTransform() {
super();
}
@Override
public String getName() {
return "CustomTransform";
}
@Override
public void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
super.transform(transformInvocation);
//当前是否是增量编译
boolean isIncremental = transformInvocation.isIncremental();
//消费型输入,可以从中获取jar包和class文件夹路径。需要输出给下一个任务
Collection<TransformInput> inputs = transformInvocation.getInputs();
//引用型输入,无需输出。
Collection<TransformInput> referencedInputs = transformInvocation.getReferencedInputs();
//OutputProvider管理输出路径,如果消费型输入为空,你会发现OutputProvider == null
TransformOutputProvider outputProvider = transformInvocation.getOutputProvider();
for(TransformInput input : inputs) {
for(JarInput jarInput : input.getJarInputs()) {
File dest = outputProvider.getContentLocation(
jarInput.getFile().getAbsolutePath(),
jarInput.getContentTypes(),
jarInput.getScopes(),
Format.JAR);
//将修改过的字节码copy到dest,就可以实现编译期间干预字节码的目的了
FileUtils.copyFile(jarInput.getFile(), dest);
}
for(DirectoryInput directoryInput : input.getDirectoryInputs()) {
File dest = outputProvider.getContentLocation(directoryInput.getName(),
directoryInput.getContentTypes(), directoryInput.getScopes(),
Format.DIRECTORY);
//将修改过的字节码copy到dest,就可以实现编译期间干预字节码的目的了
FileUtils.copyDirectory(directoryInput.getFile(), dest);
}
}
}
@Override
public Set<QualifiedContent.ContentType> getInputTypes() {
return TransformManager.CONTENT_CLASS;
}
@Override
public Set<? super QualifiedContent.Scope> getScopes() {
return TransformManager.SCOPE_FULL_PROJECT;
}
@Override
public Set<QualifiedContent.ContentType> getOutputTypes() {
return super.getOutputTypes();
}
@Override
public Set<? super QualifiedContent.Scope> getReferencedScopes() {
return TransformManager.EMPTY_SCOPES;
}
@Override
public Map<String, Object> getParameterInputs() {
return super.getParameterInputs();
}
@Override
public boolean isCacheable() {
return true;
}

@Override
public boolean isIncremental() {
return true; //是否开启增量编译
}
}

关于是否支持增量 编译

Transform 的 isIncremental() 方法表示是否支持增量编译,返回true的话表示支持,

这个时候可以根据 com.android.build.api.transform.TransformInput 来获得更改、移除或者添加的文件目录或者jar包。

JarInput有一个方法是getStatus()来获取 com.android.build.api.transform.Status。Status是一个枚举类,包含了NOTCHANGED、ADDED、CHANGED、REMOVED,所以可以根据JarInput的status来对它进行相应的处理,比如添加或者移除。
DirectoryInput有一个方法getChangedFiles()开获取一个Map集合,所以可以遍历这个Map集合,然后根据File对应的Status来对File进行处理。
如果不支持增量编译,就在处理.class之前把之前的输出目录中的文件删除。

Tranform的优化:增量与并发

果直接这样使用,会大大拖慢编译时间,为了解决这个问题,摸索了一段时间后,也借鉴了Android编译器中Desugar等几个Transform的实现,发现我们可以使用增量编译,并且上面transform方法遍历处理每个jar/class的流程,其实可以并发处理,加上一般编译流程都是在PC上,所以我们可以尽量敲诈机器的资源。
想要开启增量编译,我们需要重写Transform的这个接口,返回true。

1
2
3
4
@Override 
public boolean isIncremental() {
return true;
}

虽然开启了增量编译,但也并非每次编译过程都是支持增量的,毕竟一次clean build完全没有增量的基础,所以,我们需要检查当前编译是否是增量编译。

1、如果不是增量编译,则清空output目录,然后按照前面的方式,逐个class/jar处理
2、如果是增量编译,则要检查每个文件的Status,Status分四种,并且对这四种文件的操作也不尽相同
image.png

NOTCHANGED: 当前文件不需处理,甚至复制操作都不用;

ADDED、CHANGED: 正常处理,输出给下一个任务;

REMOVED: 移除outputProvider获取路径对应的文件。

代码如下所述

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
@Override
public void transform(TransformInvocation transformInvocation){
Collection<TransformInput> inputs = transformInvocation.getInputs();
TransformOutputProvider outputProvider = transformInvocation.getOutputProvider();
boolean isIncremental = transformInvocation.isIncremental();
//如果非增量,则清空旧的输出内容
if(!isIncremental) {
outputProvider.deleteAll();
}
for(TransformInput input : inputs) {
for(JarInput jarInput : input.getJarInputs()) {
Status status = jarInput.getStatus();
File dest = outputProvider.getContentLocation(
jarInput.getName(),
jarInput.getContentTypes(),
jarInput.getScopes(),
Format.JAR);
if(isIncremental && !emptyRun) {
switch(status) {
case NOTCHANGED:
break;
case ADDED:
case CHANGED:
transformJar(jarInput.getFile(), dest, status);
break;
case REMOVED:
if (dest.exists()) {
FileUtils.forceDelete(dest);
}
break;
}
} else {
transformJar(jarInput.getFile(), dest, status);
}
}
for(DirectoryInput directoryInput : input.getDirectoryInputs()) {
File dest = outputProvider.getContentLocation(directoryInput.getName(),
directoryInput.getContentTypes(), directoryInput.getScopes(),
Format.DIRECTORY);
FileUtils.forceMkdir(dest);
if(isIncremental && !emptyRun) {
String srcDirPath = directoryInput.getFile().getAbsolutePath();
String destDirPath = dest.getAbsolutePath();
Map<File, Status> fileStatusMap = directoryInput.getChangedFiles();
for (Map.Entry<File, Status> changedFile : fileStatusMap.entrySet()) {
Status status = changedFile.getValue();
File inputFile = changedFile.getKey();
String destFilePath = inputFile.getAbsolutePath().replace(srcDirPath, destDirPath);
File destFile = new File(destFilePath);
switch (status) {
case NOTCHANGED:
break;
case REMOVED:
if(destFile.exists()) {
FileUtils.forceDelete(destFile);
}
break;
case ADDED:
case CHANGED:
FileUtils.touch(destFile);
transformSingleFile(inputFile, destFile, srcDirPath);
break;
}
}
} else {
transformDir(directoryInput.getFile(), dest);
}
}
}
}


注册tranfrom操作

1
project.android.registerTransform(new ToastTransform())

操作字节码 Javassit

要修改class字节码,我们要是自己手动改二进制文件,有点困难,好在有Javassist这个库,可以让我们直接修改编译后的class二进制代码。
要使用到Javassist,我们得在buildsrc模块下的build.gradle添加依赖包:
compile ‘org.javassist:javassist:3.20.0-GA’

  • ClassPool 对象

通过该对象可以获取已经编译好的类

1
2
3
4
ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.get("com.hc.MyClass");
cc.setSuperclass(pool.get("com.hc.ParentClass"));
cc.writeFile();

面代码就实现了修改MyClass类的父类为ParentClass.

参考

0%