如何开发一款高性能的 gradle transform
Posted code小生
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了如何开发一款高性能的 gradle transform相关的知识,希望对你有一定的参考价值。
code小生,一个专注 android 领域的技术平台
前言
对于java开发者来说,大家好像都比较喜欢在编译期间搞事儿,比如为了做到AOP编程,大家都喜欢利用字节码生成技术,常用的有无痕埋点,方法耗时统计等等。那么Android中具体是如何做到这些的呢?所谓字节码插桩技术,其实就是修改已经编译的class文件,往里面添加自己的字节码,然后打包的时候打包的是修改后的class文件。为了便捷的修改编译后的class文件,Google爸爸开发了一套gradle相关的库,也就是gradle-transform-api,利用这个工具,我们可以自己实现class文件修改,下面我们看看具体做法。
1.实现一个gradle Plugin
想要使用gradle-transform-api,我们必须要先实现一个gradle插件,然后在插件中注册一个Transform,实现插件有三种方式,这里做下简单介绍,详细的请看 官方文档
1.1 直接在build.gradle文件中实现:
class GreetingPlugin implements Plugin<Project> {
void apply(Project project) {
project.task('hello') {
doLast {
println 'Hello from the GreetingPlugin'
}
}
}
}
// Apply the plugin
apply plugin: GreetingPlugin
关键是实现Plugin
1.2 创建一个buildsrc模块
第一种方式不适合用来开发复杂的插件,如果只是自己的项目需要,插件又比较复杂,我们可以创建一个buildsrc模块,然后把上面的GreetingPlugin类移动到这个模块中,这个和下面另一种方式比较接近,这里就不做详细介绍了,有兴趣的可以看官方文档。
1.3 单独工程
创建一个自己的module或工程,这种方式是最常用的,可以看下目录结构
├── pluginmodule
│ ├── build.gradle
│ └── src
│ └── main
│ ├── groovy
│ │ └── com
│ │ └── jianglei
│ │ └── plugin
│ │ ├── MethodTracePluginPlugin.groovy
│ └── resources
│ └── META-INF
│ └── gradle-plugins
│ └── com.jianglei.method-tracer.properties
其中,JlLogPlugin就是插件实现者:
class MethodTracePlugin implements Plugin<Project> {
@Override
void apply(Project project) {
project.getExtensions()
.create("methodTrace", MethodTraceExtension.class)
//确保只能在含有application的build.gradle文件中引入
if (!project.plugins.hasPlugin('com.android.application')) {
throw new GradleException('Android Application plugin required')
}
project.getExtensions().findByType(AppExtension.class)
.registerTransform(new MethodTraceTransform(project))
}
}
另外,com.jianglei.method-tracer.properties用来宣告谁是插件实现者,文件的名字也就是你要引用时的名字
apply plugin: 'com.jianglei.jllog'
文件里面长这样:
implementation-class=com.jianglei.plugin.MethodTracePlugin
2. 实现一个transform
我们在第一步中注册了一个transform,这个transform能够输入编译后的class文件,然后我们处理class文件,将修改后的文件输出。
代码很简单:
class MethodTraceTransform extends Transform {
private Project project
MethodTraceTransform(Project project) {
this.project = project
}
@Override
String getName() {
return "MethodTrace"
}
@Override
Set<QualifiedContent.ContentType> getInputTypes() {
return TransformManager.CONTENT_CLASS
}
@Override
Set<? super QualifiedContent.Scope> getScopes() {
//此次是只允许在主module(build.gradle中含有com.android.application插件)
//所以我们需要修改所有的module
return TransformManager.SCOPE_FULL_PROJECT
}
@Override
boolean isIncremental() {
return true
}
@Override
void transform(TransformInvocation transformInvocation) throws TransformException,
InterruptedException, IOException {
}
}
实现transform的核心就是覆写这几个方法,我们一个个说明。
2.1 getName()
这个方法只是用来定义transform任务的名称,随意定一个就好。
2.2 getInputTypes()
这个用来限定这个transform能处理的文件类型,一般来说我们要处理的都是class文件,就返回TransformManager.CONTENT_CLASS,当然如果你是想要处理资源文件,可以使用TransformManager.CONTENT_RESOURCES,这里按需要来就好,还有其它配置就要查看官网javadoc文档了,这里需要科学上网。
2.3 getScopes()
2.2中我们指定的是要处理那种文件,那么,这里我们要指定的的就是哪些文件了。比如说我们如果想处理class文件,但class文件可以是当前module的,也可以是子module的,还可以是第三方jar包中的,这里就是用来指定这个的,我们看下有哪些选项:
public static enum Scope implements QualifiedContent.ScopeType {
PROJECT(1),
SUB_PROJECTS(4),
EXTERNAL_LIBRARIES(16),
TESTED_CODE(32),
PROVIDED_ONLY(64),
/** @deprecated */
@Deprecated
PROJECT_LOCAL_DEPS(2),
/** @deprecated */
@Deprecated
SUB_PROJECTS_LOCAL_DEPS(8);
private final int value;
private Scope(int value) {
this.value = value;
}
public int getValue() {
return this.value;
}
}
基本上从名字上也可以看出作用范围了,当然具体怎么选还是要注意些的,后面我们会介绍。
2.4 inIncremental()
是否支持增量编译,按道理讲,一个合格的transform应该支持增量编译。
2.5 transform()
这个方法就是我们要处理的重点了,我们在这个方法中获取输入的class文件,然后做些修改,最后输出修改后的class文件。主要也是分为三步走:
2.5.1 获取输入文件
transformInvocation.inputs.each {input ->
transformInvocation.inputs
.each { input ->
transformSrc(transformInvocation, input)
transformJar(transformInvocation, input)
}
}
这里的输入文件分为两种, 一种是本module自己的src下的源码编译后的class文件,一种是第三方的jar包文件,我们需要分开单独处理。
2.5.2 获取输出路径
输入文件有了,我们要先确定输出路径,这里要注意,输出路径必须用特殊方式获取,而不能自己随意指定,否则下一个任务就无法获取你这次的输出文件了,编译失败。
对于源码编译的class文件输出路径这样获取:
def outputDirFile = transformInvocation.outputProvider.getContentLocation(
directoryInput.name, directoryInput.contentTypes, directoryInput.scopes,
Format.DIRECTORY
)
对于jar包的输出路径这样获取:
def outputFile = transformInvocation.outputProvider.getContentLocation(
jarInput.name, jarInput.contentTypes, jarInput.scopes,
Format.JAR
)
2.5.3处理输入文件
经过上面的步骤,我们能获取到输入文件,也确定了输出路径,现在我们只要来处理这些文件,然后输出到输出路径就可以了:
首先处理src下源码编译生成的class文件:
private void transformSrc(TransformInput input){
input.directoryInputs.each { directoryInput ->
//这里是为了把所有目录下的文件存到一个list集合中
def allFiles = DirectoryUtils.getAllFiles(directoryInput.file)
for (File file : allFiles) {
//比如上一个文件输入的全路径是 /A/B/com/jianglei/test/Test.class,获取的输出路径是
// /transform/MethodTrace/debug,替换后就变成了/transform/MethodTrance/debug/com/jianglei/test/Test.class
def outputFullPath = file.absolutePath.replace(inputFilePath, outputFilePath)
def outputFile = new File(outputFullPath)
if (!outputFile.parentFile.exists()) {
outputFile.parentFile.mkdirs()
}
//这个方法中你可以尽情修改class文件,然后输出到outputFile中即可,
//就算不修改,至少也要将原有文件拷贝过去
MethodTraceUtils.traceFile(file, outputFile)
}
}
}
注释写的很清楚,这里注意,就算你不想修改这个class文件,你也应该将它原样拷贝过去,否则这个文件就丢失了。
接着,我们处理jar文件:
private void transformJar(TransformInvocation transformInvocation, TransformInput input,
boolean isIncrement, boolean isConfigChange,
Map<String, String> lastJarMap, Set<String> curJars, MethodTraceExtension extension) {
for (JarInput jarInput : input.jarInputs) {
def outputFile = transformInvocation.outputProvider.getContentLocation(
jarInput.name, jarInput.contentTypes, jarInput.scopes,
Format.JAR
)
//这个方法就是处理jar文件,然后将处理后的jar文件输出到输出目录
MethodTraceUtils.traceJar(jarInput, outputFile)
}
上面其实就是遍历每个jar文件去处理,那么具体如何处理的?
public static void traceJar(JarInput jarInput, File outputFile) {
def jar = jarInput.file
LogUtils.i("正在处理jar:" + jarInput.name)
//jar包解压的临时位置
def tmpDir = outputFile.parentFile.absolutePath + File.separator + outputFile
.name.replace(".jar", File.separator)
def tmpFile = new File(tmpDir)
tmpFile.mkdirs()
//先解压缩到临时目录
MyZipUtils.unzip(jar.absolutePath, tmpFile.absolutePath)
//收集解压缩后的所有文件
def allFiles = new ArrayList()
collectFiles(tmpFile, allFiles)
allFiles.each {
if (isNeedTraceClass(it)) {
//将处理后的文件命名成原名称-new形式
def tracedFile = new File(tmpFile.absolutePath + "-new")
//去修改单个class文件
traceFile(it, tracedFile)
//处理完后用新的文件替换原有文件
it.delete()
tracedFile.renameTo(it)
}
}
MyZipUtils.zip(tmpFile.absolutePath, outputFile.absolutePath)
tmpFile.deleteDir()
}
jar文件和普通的class文件相比多了解压缩过程,解压缩后我们就可以按照普通的class文件一个个去处理,最后我们将处理后class文件夹重新压缩到输出目录即可,这里注意删掉中间产生的解压缩目录即可。
2.5.4 小结
经过上面的步骤,可以说我们就成功的实现了一个gradle插件,能够拦截所有的class文件,并且修改这些class文件,成功做到了AOP编程,当然具体如果修改class文件,这不在本文讨论范围内,大家可以自己去查找ASM等技术。
3. 让插件可以配置
现在,我们的插件开发完了,那这样够了吗?比如说不想对第三方的jar包做处理(不处理就直接复制过去)怎么办?又或者我只想某个时候去处理第三方jar包,某些时候又不想,这个时候我们就必须让我们的插件可以配置了。很简单,分两步走:
3.1 定义配置类
class MethodTraceExtension {
/**
* 是否追踪第三方依赖的方法执行数据
*/
boolean traceThirdLibrary = false
boolean getTraceThirdLibrary() {
return traceThirdLibrary
void setTraceThirdLibrary(boolean traceThirdLibrary) {
this.traceThirdLibrary = traceThirdLibrary
}
}
3.2 注册配置
这里我们要在自定义的Plugin中注册
class MethodTracePlugin implements Plugin<Project> {
@Override
void apply(Project project) {
project.getExtensions()
.create("methodTrace", MethodTraceExtension.class)
……
}
}
注册后,我们可以在引入了这个插件的build文件中做出如下配置
apply plugin: 'com.jianglei.method-tracer'
……
methodTrace{
traceThirdLibrary = false
}
3.3 获取配置
获取配置很简单,只要用如下代码就可以了:
//获取配置信息
MethodTraceExtension extension = project.getExtensions().findByType(MethodTraceExtension.class)
问题是你什么时候获取这个配置信息呢?刚开始,我在注册这个配置后直接去获取:
@Override
void apply(Project project) {
//注册配置
project.getExtensions()
.create("methodTrace", MethodTraceExtension.class)
//获取配置信息
MethodTraceExtension extension = project.getExtensions().findByType(MethodTraceExtension.class)
……
}
我希望在这里获取配置然后传入到Transform中去,事实上这是不可取的,此处的apply方法被调用时机是
apply plugin代码被调用的时候,此时,我们在build.gradle中的配置代码快还没有被调用,所以是取不到我们想要的配置的,取到的都是默认值。
那么到底我们应该怎么获取呢?其实我们只要在transform()方法中获取就可以了,这个时候build.gradle中配置的代码已经执行过了:
@Override
void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
//获取配置信息
MethodTraceExtension extension = project.getExtensions().findByType(MethodTraceExtension.class)
……
}
4. 优化
现在,我们有了一个可配置的插件去修改所有的class文件了,功能上的需求我们已经完成了,但是,性能上够了吗?
4.1 gradle插件应该在application模块引入还是library模块引入?
目前,我们的插件都是直接在application模块中引入的,那么多模块情况下怎么办?每个模块都要引入吗?可以只在主模块引入吗?应该只在主模块引入吗?
4.1.1 只在主模块引入
我们知道,butterknife是需要在每个模块都引入的,其实,对于多模块来说,我们完全可以只在application主模块中引入插件,这里要注意Transform中的getScopes()方法:
@Override
Set<? super QualifiedContent.Scope> getScopes() {
//此次是只允许在主module(build.gradle中含有com.android.application插件)
//所以我们需要修改所有的module
return TransformManager.SCOPE_FULL_PROJECT
}
这里的SCOPE_FULL_PROJECT其实是这样的:
SCOPE_FULL_PROJECT = Sets.immutableEnumSet(Scope.PROJECT, new Scope[]{Scope.SUB_PROJECTS, Scope.EXTERNAL_LIBRARIES});
说明这里处理的模块包括本模块,子模块以及第三方jar包,这样我们就能在主模块中处理所有的class文件了,可见我们是可以只在主模块中引入的,这样做的话,所有子模块会以jar包的形式作为输入。
4.1.2 在每个模块都引入
那么如果想要在每个module中都引入该如何做呢?
首先是注册方式要修改:
@Override
void apply(Project project) {
project.getExtensions()
.create("methodTrace", MethodTraceExtension.class)
def extension = project.getExtensions().findByType(AppExtension.class)
def isForApplication = true
if (extension == null) {
//说明当前使用在library中
extension = project.getExtensions().findByType(LibraryExtension.class)
isForApplication = false
}
extension.registerTransform(new MethodTraceTransform(project,isForApplication))
}
关键是我们在Transform中要记录当前是应用于主模块还是子模块了。
这种模式下,每一个模块都会执行自己的transform()方法,所以这里的getScopes()方法要做些修改:
@Override
Set<? super QualifiedContent.Scope> getScopes() {
def scopes = new HashSet()
scopes.add(QualifiedContent.Scope.PROJECT)
if (isForApplication) {
//application module中加入此项可以处理第三方jar包
scopes.add(QualifiedContent.Scope.EXTERNAL_LIBRARIES)
}
return scopes
}
这里对于主模块的情况下应该额外处理第三方jar包,子模块只要处理自己的项目代码即可。
其实进过实验,所有子模块的依赖的第三方jar包只会在处理主模块中输入,换句话说子模块是永远不可能处理第三方jar包的。
4.1.3 小结
两种方式都是可以的,那么到底该选那种呢?有什么选择依据吗?从上面的介绍来看没有,而且应用所有子module的方式编写起来似乎还要复杂一点,那是不是应该选择只在主模块引入插件呢?其实不然,最大的区别下面会讲到,到时候自然有结果。
4.2 如何增量编译
通过上面的介绍,完成一个插件已经不是问题,但是这里有一个问题,每次编译时,transform()方法都会执行,我们会遍历所有的class文件,会解压缩所有jar文件,然后重新压缩成所有jar文件,但事实上,一次编译有可能只改动了一个class文件,我们能不能做到只重新修改这一个class文件呢?gradle其实是提供了方法的。
4.2.1 gradle transform的增量机制
transform-api将输入文件分成了两类:
DirectoryInput,包装的是源码对应的class文件,长这样:
public interface DirectoryInput extends QualifiedContent {
Map<File, Status> getChangedFiles();
}
换句话说,我们可以通过以下方式获取改动的class文件:
input.directoryInputs.each { directoryInput ->
directoryInput.changedFiles.each{changeFileEntry->
def status = changeFileEntry.value;
}
}
这样我们可以遍历所有改动的文件,而且可以获取每个改动文件的状态,有4种:
public enum Status {
NOTCHANGED,
ADDED,
CHANGED,
REMOVED;
private Status() {
}
}
第一次编译或clean后重新编译directory.changedFiles为空,需要做好区分
经测试,删除一个java文件,对应的class文件输入不会出现REMOVED状态,也就是不能从changeFiles里面获取被删除的文件
JarInput 和DirectoryInput不同,JarInput只能获取状态,也有4种状态:
public interface JarInput extends QualifiedContent {
Status getStatus();
}
也就是说,我们如果想要增量编译,应该处理所有非 Status.NOTCHANGED状态的jar包,同样如果移除了一个依赖,这个jar包就再也不会输入,自然也就不会出现Status.REMOVED状态的jar包了。
4.2.2 增量编译要解决的问题
有了以上对gradle transform增量机制的了解,相信大家都对如何支持增量编译有了一个基本的了解,但是想要开发一个健壮的、支持增量的插件还有很多问题要解决,我们一一探讨。
4.2.2.1 如何区分未编译和未修改
之前提到,对于DirectoryInput来说,未编译或clean后重新编译时Directory.changedFiles为空,未修改时也是为空,前一种状态下我们需要处理所有的文件,后一种状态下又不应该处理任何文件,同样,JarInput也面对这个问题,要解决也很简单,这里给出一种简单方案,首次编译时生成一个标记文件,下一次编译时,如果修改文件为空,我们判断该标记文件是否存在,存在就是未修改,否则就是首次或clean后重新编译。当然上次编译也会有文件输出,我们可以直接拿任一输出文件做这个标记文件。
4.2.2.2 如何解决增量编译时包重复问题
一般来说,如果我们依赖了一个第三方jar包,比如:
implementation "commons-io:commons-io:2.4"
首次编译会在编译输出目录下生成一个文件,比如:
/home/jianglei/AndroidStudioProjects/ASMStudy/app/build/intermediates/transforms/MethodTrace/debug/32.jar
现在我们注释掉这个包的引入,重新编译,之前我们提过,删除了一个jar包引用后我们是收不到任何信息的,无法对这个包做任何处理,因为它根本就不会被输入,那么自然这个32.jar还在那里,这个时候我们在重新引入刚才被移除的依赖,这个时候生成的文件变成了:
/home/jianglei/AndroidStudioProjects/ASMStudy/app/build/intermediates/transforms/MethodTrace/debug/33.jar
这个时候问题就来了,32.jar和33.jar其实是一个jar包,编译时自然会出现类冲突,而且这个冲突还比较尴尬,不好排查,因为gradle文件是没有任何问题的,最简单的方法就是clean后重新编译,这个问题自然不存在了,但一般开发者是没有这个意识的,这样做也太麻烦了,删掉一个依赖再重新引入是很正常操作,为什么非要先clean呢?
现在,我们来看下解决方案:
解决思路很简单,要是我们能够找到此次编译时哪些jar包被删除了,我们自己手动删除该jar包上次编译的输出文件不就解决了冲突问题吗?所以我们完全可以自己记录下每次编译时有哪些jar包参与了编译,并且输出到了哪里,如下:
{
"commons-io:commons-io:2.4": "/home/jianglei/AndroidStudioProjects/ASMStudy/app/build/intermediates/transforms/MethodTrace/debug/5.jar",
……
}
那么此次编译时,我们读取上次的文件,对比两次参与编译的jar包,如果有删除的,我们自己删除该jar包对应的输出文件即可。
4.2.2.4 如何判断配置文件改变
和上面的class文件改变或jar改变都不一样,配置文件改变transform是得不到任何额外的信息的,但你不能不处理,比如说上次配置文件定义如下:
methodTrace{
traceThirdLibrary = false
}
编译后自然不会处理第三方的jar包,但现在将其改成了false , 这个时候,上次编译的所有结果都要重来,因为这次需要处理第三方jar包了。解决方案也很简单,既然gradle没有通知我们配置文件改变了,我们自己记录上次配置文件,和本次编译对比,如果配置文件改变就全部重来,这个时候记录的文件就变成了这样:
{
"extension": {
"traceThirdLibrary": true
},
"jarMap": {
"commons-io:commons-io:2.4": "/home/jianglei/AndroidStudioProjects/ASMStudy/app/build/intermediates/transforms/MethodTrace/debug/5.jar",
……
}
}
这里有一个问题没有解决,即使是自己对比配置文件是否改变 ,这些代码都是写在transform()方法中的,如果此次编译只是修改配置文件,没有修改任何东西,gradle认为你什么都没有改动,直接不调用transform()方法了,这个就意味着你给了配置文件增量编译不生效,暂时没有好的解决方案,只能重新CLEAN,或者修改其他的java文件,都能触发重新编译。如果大家有更好的解决方案,希望能指出来。
5. 查缺补漏
之前我们还有一个问题没有解决,那就是gradle插件到底是应该只在主module中引入还是再所有的module中都引入。在我看来,衡量的关键点就是编译速度,如果只在主module中引入的话,子module其实是以jar包的形式作为输入文件来 处理的,这样我们就算只修改了子module中一个文件,我们都需要将整个jar解压,然后处理该jar中的所有class文件,最后还得压缩一次,多做了无用功;如果我们放在所有的module中引入的话,针对这种情况我们只需要处理改动的class文件即可,能节省很多时间,所以我推荐放到所有module引入。
6. 总结
有了上面的知识,我相信大家应该都能开发出一个健壮的、支持增量编译的插件了,然后你就能利用字节码插桩技术为所欲为了,上面这些源码大家可以点这里:https://github.com/FamliarMan/ASMStudy , 当然这些都是我自己瞎琢磨出来的,网上似乎没有查到相关资料,如果有错误,恳请指正,不甚感激!
推荐阅读
如果你想要跟大家分享你的文章,
以上是关于如何开发一款高性能的 gradle transform的主要内容,如果未能解决你的问题,请参考以下文章
手把手完成商业级社交App开发 进阶Android高级工程师