初识Android Gradle编译之Transform

什么是Transform

从android-build-tool:gradle:1.5开始,gradle插件包含了一个叫Transform的API,这个API允许第三方插件在class文件转为为dex文件前操作编译好的class文件,这个API的目标就是简化class文件的自定义的操作而不用对Task进行处理。

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

先来看 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 。


image.png

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)
image.png

每个 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 语句。

1.创建自定义插件 Module

2.引入 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 中找到相应版本。

3.实现自定义 Transform

/**
 * 定义一个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 结束-------------------'
    }
}

4.使用 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() //释放
                }
            }
        }
    }

}

5.将 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 。

6.发布插件并使用

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

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


image.png

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


image.png

可以看到成功注入了一行 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 字节码处理工具。

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