Android中Gradle插件和Transform

目录
1、Gradle插件
2、Transform
3、ASM
4、应用-防止快速点击的插件

1、Gradle插件

1.1、Gradle插件是什么?

Gradle插件打包了可重用的构建逻辑,可以适用不同的项目和构建。

1.2、自定义Gradle插件的流程

(1)、新建一个 Android Library项目,然后除了主目录删除主目录中的所有文件;
(2)、main目录下建立groovy目录和 resources目录,groovy目录用于写插件逻辑, resources目录下用于声明自定义的插件;
(3)、书写插件的方法就是,写一个类实现Plugin类,并实现其apply方法,在apply方法中完成插件逻辑;
(4)、在resources目录下(建立/META-INF/gradle-plugins目录,并)建立一个(plugin.)properties的文件,在里面声明自定义的插件。这个properties文件的名称是我们应用插件时使用的名称。

1.3、Gradle插件应用流程

(5)、使用uploadArchives将插件上传的maven库。
(6)、依赖路径,使用apply plugin应用插件。

2、Transform API

2.1、Transform API是什么

Transform用于在编译打包的.class文件到.dex文件流程中,去转换.class文件。
目前 jarMerge、proguard、multi-dex、Instant-Run都已经换成 Transform 实现。

2.2、如何注册一个自定的Transform

public class SingleClickHunterPlugin implements Plugin<Project> {
    @Override
    public void apply(Project project) {
        AppExtension appExtension = project.getExtensions().getByType(AppExtension);
        appExtension.registerTransform(new SingleClickHunterTransform(project), Collections.EMPTY_LIST);
    }
}

在自定义插件的apply方法中,获取module对应的project的AppExtension,然后通过其registerTransform方法注册一个自定义的Transform。

注册之后,在编译流程中会通过TaskManager#createPostCompilationTasks为这个自定义的Transform生成一个对应的Task,(transformClassesWithSingleClickHunterTransformForDebug),在.class文件转换成.dex文件的流程中会执行这个Task,对所有的.class文件(可包括第三方库的.class)进行转换,转换的逻辑定义在Transform的transform方法中。

2.3、自定义一个Transform

public class CustomTransform extends Transform {
    @Override
    public void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        super.transform(transformInvocation);
        //当前是否是增量编译(由isIncremental() 方法的返回和当前编译是否有增量基础)
        boolean isIncremental = transformInvocation.isIncremental();
        //消费型输入,可以从中获取jar包和class文件夹路径。需要输出给下一个任务
        Collection<TransformInput> inputs = transformInvocation.getInputs();
        //OutputProvider管理输出路径,如果消费型输入为空,你会发现OutputProvider == null
        TransformOutputProvider outputProvider = transformInvocation.getOutputProvider();

        for(TransformInput input : inputs) {
            for(JarInput jarInput : input.getJarInputs()) {
                File dest = outputProvider.getContentLocation(
                        jarInput.getFile().getAbsolutePath(),
                        jarInput.getContentTypes(),
                        jarInput.getScopes(),
                        Format.JAR);
                //将修改过的字节码copy到dest,就可以实现编译期间干预字节码的目的了        
                FileUtils.copyFile(jarInput.getFile(), dest);
            }

            for(DirectoryInput directoryInput : input.getDirectoryInputs()) {
                File dest = outputProvider.getContentLocation(directoryInput.getName(),
                        directoryInput.getContentTypes(), directoryInput.getScopes(),
                        Format.DIRECTORY);
                //将修改过的字节码copy到dest,就可以实现编译期间干预字节码的目的了        
                FileUtils.copyDirectory(directoryInput.getFile(), dest);
            }
        }
    }
    @Override
    public String getName() {
        return "CustomTransform";
    }
    @Override 
    public boolean isIncremental() {
        return true; //是否开启增量编译
    }
    @Override
    public Set<QualifiedContent.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS;
    }
    @Override
    public Set<? super QualifiedContent.Scope> getScopes() {
        return TransformManager.SCOPE_FULL_PROJECT;
    }
}

在transform方法中,我们需要将每个jar包和class文件复制到dest路径,这个dest路径就是下一个Transform的输入数据。而在复制时,就可以将jar包和class文件的字节码做一些修改,再进行复制。

2.4、Transform两个过滤纬度

Transform两个过滤纬度

ContentType,数据类型,有CLASSES和RESOURCES两种。
其中的CLASSES包含了源项目中的.class文件和第三方库中的.class文件。
RESOURCES仅包含源项目中的.class文件。
对应getInputTypes() 方法。

Scope,表示要处理的.class文件的范围,主要有
PROJECT, SUB_PROJECTS,EXTERNAL_LIBRARIES等。
对应getScopes() 方法。

2.5、支持增量编译

Transform支持增量编译分为两步:

(1)重写Transform的接口方法:isIncremental(),返回true。

@Override 
public boolean isIncremental() {
    return true;
}

(2)判断当前编译对于Transform是否是增量编译:
如果不是增量编译,就按照前面的方式,依次处理所有的class文件;
(比如说clean之后的第一次编译没有增量基础,即使Transform的isIncremental放回true,当前编译对Transform仍然不是增量编译,所有需要依次处理所有的class文件)
如果是增量编译,根据每个文件的Status,处理文件:
如果文件有改变,就按照前面的方式,去处理这个问题。
如果文件没有改变,就不需要进行处理,因为在输出目录已经有一个上次处理过的class文件了
(NOTCHANGED: 当前文件不需处理,甚至复制操作都不用;
ADDED、CHANGED: 正常处理,输出给下一个任务;
REMOVED: 移除outputProvider获取路径对应的文件。)

注意:当前编译对于Transform是否是增量编译受两个方面的影响:
(1)isIncremental() 方法的返回值;
(2)当前编译是否有增量基础;(clean之后的第一次编译没有增量基础,之后的编译有增量基础)

增量的时间缩短为全量的速度提升了3倍多,而且这个速度优化会随着工程的变大而更加显著。

2.6、支持并发编译

private WaitableExecutor waitableExecutor = WaitableExecutor.useGlobalSharedThreadPool();
//异步并发处理jar/class
waitableExecutor.execute(() -> {
    bytecodeWeaver.weaveJar(srcJar, destJar);
    return null;
});
waitableExecutor.execute(() -> {
    bytecodeWeaver.weaveSingleClassToFile(file, outputFile, inputDirPath);
    return null;
});  
//等待所有任务结束
waitableExecutor.waitForTasksWithQuickFail(true);

为什么要等待所有任务结束?
如果不等待,主线程就会进入下一个任务的处理,可能当前的任务的处理工作还没完成。

并发Transform和非并发Transform下,编译速度提高了80%。

3、ASM

ASM ,速度快、代码量小、功能强大,要写字节码、学习曲线高。
Javassist,学习简单,不用写字节码,比ASM慢,功能少。

3.1、ASM访问字节码流程

private void copy(String inputPath, String outputPath) {
        FileInputStream is = new FileInputStream(inputPath);
        ClassReader cr = new ClassReader(is);
        ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
        cr.accept(cw, 0);
        FileOutputStream fos = new FileOutputStream(outputPath);
        fos.write(cw.toByteArray());
}

(1)、ClassReader负责读取.class字节码;
(2)、ClassReader将所有字节码传递ClassWriter(是一个ClassVisitor)中的(多个)visitxxx接口方法依次进行处理;
(3)、ClassWriter访问某个方法时会将这个方法的所有字节码传递给MethodWriter(是一个MethodVisitor)处理。

默认ClassWriter会保存传递到它的所有字节码,可使用ClassWriter.toByteArray()方法获取经过ClassWriter的字节码。

3.2、以上流程代码证明:ClassReader.accept()。

public void accept(ClassVisitor classVisitor, Attribute[] attributePrototypes, int parsingOptions) {
    
    // 读取当前class的字节码信息
    int accessFlags = this.readUnsignedShort(currentOffset);
    String thisClass = this.readClass(currentOffset + 2, charBuffer);
    String superClass = this.readClass(currentOffset + 4, charBuffer);
    String[] interfaces = new String[this.readUnsignedShort(currentOffset + 6)];
    
    //classVisitor就是刚才accept方法传进来的ClassWriter,每次visitXXX都负责将字节码的信息存储起来
    classVisitor.visit(this.readInt(this.cpInfoOffsets[1] - 7), accessFlags, thisClass, signature, superClass, interfaces);
    
    /**
        略去很多visit逻辑
    */
    //visit Attribute
    while(attributes != null) {
        Attribute nextAttribute = attributes.nextAttribute;
        attributes.nextAttribute = null;
        classVisitor.visitAttribute(attributes);
        attributes = nextAttribute;
    }
    /**
        略去很多visit逻辑
    */
    classVisitor.visitEnd();
}

Gradle中的ClassWriter默认对传递给它的字节码不做任何处理,只做保存工作。
通过默认ClassWriter处理字节码的流程如下:


通过默认ClassWriter处理字节码的流程

3.3、修改字节码

要修改字节码,需要自定义ClassWriter,在其访问类的相应方法时对其做相应操作(使用自定义的MetiodWriter),达到字节码插桩的目的。

修改字节码

3.4、什么事增量编译

我理解的增量编译:
1、基于Task的上次输出快照和这次输入快照对比,如果相同,则跳过相应任务;
2、基于Task本身是否支持增量更新。

3.4、增量编译实验

3.4.1、Transform 的isIncremental()返回true。
@Override
public boolean isIncremental() {
    return true;
}

(1)、clean之后,第一次编译,即使Transform里面isIncremental()返回true,Transform开启了增量编译,此时对Transform来说仍然不是增量编译, transform方法中isIncremental = false;

(2)、不做任何改变直接进行第二次编译,Transform别标记为up-to-date,被跳过执行;

(3)、修改一个文件中代码,进行第三次编译,此时对Transform来说是增量编译,transform方法中isIncremental = true。

3.4.2、Transform 的isIncremental()返回false。
@Override
public boolean isIncremental() {
    return false;
}

(1)、clean之后,第一次编译,此时对Transform来说不是增量编译, transform方法中isIncremental = false;

(2)、不做任何改变直接进行第二次编译,Transform别标记为up-to-date,被跳过执行;

(3)、修改一个文件中代码,进行第三次编译,此时对Transform来说不是增量编译,transform方法中isIncremental = false。

结论:1、一次编译对Transform来说是否是增量编译取决于两个方面:
(1)、当前编译是否有增量基础;
(2)、当前Transform是否开启增量编译。

结论:2、不管Transform是否开启增量编译,若TransformTask的当前输入快照和上次输出快照相同,则跳过当前TransformTask。

4、Gradle插件和Transform实战应用

防止快速点击的小插件

https://github.com/Leaking/Hunter/pulls

按钮快速点击的问题在于:可能重复打开多个页面。

原理:

4.1、防止快速点击的原理

记录view两次点击的时间差,如果这个时间差小于我们定义的一个时间间隔,那么第二次点击就直接返回,不进行点击的逻辑处理。

4.2、如何全局解决项目中所有按钮的快速点击问题

第一种方法是手动全局添加,它的问题在于:
(1)、按钮太多,工作量大,容易遗漏;
(2)、无法给第三方sdk中的按钮添加此逻辑。
第二种方法是采用AOP的方式去添加,具体的过程是:
在打包过程中有一个阶段是class文件转换dex的阶段,所有class文件会经历多个Transform进行处理,我们可以自定义一个Transform得到所有的class文件,然后扫描判断这个类是否实现OnClickListener,如果实现就在其onclick方法中是用asm操作字节码插入上述防止快速点击的逻辑。最后将所有文件复制到输出目录就可以了。
这样做可以实现功能,但是发现处理速度较慢,修改5个类,这个Transform大概需要10s处理。
于是我做了两点优化:
(1)、支持并发编译
(2)、支持增量编译
做了这两点优化后,修改5个类,这个Transform大概在1.5s左右处理。提升了6倍多。

另外我发现我们app中有少数按钮是需要快速点击的,所有我又自定义了一个注解,在onclick方法上面加上这个注解就不会插入防止快速点击的逻辑。
实现原理也很简单,就是在字节码插桩的时候去判断onclick方法上是否有这样一个注解,如果有就不插入。

问题:
1,点击事件委派问题。导致按钮点击没有响应。如:

View.OnClickListener DelegateClickListener;
button.setOnClickListener(new OnClickListener() {
        @Override
        public void onClick(View v) {
            DelegateClickListener.onclick(v);
        }
    });

在一个view的点击事件中有两个OnClickListener处理这个事件,具体来说就是在第一个OnClickListener的onClick中调用了第二个OnClickListener的onclick方法,讲这个事件传递个了二个OnClickListener

出现这个问题的原因是,我们开始将点击时间和view绑定,那么必然存在第一个onClick方法可以响应点击逻辑,第二个onclick方法就直接返回了,因为代码的执行时间必然比我们定义的时间间隔短。

问题的根源是点击时间和view绑定,那么我们将点击时间和view解绑,然后将点击时间和OnclickListener关联起来,就是每个OnclickListener中有一个点击时间,这样,就不会相互影响了。

2,lambda表达式问题
最新的编译流程中,lambda代码转换成class后,不会脱糖,所以是找不到onclick方法的。

public class OtherTestViewHolder extends ViewHolder {
    private int i = 100;

    public OtherTestViewHolder(@NonNull View itemView) {
        super(itemView);
        itemView.setOnClickListener((v) -> {
            Log.i("", "");
            ToastHelper.toast(v, v, "1234");
        });
    }
}
//删除无效的不想阅读的代码
// access flags 0x1
 public <init>(Landroid/view/View;)V
  L2
   LINENUMBER 19 L2
   ALOAD 1

   // 通过INVOKEDYNAMIC 将当前的的setOnClickListener 链接到lambda$new$0静态方法上去。
   INVOKEDYNAMIC onClick()Landroid/view/View$OnClickListener; [
     // arguments:
     (Landroid/view/View;)V, 
     // handle kind 0x6 : INVOKESTATIC
     com/wallstreetcn/sample/adapter/OtherTestViewHolder.lambda$new$0(Landroid/view/View;)V, 
     (Landroid/view/View;)V
   ]

// access flags 0x100A
private static synthetic lambda$new$0(Landroid/view/View;)V
 L0
  LINENUMBER 20 L0
  LDC ""
  LDC ""
  INVOKESTATIC android/util/Log.i (Ljava/lang/String;Ljava/lang/String;)I
  POP
 L1
  LINENUMBER 21 L1
  ALOAD 0
  ALOAD 0
  LDC "1234"
  INVOKESTATIC com/wallstreetcn/sample/ToastHelper.toast (Ljava/lang/Object;Landroid/view/View;Ljava/lang/Object;)V
  RETURN
 L2
  LOCALVARIABLE v Landroid/view/View; L0 L2 0
  MAXSTACK = 2
  MAXLOCALS = 1

最新的编译流程中,Lambda表达式会被翻译成INVOKEDYNAMIC指令,(它的名称是onclick,描述符是OnClickListener),然后对应的onclick里面的逻辑会被包装成一个静态方法,在这个INVOKEDYNAMIC指令的参数中,会记录将这个onclick对应的静态方法的名字和描述,我们根据这个名称和描述就可以找对 onclick对应的方法。进而就可以对这个静态方法进行后续的字节码操作就可以了。

fun ClassNode.lambdaHelper(): MutableList<MethodNode> {
    val lambdaMethodNodes = mutableListOf<MethodNode>()
    methods?.forEach { method ->
        method.instructions.iterator()?.forEach {
            // 先从  method.instructions中找到`InvokeDynamicInsnNode`
            if (it is InvokeDynamicInsnNode) {
              // 判断是不是我想要修改的类 举例View\$OnClickListener
                if (it.name == "onClick" && it.desc.contains(")Landroid/view/View\$OnClickListener;")) {
                    Log.info("dynamicName:${it.name} dynamicDesc:${it.desc}")
                    //获取指令中的参数,name和desc
                    val args = it.bsmArgs
                    args.forEach { arg ->
                      // 根据其中的name和desc等找到其所对应的静态方法,之后加入list中
                        if (arg is Handle) {
                            val methodNode = findMethodByNameAndDesc(arg.name, arg.desc, arg.tag)
                            Log.info("findMethodByNameAndDesc argName:${arg.name}  argDesc:${arg.desc} " +
                                    "method:${method?.name} ")
                            if (methodNode != null) {
                                lambdaMethodNodes.add(methodNode)
                            }
                        }
                    }
                }
            }
        }
    }
    //然后返回当前类所有要修改的lambda
    lambdaMethodNodes.forEach {
        Log.info("lambdaName:${it.name} lambdaDesc:${it.desc} lambdaAccess:${it.access}")
    }
    return lambdaMethodNodes

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

推荐阅读更多精彩内容