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
之间是串行执行的,执行流程如下图所示:
每一个Tranform 都有一个输入一个输出,上一个Transform的输出是下一个transform的输入
Tips:输出地址不是由你任意指定的。而是根据输入的内容、作用范围等由TransformOutputProvider
生成,比如,你要获取输出路径:
1 | String dest = outputProvider.getContentLocation(directoryInput.name, |
Tranform原理之数据流动:
每个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两个维度过滤
ContentType,顾名思义,就是数据类型,在插件开发中,我们一般只能使用CLASSES和RESOURCES两种类型,注意,其中的CLASSES已经包含了class文件和jar文件
从图中可以看到,除了CLASSES和RESOURCES,还有一些我们开发过程无法使用的类型,比如DEX文件,这些隐藏类型在一个独立的枚举类ExtendedContentType中,这些类型只能给Android编译器使用。另外,我们一般使用TransformManager中提供的几个常用的ContentType集合和Scope集合,如果是要处理所有class和jar的字节码,ContentType我们一般使用TransformManager.CONTENT_CLASS。
Scope
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 |
|
配置 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
24public interface TransformInvocation {
// 返回transform运行的上下文,在android gradle plugin中有唯一的实现类TransformTask
Context getContext();
// 获取transform的输入
Collection<TransformInput> getInputs();
/**
* Returns the referenced-only inputs which are not consumed by this transformation.
* @return the referenced-only inputs.
*/
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}
*/
Collection<SecondaryInput> getSecondaryInputs();
//TransformOutputProvider用于删除输出目录或者创建文件对应的生成目录
TransformOutputProvider getOutputProvider();
// transform过程是否支持增量编译
boolean isIncremental();
}
可以看出TransformInvovation包含了输入输出相关信息,
其输出内容是由TransformOutProvider来做处理,TransformOutputProvider#getContentLocation()方法可以获取文件的输出目录,如果目录存在的话直接返回,如果不存在就会重新创建一个在执行编译过程中会生成对应的目录,
例如在/app/build/intermediates/transforms目录下生成了一个名为ajx
的目录,这个名称就是根据自定义的Transform类getName()
方法返回的字符串来的。
inputs 包含两个部分:1
2
3
4public interface TransformInput {
Collection<JarInput> getJarInputs();
Collection<DirectoryInput> getDirectoryInputs();
}
看接口方法可知,包含了 jar 包和目录。子 module 的 java 文件在编译过程中也会生成一个 jar 包然后编译到主工程中
Transform的输入输出
可以通过TransformInvocation
来获取输入,同时也获得了输出的功能
1 | public void transform(TransformInvocation invocation) { |
我们在做完自定义的处理后,如果想自己输出一些东西怎么办? 比如一个class文件,就可以通过TransformOutputProvider
来完成。比如下面这段代码:
1 | File dest = invocation.getOutputProvider().getContentLocation( |
这段代码就是在本工程(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
9public <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 和 DirectoryInput1
2
3public interface JarInput extends QualifiedContent {
Status getStatus();
}
Status 是一个枚举:1
2
3
4
5
6public enum Status {
NOTCHANGED,
ADDED,
CHANGED,
REMOVED;
}
所以在输入源中, 获取了 JarInput 的对象时,可以同时得到每个 jar 的变更状态。需要注意的是:比如先 clean 再编译时, jar 的状态是 NOTCHANGED
再看看 DirectoryInput:1
2
3public interface DirectoryInput extends QualifiedContent {
Map<File, Status> getChangedFiles();
}
changedFiles 是一个 Map,其中会包含所有变更后的文件,以及每个文件对应的状态。同样需要注意的是:先 clean 再编译时, changedFiles 是空的。
所以在处理增量时,只需要根据每个文件的状态进行相应的处理即可,不需要每次所有流程都重新来一遍。
1 | public class CustomTransform extends Transform { |
关于是否支持增量 编译
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
如果不支持增量编译,就在处理.class之前把之前的输出目录中的文件删除。
Tranform的优化:增量与并发
果直接这样使用,会大大拖慢编译时间,为了解决这个问题,摸索了一段时间后,也借鉴了Android编译器中Desugar等几个Transform的实现,发现我们可以使用增量编译,并且上面transform方法遍历处理每个jar/class的流程,其实可以并发处理,加上一般编译流程都是在PC上,所以我们可以尽量敲诈机器的资源。
想要开启增量编译,我们需要重写Transform的这个接口,返回true。
1 |
|
虽然开启了增量编译,但也并非每次编译过程都是支持增量的,毕竟一次clean build完全没有增量的基础,所以,我们需要检查当前编译是否是增量编译。
1、如果不是增量编译,则清空output目录,然后按照前面的方式,逐个class/jar处理
2、如果是增量编译,则要检查每个文件的Status,Status分四种,并且对这四种文件的操作也不尽相同
NOTCHANGED: 当前文件不需处理,甚至复制操作都不用;
ADDED、CHANGED: 正常处理,输出给下一个任务;
REMOVED: 移除outputProvider获取路径对应的文件。
代码如下所述
1 |
|
注册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 | ClassPool pool = ClassPool.getDefault(); |
面代码就实现了修改MyClass
类的父类为ParentClass
.