再学一次gradle系列——插件应用Transform(四)

1,661 阅读7分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路

gradle系列——groovy,核心对象(一)

gradle系列——Task和生命周期(二)

gradle系列——Plugin插件(三)

gradle系列——插件应用Transform(四)

gradle系列——常用技巧(五)

这篇文章是对前一篇gradle系列第三篇——插件的一个补充分享,基于gradle插件的Transform接口,实现一个简单的android字节码修改插件,是对gradle自定义插件的一个实践

什么是Transform

Transfrom是Android gradle插件从1.5.0版本开始提供的接口,我们可以通过它去hook介入Android项目的打包流程

我们都知道一个apk的打包流程中包括两个关键步骤

  • 将所有的java源码文件编译为class文件
  • 再将这些class打包成dex 这两个步骤现在都是封装在android的gradle插件内部的,正常我们没办法去修改,但是有了TransForm之后,我们就可以在class打包成dex之前,获取到所有的class文件,有了这些class文件,我们自然就可以借助相关字节码修改工具(例如ASM)对class中的内容进行修改替换,也就是所谓的字节码插桩技术

再简单介绍下字节码插桩技术

字节码插桩技术就是修改class字节码文件,例如可以实现无感埋点,假设我们需要在所有页面的可点击组件的点击事件中加上埋点,正常我们可能需要在每个OnClick回调中一个个写埋点逻辑,但是有了字节码插桩,我们就可以不需要侵入到每个页面每个组件,直接在打包阶段通过字节码修改,批量操作加入埋点

Transform的使用

前面提到Transform的使用需要借助自定义gradle插件

首先按照gradle系列第三篇——插件自定义一个最基本的gradle插件

插件moudule的结构如图所示,其中CustomGradlePlugin里面就是继承自定义了一个Plugin,

class CustomGradlePlugin implements Plugin<Project>{

    @Override
    void apply(Project project) {
        println "apply CustomGradlePlugin"
    }
}

相比之前简单的自定义插件,主要在build.gradle脚本中增加了几个依赖项

apply plugin: 'groovy'
apply plugin: 'maven'

repositories {
    mavenCentral()
    google()
}

dependencies {
    implementation localGroovy()
    implementation gradleApi()
    
    // 添加android gradle插件的依赖,因为需要用到这个库里面的Transform相关接口
    implementation 'com.android.tools.build:gradle:4.2.2'

    // ASM库依赖,用于修改字节码
    implementation 'org.ow2.asm:asm:9.1'
    implementation 'org.ow2.asm:asm-commons:9.1'

}

sourceSets {
    main{
        groovy {
            srcDir 'src/main/groovy'
        }
        resources {
            srcDir 'src/main/resources'
        }
    }
}

uploadArchives {
    repositories.mavenDeployer {
        repository(url: uri('../repo'))   // 本地仓库路径
        pom.groupId = "com.tu.gradle.plugin"// 唯一标识(通常为模块包名,也可以任意)
        pom.artifactId = "CustomGradlePlugin"// 项目名称(通常为类库模块名称,也可以任意)
        pom.version = "1.0.0"// 版本号
    }
}

然后继承Transfrom实现一个自定义的CustomTransform

class CustomLogTransform extends Transform{

    @Override
    String getName() {
        return "CustomLogTransform"
    }

    @Override
    Set<QualifiedContent.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS
    }

    @Override
    Set<? super QualifiedContent.Scope> getScopes() {
        return TransformManager.SCOPE_FULL_PROJECT
    }

    @Override
    boolean isIncremental() {
        return false
    }
}

Transfrom是一个抽象类,有4个接口必须要重写 getName 这个很简单就是返回当前transform的名字,可以任意

getInputTypes 指定需要处理的输入类型,什么是输入?Android打包过程中会产出多种类型文件的,例如class文件,资源文件,这个demo中我们需要修改的是class文件,所以这里指定为CONTENT_CLASS

getScopes 指定我们这个自定义的Transform可使用的范围,Scope有5种可选的类型

// 只有当前project,跟build.gradle的project对应
PROJECT(0x01),
// 只有当前project的子project
SUB_PROJECTS(0x04),
// 只有三方库
EXTERNAL_LIBRARIES(0x10),
// 测试代码
TESTED_CODE(0x20),
// 只提供本地或远程依赖项
PROVIDED_ONLY(0x40),

另外TransformManager通过这几种基本的scope的组合提供了一些复合类型,例如我们这demo使用的SCOPE_FULL_PROJECT就是包括了项目下所有的代码,包括依赖的三方库

isIncremental 控制是否支持增量编译,false表示全量编译

transform方法

除了上面必须重写的4个抽象方法,还有一个方法也必须重写,就是transform,我们需要通过transform方法去获取到class文件


@Override
void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
    def inputs = transformInvocation.inputs
    def outputProvider = transformInvocation.outputProvider
}

这里只是把函数的参数解析了出来,可以看到TransformInvocation类中有两个重要属性,一个是inputs,一个是outputProvider

inputs

inputs是一个类型为TransformInput的集合,他是所有输入文件的集合,TransformInput里面又包括两种输入类型

  • DirectoryInput集合 这个集合包括了以源码形式参与构建的目录结构和源码文件
  • JarInput集合 这个集合包括了以jar形式和aar形式参与构建的文件

outProvider

outputProvider是TransformOutputProvider类型的,处理过后的各种文件需要通过它输出到下一个transform

字节码修改

上面已经简单实现了一个Transfrom,在transfrom方法中可以去获取到输入inputs了,下面的关键就是把这些inputs中的class取出来,并根据需求去修改相关的class文件

字节码修改需要借助到一个关键的库ASM(字节码修改中涉及到的知识还比较多,后续也会针对字节码相关的知识学习分享一个系列,目前这篇侧重点在理解Transfrom,ASM等有个大概的了解就好)

这个demo我们主要会用到ASM框架中的两个基础类,ClassVistor,MethodVisitor

ClassVisitor

这个类就是ASM用来接收处理Class文件的入口,继承它实现一个自定义的ClassVisitor,直接上代码,主要看下注释

public class LogClassVisitor extends ClassVisitor {

    // api是ASM的api版本,是一个枚举
    // 接收一个Classvisitor类型参数,这里其实属于代理模式,后面用到的时候会讲到
    public LogClassVisitor(int api, ClassVisitor classVisitor) {
        super(api, classVisitor);
    }

    // 遍历class中的方法,会回调到这个方法
    @Override
    public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
        // 返回一个MethodVisitor,从字面意思就能看出用来hook方法的
        MethodVisitor visitor = cv.visitMethod(access,name,descriptor,signature,exceptions);
        // hook OnCreate方法
        if(name.equals("onCreate")){
            return new LogMethodVisitor(Opcodes.ASM5, visitor);
        }
        return visitor;
    }
}

在上面实现的ClassVisitor中,visitMethod是用来遍历class文件里面的函数的,它需要返回一个MethodVisitor,所以我们继承这个MethodVisitor实现一个

MethodVisitor

class LogMethodVisitor extends MethodVisitor{

        public LogMethodVisitor(int api, MethodVisitor methodVisitor) {
            super(api, methodVisitor);
        }

        // 加入需要插入的逻辑代码
        @Override
        public void visitCode() {
            super.visitCode();
            mv.visitLdcInsn("ASM_CUSTOM_LOG");
            mv.visitLdcInsn("custom transform log");
            mv.visitMethodInsn(Opcodes.INVOKESTATIC, "android/util/Log","e",
                    "(Ljava/lang/String;Ljava/lang/String;)I",false);
            mv.visitInsn(Opcodes.POP);
        }
    }

MethodVisitor很明显就是用来hook访问对应的方法,这里只是举个简单的例子,在onCreate方法中插入了一段日志

Transfrom与ASM结合

有了具体的操作字节码的逻辑,就可以将前面写好的transform模块跟这个串起来了,将截取到的class文件,传递给ClassVisitor去处理,主要看下注释

void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
    def inputs = transformInvocation.inputs
    def outputProvider = transformInvocation.outputProvider
    inputs.each {
        // 遍历所有依赖性的jar或者aar里面的文件进行处理
        it.jarInputs.each {jarInput->
        // 需要将所有输入的jar里面的文件copy到输出目录,以交给下一下transform处理
            File dest = outputProvider.getContentLocation(jarInput.name,jarInput.contentTypes,jarInput.scopes, Format.JAR)
            FileUtils.copyFile(jarInput.file,dest)
        }
        // 遍历源码里面的文件进行处理
        it.directoryInputs.each {directoryInput->
            directoryInput.file.eachFileRecurse {file->
                // 判断是class文件,将他交给自定义的ClassVisitor
                if(file.absolutePath.endsWith(".class")){
                    // ClassReader读取class文件的内容
                    def classReader = new ClassReader(file.bytes)
                    // ClassWriter用于回写修改后的class内容
                    def classWriter = new ClassWriter(classReader,ClassWriter.COMPUTE_MAXS)
                    // ClassWriter其实也是ClassVisitor的子类,这样就可构造出一个自定义的ClassVisitor
                    def classVisitor = new LogClassVisitor(Opcodes.ASM5,classWriter)
                    // ClassVisitor处理class
                    classReader.accept(classVisitor,ClassReader.EXPAND_FRAMES)
                    // 处理后的数据写回文件
                    def bytes = classWriter.toByteArray()
                    def outputStream = new FileOutputStream(file.path)
                    outputStream.write(bytes)
                    outputStream.close()
                }
            }
            // 需要将所有输入文件copy到输出,以交给下一下transform处理
            File dest = outputProvider.getContentLocation(directoryInput.name,
                    directoryInput.contentTypes,directoryInput.scopes,Format.DIRECTORY)
            FileUtils.copyDirectory(directoryInput.file,dest)

        }
    }
}

整个过程就是遍历所有的文件,过滤需要处理的Class文件交给ClassVisitor,处理完后放到输出目录,交由下一个transform处理,这样就完成了一个简单的字节码插桩插件

字节码插件的使用

只需要在app的gradle脚本中应用这个自定义的插件

apply plugin: 'com.tu.gradle.plugin'

执行一下assemble的任务,输出打包一个apk,运行看下日志 com.example.myapplication I/ASM_CUSTOM_LOG: custom transform log 结果符合预期

最后还可以通过jadx反编译再确认下

tips

运行之后发现了一个奇怪的bug Type kotlinx.coroutines.android.AndroidDispatcherFactory is defined multiple times

几番google也没找到具体原因,最后clean一下好了,有知道的大佬烦请解答一下

以上就是gradle系列Transform相关的分享,如果有写的不对的地方欢迎评论指正,感觉写的还不错的也欢迎评论点赞支持一下,感谢