Gradle Plugin: Transform + Javassist 编译期操作Class文件

一、Gradle 自定义插件步骤

参考://www.greatytc.com/p/03eb55536298

在Gradle中自定义插件,有三种方式:

  • 在 build.gradle 脚本中直接创建使用
  • 在 buildSrc 模块中使用
  • 在独立 Module 中使用

对比这三种方式,各自优缺点如下:

  • 方式一比较快捷,但是可能不能为其它项目使用;
  • 方式二创建了 buildSrc 模块后,Android Studio 会直接识别其为插件模块,在主工程 .gradle 文件中可以直接 apply 插件,而不用引入 maven 或 jcenter 等仓库才能使用插件;
  • 方式三就需要使用引入 maven 或 jcenter 等仓库才能使用插件。

另外,在 IDEA 中也能开发 Gradle 插件,但是在 Android Studio 中更利于进行插件依赖和调试。所以建议直接在 Android Studio 中创建插件,若提供给其他项目使用,则创建 maven 、jcenter 仓库上传脚本上传到远程仓库后进行远程依赖就行。

以下所有的插件实现都是通过在 Android Studio 中创建 buildSrc 模块实现的。

通过 buildSrc 方式自定义插件过程中遇见的问题:
Q:定义了多个插件如何声明和使用?
A:gradle-plugins 为声明插件的目录,项目中创建了多个 Plugin.groovy 文件,可以在这里创建多个 youPluginName.properties 文件,内容为:

implementation-class=包名.插件类名

使用时,直接在 app.gradle 中进行依赖:

apply plugin: 'youPluginName'

如果是远程maven等仓库依赖,则需要添加仓库地址,并且需要在项目根目录添加插件版本 classPath 。

Q:buildSrc 插件模块,在定义插件时,如何使用第三方依赖?
A:同一般依赖引用,比如下面要使用 Transform + Javassist 进行操作字节码,则需要同时加入 gradle 和 javassist 远程依赖,在 buildSrc 模块下的 .gradle 文件配置如下:

apply plugin: 'groovy'

sourceCompatibility = 1.8
targetCompatibility = 1.8

dependencies {
    repositories {
        google()
        mavenLocal()
        jcenter()
    }

    implementation gradleApi()    // gradle sdk
    implementation localGroovy()  // groovy sdk
    
    // transform 时需要用到gradle tool的api,需要单独引入
    implementation 'com.android.tools.build:gradle:3.1.3'
    implementation 'com.android.tools.build:gradle-api:3.1.3'
    implementation 'org.javassist:javassist:3.20.0-GA'
}
二、Javassist + Task 自动生成 .java 文件

按照上面的配置,先来试一下如何使用 javassit 在编译期自动生成 java 代码。
具体场景:在系统自动生成 BuildConfig.java 文件后(也就是系统内置任务 generateDebugBuildConfig 之后新建任务执行),自动生成我们自定义的 java 代码文件。

具体 .groovy 代码如下:

package com.coral.plugin

import com.android.build.gradle.AppPlugin
import org.gradle.api.Plugin
import org.gradle.api.Project
import com.android.build.gradle.AppExtension

/**
 * desc: 利用 Javassist,在系统自动生成BuildConfig.java文件后,自动生成我们的java文件
 */
public class CreateJavaPlugin implements Plugin<Project> {

    @Override
    void apply(Project project) {
        System.out.println("----------------Begin----------------")
        System.out.println("This is out custom plugin.")

        def android = project.extensions.getByType(AppExtension)

        // 注册一个Transform
        def classTransform = new MyTransform(project)
        android.registerTransform(classTransform)

        // 创建一个 Extension
        project.extensions.create("testCreateJavaConfig", CreateJavaExtension)

        // 生产一个类
        if (project.plugins.hasPlugin(AppPlugin)) {
            // 获取到 Extension,也即是 .gradle 文件中的闭包
            android.applicationVariants.all { variant ->
                // 获取到 scope 作用域
                def variantData = variant.variantData
                def scope = variantData.scope

                // 拿到 .gradle 中配置的 Extension 值
                def config = project.extensions.getByName("testCreateJavaConfig")

                // 创建一个 Task(名称为:coralDebugCreateJavaPlugin 或 coralReleaseCreateJavaPlugin)
                def createTaskName = scope.getTaskName("coral", "CreateJavaPlugin")
                def createTask = project.task(createTaskName)

                // 设置 task 要执行的任务
                createTask.doLast {
                    // 生成 java 类
                    createJavaTest(variant, config)
                }

                // 设置 task 依赖于生成 BuildConfig 的 task,然后在生成 BuildConfig 后生成我们的类
                String generateBuildConfigTaskName = variant.getVariantData()
                        .getScope().getGenerateBuildConfigTask().name
                // 任务名称:generateDebugBuildConfig
                println("generateBuildConfigTaskName = " + generateBuildConfigTaskName)

                def generateBuildConfigTask = project.tasks.getByName(generateBuildConfigTaskName)
                if (generateBuildConfigTask) {
                    createTask.dependsOn generateBuildConfigTask
                    generateBuildConfigTask.finalizedBy createTask
                }
            }
        }

        System.out.println("----------------Has it finished?----------------")
    }

    static void createJavaTest(variant, config) {
        println("---begin create: " + variant + ", " + config.str)
        // 要生成的内容
        def content = """package com.coral.demo;
/**
* Created by xss on 2018/11/20.
*/
public class TestClass {
    public static final String str = "${config.str}";
}
                      """
        // 获取到 BuildConfig 类的路径
        File outputDir = variant.getVariantData().getScope().getBuildConfigSourceOutputDir()
        // app/build/generated/source/buildConfig/debug
        println("outputDir = " + outputDir.absolutePath)
        def javaFile = new File(outputDir, "TestClass.java")
        javaFile.write(content, 'UTF-8')
        println("---create finished---")
    }
}

public class CreateJavaExtension {
    def str = "动态生成Java类的字符串"
}

在 app.gradle 文件中配置如下:

// 自动生成 Java 类插件 
apply plugin: 'myPluginCreateJava'

testCreateJavaConfig {
    str = '动态生成Java类'
}

同步gradle 后,在Studio右侧 app -> Tasks -> other 可以看到自定义的任务:


自定义任务名称

双击执行任务编译成功后,在 app/build/generated/source/buildConfig/debug 目录下可以看到自动生成的 java 文件,在项目中可以进行直接引用该类。

参考:http://www.10tiao.com/html/227/201709/2650241354/1.html

三、Transform + Javassist 编译期注入代码到 .class文件

使用 Transform + Javassit 操作字节码,需要在 .gradle 中添加 Transform 和 Javassist 的 API ,配置按上面的 .gradle 配置就行。

具体场景:在项目的 MainActivity 的 onCreate() 方法内部插入一行代码。

MyTransform.groovy 文件代码如下:

package com.coral.plugin

import com.android.build.api.transform.Context
import com.android.build.api.transform.DirectoryInput
import com.android.build.api.transform.Format
import com.android.build.api.transform.JarInput
import com.android.build.api.transform.Transform
import com.android.build.api.transform.QualifiedContent
import com.android.build.api.transform.TransformException
import com.android.build.api.transform.TransformInput
import com.android.build.api.transform.TransformOutputProvider
import com.android.build.gradle.internal.pipeline.TransformManager
import com.android.utils.FileUtils
import org.apache.commons.codec.digest.DigestUtils
import org.gradle.api.Project

public class MyTransform extends Transform {

    Project project

    /**
     * 构造方法,保留原project备用
     */
    MyTransform(Project project) {
        this.project = project
    }

    /**
     * 设置自定义 Transform 对应的 Task 名称
     * 类似:TransformClassesWithPreDexForXXX,对应的 task 名称为:transformClassesWithMyTransformForDebug
     * 会生成目录 build/intermediates/transforms/MyTransform/
     */
    @Override
    String getName() {
        return "MyTransform"
    }

    /**
     * 指定输入的类型,可指定我们要处理的文件类型(保证其他类型文件不会传入)
     * CLASSES - 表示处理java的class文件
     * RESOURCES - 表示处理java的资源
     */
    @Override
    Set<QualifiedContent.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS
    }

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

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

    /**
     * 核心方法,具体如何处理输入和输出
     * @param inputs          为传过来的输入流,两种格式,一种jar包格式,一种目录格式
     * @param outputProvider  获取到输出目录,最后将修改的文件复制到输出目录,这一步必须执行,不让编译会报错
     */
    @Override
    void transform(Context context, Collection<TransformInput> inputs, Collection<TransformInput> referencedInputs,
                   TransformOutputProvider outputProvider, boolean isIncremental) throws IOException, TransformException, InterruptedException {

        System.out.println("----------开始Transform-----------")
        // Transform 的 inputs 分为两种类型,一直是目录,一种是 jar 包。需要分开遍历

        inputs.each { TransformInput input ->
            // 1) 对类型为"目录"的 input 进行遍历
            input.directoryInputs.each { DirectoryInput dirInput ->
                // demo1. 在MainActivity的onCreate()方法之前注入代码
                MyInject.injectOnCreate(dirInput.file.absolutePath, project)
                // 获取 output 目录
                def dest = outputProvider.getContentLocation(dirInput.name, dirInput.contentTypes,
                    dirInput.scopes, Format.DIRECTORY)
                // 将 input 的目录复制到 output 指定目录
                FileUtils.copyDirectory(dirInput.file, dest)
            }

            // 2) 对类型为 jar 文件的 input 进行遍历
            input.jarInputs.each { JarInput jarInput ->
                // jar 文件一般是第三方依赖库jar包

                // 重命名输出文件(同目录 copyFile 会冲突)
                def jarName = jarInput.name
                def md5Name = DigestUtils.md5Hex(jarInput.file.getAbsolutePath())

                if (jarName.endsWith(".jar")) {
                    jarName = jarName.substring(0, jarName.length() - 4)
                }
                // 生成输出路径
                def dest = outputProvider.getContentLocation(jarName + md5Name, jarInput.contentTypes,
                    jarInput.scopes, Format.JAR)
                // 将输入内容复制到输出
                FileUtils.copyFile(jarInput.file, dest)
            }
        }

        System.out.println("----------结束Transform-----------")
    }
}

MyInject.groovy 文件操作字节码代码如下:

package com.coral.plugin

import javassist.ClassPool
import javassist.CtClass
import javassist.CtConstructor
import javassist.CtMethod
import org.gradle.api.Project

public class MyInject {
    private static ClassPool classPool = ClassPool.getDefault()

    public static void injectOnCreate(String path, Project project) {
        classPool.appendClassPath(path)
        classPool.appendClassPath(project.android.bootClasspath[0].toString())
        classPool.importPackage("android.os.Bundle")

        File dir = new File(path)
        if (dir.isDirectory()) {
            dir.eachFileRecurse { File file ->
                String filePath = file.absolutePath
                if (file.getName().equals("MainActivity.class")) {
                    // 获取 MainActivity
                    CtClass ctClass = classPool.getCtClass("com.coral.demo.MainActivity")
                    println("ctClass = " + ctClass)

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

                    // 获取到 onCreate() 方法
                    CtMethod ctMethod = ctClass.getDeclaredMethod("onCreate")
                    println("ctMethod = " + ctMethod)
                    // 插入日志打印代码
                    String insertBeforeStr = """android.util.Log.e("--->", "Hello");"""

                    ctMethod.insertBefore(insertBeforeStr)
                    ctClass.writeFile(path)
                    ctClass.detach()
                }
            }
        }
    }
}

如何使自定义的 Transform 有作用?需要定义插件进行注册,MyTransformPlugin.groovy 代码如下:

import com.android.build.gradle.AppPlugin
import org.gradle.api.Plugin
import org.gradle.api.Project
import com.android.build.gradle.AppExtension

public class MyTransformPlugin implements Plugin<Project> {

    @Override
    public void apply(Project project) {
        def android = project.extensions.getByType(AppExtension)
        // 注册Transform
        def classTransform = new MyTransform(project)
        android.registerTransform(classTransform)
    }
}

在 app.gradle 使用时也需要 apply plugin ,依赖脚本同上。

说明:

  • 自定义的 Transform 在编译的时候并不会被触发执行,在安装 apk 时会触发执行;

  • 自定义的 Transform 会自动生成几种不同 gradle task,任务名称规则为:transformClassWith$${getName}For${variant}


    自定义Transform任务名称
  • 双击上述自定义的 transform 任务会去执行 Transform 中的 transform() 方法,进行字节码操作代码。这一步可以看到我们再 groovy 中的打印日志,很方便调试。

  • 在自定义的 MyTransform 中,使用 transform() 方法处理字节码,除了调用 MyInject 类的方法处理不同,其他的处理步骤都是统一的。

  • transform() 处理步骤大致可以分为:1)对类型为目录的 input 遍历;2)调用 javassist api 处理字节码;3)生成输出路径,将操作后的 input 目录复制到 output 指定目录;4)对类型为 jar 的 input 遍历;5)重命名输出文件(防止复制文件冲突);5)生成输出路径 & 将输入内容复制到输出。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345

推荐阅读更多精彩内容