Gradle 学习之 Android 插件的 Transform API

20,082 阅读8分钟

前言

我们已经学习了如何自定义 Gradle 插件以及 Android 插件的基本知识。那我们自定义 Gradle 插件用来干什么呢?总不能只是定义一些简单 Task 吧,那就有点大材小用了。这个时候,Android 插件就派上用场了。因为,从 1.5.0-beta1 版本开始,Android 插件中包含了 Transform API ,它允许第三方插件在将编译后的类文件转换为 dex 文件之前对其进行操作。

本文主要学习 Transform API 的基本知识,然后借助 javassist 来完成一个简单的字节码操作。

初识 Transform API

先来看 Transform 类:

public abstract class Transform

它是一个抽象类,自定义 Transform 时必须继承 Transform 类,并实现它的几个方法:

getName 方法

public abstract String getName();

用于指明 Transform 的名字,也对应了该 Transform 所代表的 Task 名称,例如:

// 设置自定义的Transform对应的Task名称
// 类似:transformClassesWithPreDexForXXX
// 这里应该是:transformClassesWithInjectTransformForxxx
@Override
String getName() {
    return 'InjectTransform'
}

示例中给 Transform 取名:InjectTransform ,编译运行后,可以在 Android Studio 中查到生成的 Task 。

Transform任务名

getInputTypes 方法

public abstract Set<ContentType> getInputTypes();

用于指明 Transform 的输入类型,可以作为输入过滤的手段。在 TransformManager 类中定义了很多类型:

// 代表 javac 编译成的 class 文件,常用
public static final Set<ContentType> CONTENT_CLASS;
public static final Set<ContentType> CONTENT_JARS;
// 这里的 resources 单指 java 的资源
public static final Set<ContentType> CONTENT_RESOURCES;
public static final Set<ContentType> CONTENT_NATIVE_LIBS;
public static final Set<ContentType> CONTENT_DEX;
public static final Set<ContentType> CONTENT_DEX_WITH_RESOURCES;
public static final Set<ContentType> DATA_BINDING_BASE_CLASS_LOG_ARTIFACT;

其中,很多类型是不允许自定义 Transform 来处理的,我们常使用 CONTENT_CLASS 来操作 Class 文件。

getScopes 方法

public abstract Set<? super Scope> getScopes();

用于指明 Transform 的作用域。同样,在 TransformManager 类中定义了几种范围:

// 注意,不同版本值不一样
public static final Set<Scope> EMPTY_SCOPES = ImmutableSet.of();
public static final Set<ScopeType> PROJECT_ONLY;
public static final Set<Scope> SCOPE_FULL_PROJECT; // 常用
public static final Set<ScopeType> SCOPE_FULL_WITH_IR_FOR_DEXING;
public static final Set<ScopeType> SCOPE_FULL_WITH_FEATURES;
public static final Set<ScopeType> SCOPE_FULL_WITH_IR_AND_FEATURES;
public static final Set<ScopeType> SCOPE_FEATURES;
public static final Set<ScopeType> SCOPE_FULL_LIBRARY_WITH_LOCAL_JARS;
public static final Set<ScopeType> SCOPE_IR_FOR_SLICING;

常用的是 SCOPE_FULL_PROJECT ,代表所有 Project 。

确定了 ContentType 和 Scope 后就确定了该自定义 Transform 需要处理的资源流。比如 CONTENT_CLASS 和 SCOPE_FULL_PROJECT 表示了所有项目中 java 编译成的 class 组成的资源流。

isIncremental 方法

public abstract boolean isIncremental();

指明该 Transform 是否支持增量编译。需要注意的是,即使返回了 true ,在某些情况下运行时,它还是会返回 false 的。

transform 方法

/** @deprecated */
@Deprecated
public void transform(Context context, Collection<TransformInput> inputs, Collection<TransformInput> referencedInputs, TransformOutputProvider outputProvider, boolean isIncremental) throws IOException, TransformException, InterruptedException {
    }

public void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        this.transform(transformInvocation.getContext(), transformInvocation.getInputs(), transformInvocation.getReferencedInputs(), transformInvocation.getOutputProvider(), transformInvocation.isIncremental());
    }

重写任意一个方法即可。其中,inputs 是该 Transform 要消费的输入流,有两种格式:jar 和目录格式;referencedInputs 集合仅供参考,不应进行转换,它是受 getReferencedScopes 方法控制的;outputProvider 是用来获取输出目录的,我们要将操作后的文件复制到输出目录中。

TransformInput 类

一个简单的接口类:

public interface TransformInput {
    Collection<JarInput> getJarInputs();

    Collection<DirectoryInput> getDirectoryInputs();
}

所谓 Transform 就是对输入的 class 文件转变成目标字节码文件,TransformInput 就是这些输入文件的抽象。目前它包括两部分:DirectoryInput 集合与 JarInput 集合。

DirectoryInput 代表以源码方式参与项目编译的所有目录结构及其目录下的源码文件,可以借助于它来修改输出文件的目录结构以及目标字节码文件。

JarInput 代表以 jar 包方式参与项目编译的所有本地 jar 包或远程 jar 包,可以借助它来动态添加 jar 包。

TransformOutputProvider 类

也是一个简单的接口:

public interface TransformOutputProvider {
    void deleteAll() throws IOException;

    File getContentLocation(String var1, Set<ContentType> var2, Set<? super Scope> var3, Format var4);
}

调用 getContentLocation 获取输出目录,例如:

// 获取输出目录
def dest = outputProvider.getContentLocation(directoryInput.name,
               directoryInput.contentTypes, directoryInput.scopes, Format.DIRECTORY)

Transform 的工作原理

直接来看图:

Transform原理图

很明显的一个链式结构。其中,红色的 Transform 代表自定义 Transform ,蓝色的代表系统的 Transform 。

每个 Transform 其实都是一个 Gradle 的 Task , Android 编译器中的 TaskManager 会将每个 Transform 串联起来。第一个 Transform 接收来自 javac 编译的结果,以及拉取到本地的第三方依赖和 resource 资源。这些编译的中间产物在 Transform 链上流动,每个 Transform 节点都可以对 class 进行处理再传递到下一个 Transform 。我们自定义的 Transform 会插入到链的最前面,可以在 TaskManager 类的 createPostCompilationTasks 方法中找到相关逻辑:

public void createPostCompilationTasks(VariantScope variantScope) {
    ...
    TransformManager transformManager = variantScope.getTransformManager();
    ...
    // 获取自定义 Transform 列表
    List<Transform> customTransforms = extension.getTransforms();
    List<List<Object>> customTransformsDependencies = extension.getTransformsDependencies();
    int i = 0;
	// 循环添加
    for(int count = customTransforms.size(); i < count; ++i) {
        Transform transform = (Transform)customTransforms.get(i);
        List<Object> deps = (List)customTransformsDependencies.get(i);
        transformManager.addTransform(this.taskFactory, variantScope, transform, (PreConfigAction)null, (taskx) -> {
            if (!deps.isEmpty()) {
                taskx.dependsOn(new Object[]{deps});
            }

        }, (taskProvider) -> {
            if (transform.getScopes().isEmpty()) {
                    TaskFactoryUtils.dependsOn(variantScope.getTaskContainer().getAssembleTask(), taskProvider);
            }

        });
    }
}

以上是 Transform 的数据流动原理,下面再说下 Transform 的输入数据的过滤机制。

Transform 的数据输入 key 通过 Scope 和 ContentType 两个维度进行过滤。ContentType 就是数据类型,在开发中一般只能使用 CLASSES 和 RESOURCES 两种类型,这里的 CLASSES 已经包含了 class 文件和 jar 包。其他的一些类型如 DEX 是留给 Android 编译器的,我们无法使用。至于 Scope ,开发可用的相对较多(详细见 TransformManager 类),处理 class 字节码时一般使用 SCOPE_FULL_PROJECT 。

Javassist 操作字节码

说完了 Transform 的理论,我们来实际操作一下,编写自定义 Transform 来给类文件插入一行代码。

示例:

利用 Javassist 在 MainActivity 的 onCreate 方法的最后插入一行 Toast 语句。

步骤一:创建自定义插件 Module

参照之前的文章Gradle 学习之插件中的方法创建自定义插件即可,这里直接给图:

transformPlugin目录结构

步骤二:引入 Transform API 和 Javassist 依赖

dependencies {
    ...
    compile 'com.android.tools.build:gradle:3.3.1'
    compile group: 'org.javassist', name: 'javassist', version: '3.22.0-GA'
}

Transform API 和 Javassist 需要单独依赖,这里直接依赖 gradle 是因为其包含的 API 会更加丰富。注意:Transform API 的依赖包经历过修改,从 transform-api 改成了 gradle-api ,大家可以在 Jcenter 中找到相应版本。

步骤三:实现自定义 Transform

这里直接贴代码了,其 API 和原理已经在上文中说过了。

/**
 * 定义一个Transform
 */
class InjectTransform extends Transform {

    private Project mProject

    // 构造函数,我们将Project保存下来备用
    InjectTransform(Project project) {
        this.mProject = project
    }

    // 设置我们自定义的Transform对应的Task名称
    // 类似:transformClassesWithPreDexForXXX
    // 这里应该是:transformClassesWithInjectTransformForxxx
    @Override
    String getName() {
        return 'InjectTransform'
    }

    // 指定输入的类型,通过这里的设定,可以指定我们要处理的文件类型
    //  这样确保其他类型的文件不会传入
    @Override
    Set<QualifiedContent.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS
    }

    // 指定Transform的作用范围
    @Override
    Set<? super QualifiedContent.Scope> getScopes() {
        return TransformManager.SCOPE_FULL_PROJECT
    }

    // 当前Transform是否支持增量编译
    @Override
    boolean isIncremental() {
        return false
    }

    // 核心方法
    // inputs是传过来的输入流,有两种格式:jar和目录格式
    // outputProvider 获取输出目录,将修改的文件复制到输出目录,必须执行
    @Override
    void transform(Context context, Collection<TransformInput> inputs,
                   Collection<TransformInput> referencedInputs, TransformOutputProvider outputProvider,
                   boolean isIncremental) throws IOException, TransformException, InterruptedException {
        println '--------------------transform 开始-------------------'

        // Transform的inputs有两种类型,一种是目录,一种是jar包,要分开遍历
        inputs.each {
            TransformInput input ->
                // 遍历文件夹
                //文件夹里面包含的是我们手写的类以及R.class、BuildConfig.class以及R$XXX.class等
                input.directoryInputs.each {
                    DirectoryInput directoryInput ->
                        // 注入代码
                        MyInjectByJavassit.injectToast(directoryInput.file.absolutePath, mProject)

                        // 获取输出目录
                        def dest = outputProvider.getContentLocation(directoryInput.name,
                                directoryInput.contentTypes, directoryInput.scopes, Format.DIRECTORY)

                        println("directory output dest: $dest.absolutePath")
                        // 将input的目录复制到output指定目录
                        FileUtils.copyDirectory(directoryInput.file, dest)
                }

                //对类型为jar文件的input进行遍历
                input.jarInputs.each {
                        //jar文件一般是第三方依赖库jar文件
                    JarInput jarInput ->
                        // 重命名输出文件(同目录copyFile会冲突)
                        def jarName = jarInput.name
                        println("jar: $jarInput.file.absolutePath")
                        def md5Name = DigestUtils.md5Hex(jarInput.file.absolutePath)
                        if (jarName.endsWith('.jar')) {
                            jarName = jarName.substring(0, jarName.length() - 4)
                        }
                        def dest = outputProvider.getContentLocation(jarName + md5Name, jarInput.contentTypes, jarInput.scopes, Format.JAR)

                        println("jar output dest: $dest.absolutePath")
                        FileUtils.copyFile(jarInput.file, dest)
                }
        }

        println '---------------------transform 结束-------------------'
    }
}

步骤四:使用 Javassist 实现代码注入逻辑

/**
 * 借助 Javassit 操作 Class 文件
 */
class MyInjectByJavassit {

    private static final ClassPool sClassPool = ClassPool.getDefault()

    /**
     * 插入一段Toast代码
     * @param path
     * @param project
     */
    static void injectToast(String path, Project project) {
        // 加入当前路径
        sClassPool.appendClassPath(path)
        // project.android.bootClasspath 加入android.jar,不然找不到android相关的所有类
        sClassPool.appendClassPath(project.android.bootClasspath[0].toString())
        // 引入android.os.Bundle包,因为onCreate方法参数有Bundle
        sClassPool.importPackage('android.os.Bundle')

        File dir = new File(path)
        if (dir.isDirectory()) {
            // 遍历文件夹
            dir.eachFileRecurse { File file ->
                String filePath = file.absolutePath
                println("filePath: $filePath")

                if (file.name == 'MainActivity.class') {
                    // 获取Class
                    // 这里的MainActivity就在app模块里
                    CtClass ctClass = sClassPool.getCtClass('com.apm.windseeker.MainActivity')
                    println("ctClass: $ctClass")

                    // 解冻
                    if (ctClass.isFrozen()) {
                        ctClass.defrost()
                    }

                    // 获取Method
                    CtMethod ctMethod = ctClass.getDeclaredMethod('onCreate')
                    println("ctMethod: $ctMethod")

                    String toastStr = """ android.widget.Toast.makeText(this,"我是被插入的Toast代码~!!",android.widget.Toast.LENGTH_SHORT).show();  
                                      """

                    // 方法尾插入
                    ctMethod.insertAfter(toastStr)
                    ctClass.writeFile(path)
                    ctClass.detach() //释放
                }
            }
        }
    }

}

步骤五:将 Transform 注册到 Android 插件中

/**
 * 定义插件,加入Transform
 */
class TransformPlugin implements Plugin<Project> {

    @Override
    void apply(Project project) {

        // 获取Android扩展
        def android = project.extensions.getByType(AppExtension)
        // 注册Transform,其实就是添加了Task
        android.registerTransform(new InjectTransform(project))

        // 这里只是随便定义一个Task而已,和Transform无关
        project.task('JustTask') {
            doLast {
                println('InjectTransform task')
            }
        }

    }
}

这里先通过 AppExtension 获取 Android 扩展,然后调用 registerTransform 方法添加自定义的 Transform 。

步骤六:发布插件并使用

/* 自定义插件:利用Transform向MainActivity中插入代码 */
apply plugin: 'com.happy.customplugin.transform'

运行后,可以在 build/intermediates/transforms 目录下找到自定义的 Transform :

InjectTransform

这里的 jar 包名字是数字递增的,这是正常的,其命名逻辑可以在 IntermediateFolderUtils 类的 getContentLocation 方法中找到。我们直接看 MainActivity.class 文件:

修改后的MainActivity

可以看到成功注入了一行 Toast 语句。运行 APP 也能正常弹出 Toast 。

Transform 的注意点

  1. 自定义 Transform 无法处理 Dex ;
  2. 自定义 Transform 无法使用自定义 Transform ;
  3. 可以使用 isIncremental 来支持增量编译以及并发处理来加快 Transform 编译速度;
  4. Transform 只能在全局注册,并将其应用于所有变体(variant)。

总结

Transform 简单来看就是一个 Task ,只不过 Android 在这个 Task 中给我们提供了一个修改 Class 字节码的契机。我们可以根据自己的业务需求进行字节码操作。文中利用 Javassist 写的示例很简单,像 APM 这种功能强大的 SDK ,它的字节码处理逻辑会很复杂,可能会使用到更强大的 ASM 字节码处理工具。

本文示例代码已放在 zjxstar 的 GitHub

参考资料

  1. Android Gradle API
  2. Transform API 首页
  3. Javassist官网
  4. 一起玩转Android项目中的字节码(Transform篇)
  5. Android Gradle高级用法,动态编译技术:Plugin Transform Javassist操作Class文件