Gradle系列 (中篇) —在自定义Gradle插件中使用javassist往class中注入代码

上一篇我们已经详细讲了如何自定义Gradle插件,没有学习的小伙伴可以链接过去学习哦:Gradle系列 (上篇) —Android自定义Gradle插件并在项目中使用,那么今天我们就来讲一下如何在已完成的自定义插件中完成对class文件代码的注入。

Transform API

如何将指定代码注入到class文件中?Google专门提供了Transform API来解决这类问题。

Starting with 1.5.0-beta1, the Gradle plugin includes a Transform API allowing 3rd party plugins to manipulate compiled class files before they are converted to dex files.
(The API existed in 1.4.0-beta2 but it's been completely revamped in 1.5.0-beta1)

The goal of this API is to simplify injecting custom class manipulations without having to deal with tasks, and to offer more flexibility on what is manipulated. The internal code processing (jacoco, progard, multi-dex) have all moved to this new mechanism already in 1.5.0-beta1.
Note: this applies only to the javac/dx code path. Jack does not use this API at the moment.

The API doc is http://google.github.io/android-gradle-dsl/javadoc/.

To insert a transform into a build, you simply create a new class implementing one of the Transform interfaces, and register it with android.registerTransform(theTransform) or android.registerTransform(theTransform, dependencies).

大概意思就是:
从1.5.0-beta1开始,Gradle插件包含一个Transform API,允许第三方插件在将已编译的类文件转换为dex文件之前对其进行操作,也就是允许第三方 Plugin 在打包 dex 文件之前的编译过程中操作 .class 文件(该API已存在于1.4.0-beta2中,但已在1.5.0-beta1中进行了彻底修改)

该API的目标是简化注入自定义类的操作而不必处理任务,并为操作内容提供更大的灵活性。内部代码处理(jacoco,progard,multi-dex)都已在1.5.0-beta1中移至此新机制。该API文档为:Gradle Android Plugin API Javadoc

要将转换插入到构建中,只需创建一个实现Transform接口之一的新类,并向android.registerTransform(theTransform)或android.registerTransform(theTransform,依赖项)注册即可。

接下来看一下Transform的工作流程

Transform 将输入进行处理,然后写入到指定的目录下作为下一个 Transform 的输入源,整个过程是连续不间断的,否则就会报错。

Javassist

Java 字节码以二进制的形式存储在 .class 文件中,每一个 .class 文件包含一个 Java 类或接口。Javaassist 就是一个用来 处理 Java 字节码的类库。它可以在一个已经编译好的类中添加新的方法,或者是修改已有的方法,并且不需要对字节码方面有深入的了解。关于Javassist可以参考学习下面这个链接,它是对javassist官方文档的中文翻译:Javassist官方文档中文翻译

注入代码

为了使用Transform和Javassist,我们需要先依赖进来,在myplugin的build.gradle中添加以下代码:

implementation 'com.android.tools.build:gradle:3.6.1'
implementation 'org.javassist:javassist:3.24.0-GA'

截图如下:

然后新建MyTransform.groovy文件,代码如下:

package com.zhuyong.myplugin

import com.android.build.api.transform.*
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

/**
 * 向calss文件中注入代码
 */
public class MyTransform extends Transform {

    private static final String DEFAULT_NAME = "__MyTransformEditClasses__"

    private static final Set<QualifiedContent.Scope> SCOPES = new HashSet<>();

    static {
        SCOPES.add(QualifiedContent.Scope.PROJECT);
        SCOPES.add(QualifiedContent.Scope.SUB_PROJECTS);
        SCOPES.add(QualifiedContent.Scope.EXTERNAL_LIBRARIES);
    }

    private Project project

    MyTransform(Project project) {
        this.project = project
    }

    /**
     * 设置自定义的Transform对应的Task名称
     * @return Task名称
     */
    @Override
    public String getName() {
        return DEFAULT_NAME
    }

    /**
     * 需要处理的数据类型,CONTENT_CLASS代表处理class文件
     * @return
     */
    @Override
    public Set<QualifiedContent.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS
    }

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

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

    @Override
    public void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        TransformOutputProvider outputProvider = transformInvocation.outputProvider;

        for (TransformInput input : transformInvocation.inputs) {

            if (null == input) continue
            //遍历文件夹
            for (DirectoryInput directoryInput : input.directoryInputs) {
                // 获取output目录
                def dest = outputProvider.getContentLocation(directoryInput.name, directoryInput.contentTypes, directoryInput.scopes, Format.DIRECTORY)
                // 将input的目录复制到output指定目录
                FileUtils.copyDirectory(directoryInput.file, dest)
            }

            //遍历jar文件 对jar不操作,但是要输出到out路径
            for (JarInput jarInput : input.jarInputs) {
                // 重命名输出文件
                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)
            }
        }
    }
}

OK ,Transform已经新建好了,接下来只需要在MyPlugin中注册即可。

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

class MyPlugin implements Plugin<Project> {

    @Override
    void apply(Project project) {
        println("=================================")
        println("======这是我的自定义Gradle插件======")
        println("=================================")

        //AppExtension对应build.gradle中android{...}
        def android = project.extensions.getByType(AppExtension.class)
        //注册一个Transform
        def classTransform = new MyTransform(project)
        android.registerTransform(classTransform)
    }
}

写到这里就完成了注册,试一下能不能重新生成maven包,执行uploadArchives发现没有问题。接下来就来编写注入代码的代码:假设我们要在MainActivity的showToast()方法中插入一个Toast代码,MainActivity如下:

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        showToast();
    }

    /**
     * 弹出一个Toast
     */
    public void showToast() {

    }
}

我们先来新建一个类,专门用来处理代码的注入:

package com.zhuyong.myplugin

import javassist.*
import org.gradle.api.Project

class InjectClass {

    //初始化类池,以单例模式获取
    private final static ClassPool pool = ClassPool.getDefault()

    static void inject(String path, Project project, String injectCode) throws NotFoundException, CannotCompileException {
        println("filePath = " + path)
        //将当前路径加入类池,不然找不到这个类
        pool.appendClassPath(path)
        //为了能找到android相关的所有类,添加project.android.bootClasspath 加入android.jar,
        pool.appendClassPath(project.android.bootClasspath[0].toString())

        File dir = new File(path)
        //判断如果是文件夹,则遍历文件夹
        if (dir.isDirectory()) {
            //开始遍历
            dir.eachFileRecurse { File file ->
                if (file.getName().equals("MainActivity.class")) {
                    //获取到要修改的class文件
                    CtClass ctClass = pool.getCtClass("com.zhuyong.gradledemo.MainActivity")
                    if (null != ctClass) {
                        println "正在操作的路径 = " + file.getAbsolutePath()
                        //判断一个类是否已被冻结,如果被冻结,则进行解冻,使其可以被修改
                        if (ctClass.isFrozen()) ctClass.defrost()
                        //获取到方法
                        CtMethod ctMethod = ctClass.getDeclaredMethod("showToast")
                        println "要插入的代码 = " + injectCode

                        ctMethod.insertBefore(injectCode)//在方法开始注入代码
//                        ctMethod.insertAfter(injectCode)//在方法结尾注入代码
//                        ctMethod.insertAt(18, injectCode)//在class文件的某一行插入代码,前提是class包含行号信息

                        ctClass.writeFile(path)//根据CtClass生成.class文件;
                        /**
                         * 将该class从ClassPool中删除
                         *
                         * ClassPool 会在内存中维护所有被它创建过的 CtClass,当 CtClass 数量过多时,会占用大量的内存,
                         * API中给出的解决方案是 有意识的调用CtClass的detach()方法以释放内存。
                         */
                        ctClass.detach()
                    }
                }
            }
        }
    }
}

接下来就是使用在MyTransform这个文件遍历文件夹的方法中使用InjectClass这个方法,代码如下:(以下代码是添加到MyTransform这个文件中)

def injectCode = "android.widget.Toast.makeText(this,\"这是我插入的代码\",android.widget.Toast.LENGTH_SHORT).show();"
    
//遍历文件夹
for (DirectoryInput directoryInput : input.directoryInputs) {
    //注入代码
    InjectClass.inject(directoryInput.file.absolutePath, project, injectCode)
    // 获取output目录
    def dest = outputProvider.getContentLocation(directoryInput.name, directoryInput.contentTypes, directoryInput.scopes, Format.DIRECTORY)
    // 将input的目录复制到output指定目录
    FileUtils.copyDirectory(directoryInput.file, dest)
}
          

OK,到这里就可以重新执行uploadArchives打包了,然后clean一下整个项目,再执行make project就可以了。此时可以看到Task执行的过程中包含了transformClassesWith__MyTransformEditClasses__ForDebug,这个是注册MyTransform生成的对应的Task。

打开以下文件夹即可看到如下代码已经插入:

OK,我们安装一下生成的debugAPK,看一下会不会弹出Toast。

安装到手机上,执行效果如下:

OK,Toast已经弹出来了,这代表被打到apk包里的dex文件中的class文件,已经成功的被注入了代码,到这里基本上已经完成了本篇文章的目的,但是我们再来完善一下。我们的代码injectCode变量是写死在MyTransform类中的,显示这不符合我们maven包的要求,无法满足依赖以后的自定义效果,最完美的效果应该是注入的代码是可以在app中进行配置的(要注入的类、or类中的方法、or注入到什么位置,都是可以配置的,这里以注入代码内容为例)。

Gradle脚本中是通过Extension传递一些配置参数给自定义插件的,就像上面我们用到的AppExtension,有兴趣的可以到这个类以及父类中去看一下,有些代码变量是我们在build.gradle中熟悉的不能再熟悉的了。所以,我们可以自定义一个extension,通过extension对象传递要注入的代码。首先创建一个类为InjectCodeToClass,用来声明参数(被注入的代码):

package com.zhuyong.myplugin

class InjectCodeExtension {
    def injectCode
}

然后在extensions容器中添加一个名称为InjectCodeToClass,类型为InjectCodeExtension的对象,代码如下:

package com.zhuyong.myplugin

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

class MyPlugin implements Plugin<Project> {

    @Override
    void apply(Project project) {
        println("=================================")
        println("======这是我的自定义Gradle插件======")
        println("=================================")

        //AppExtension对应build.gradle中android{...}
        def android = project.extensions.getByType(AppExtension.class)
        //注册一个Transform
        def classTransform = new MyTransform(project)
        android.registerTransform(classTransform)

        // 通过Extension的方式传递将要被注入的自定义代码
        def extension = project.extensions.create("InjectCodeToClass", InjectCodeExtension)
        project.afterEvaluate {
            classTransform.injectCode = extension.injectCode
        }
    }
}

至此已经完成了插件的修改,再次执行uploadArchives进行重新打包(注意:每次修改完插件里的代码都要重新执行uploadArchives),然后就可以在apply该插件的地方进行赋值了。现在,在app的build.gradle中添加以下代码,就完成了对InjectCodeToClass对象的赋值,执行完成后将要注入的代码传递给Transform对象:

apply plugin: 'custom-gradle-plugin'

InjectCodeToClass {
    injectCode = "android.widget.Toast.makeText(this,\"这是我第二次插入的代码\",android.widget.Toast.LENGTH_SHORT).show();"
}

截图如下:

执行Sync即完成了传值。我们再来验证一下即可:

大功告成,项目代码已上传至 Github

下一篇我们来讲如何在module(Android Library,非app)中依赖自定义插件并完成修改module中的class文件,它和在app module中依赖有什么区别呢?会踩到什么坑呢?请看Gradle系列 (下篇) —在Android Library中依赖自定义Gradle插件并往class中注入代码(暂未发布)!!!

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

推荐阅读更多精彩内容