Android Gradle学习(五)- gradle插件实战

179 阅读7分钟

本篇以按钮点击防抖为例,使用gradle插件在所有onClick()方法体里面插入代码,大致步骤如下

  1. 新建一个sdk module,定义DoubleClickController.isDoubleClick()判断方法
  2. 新建gradle插件module,使用gradle插件遍历所有onClick方法
  3. 引入ASM,修改onClick方法体,在onClick()方法体第一行插入DoubleClickController.isDoubleClick()判断

效果如下:

findViewById(R.id.btnTestSdk).setOnClickListener(view -> {  
    if (DoubleClickController.isDoubleClick()) return; // 要插入的代码  
    Toast.makeText(TestSdkActivity.this, "test sdk click", Toast.LENGTH_SHORT).show();  
});

1. 定义DoubleClickController.isDoubleClick()判断方法

该方法可以放到一个独立module里面,最终可以和gradle plugin一起提供给外部使用

public class DoubleClickController {  
  
    private static final long DOUBLE_CLICK_TIME_SPACE = 500; // 双击间隔  
    private static long lastClickTime = 0L; // 上次点击事件戳  

    public static boolean isDoubleClick() {  
        long curTime = System.currentTimeMillis();  
        if (curTime - lastClickTime < DOUBLE_CLICK_TIME_SPACE) {  
            return true;  
        }  
        lastClickTime = curTime;  
        return false;  
    }  
  
}
findViewById(R.id.btnTestSdk).setOnClickListener(new View.OnClickListener() {  
    @Override  
    public void onClick(View view) {  
        // if (DoubleClickController.isDoubleClick()) return; // 要插入的代码  
        Toast.makeText(TestSdkActivity.this, "test sdk click", Toast.LENGTH_SHORT).show();  
    }  
});

2. gradle插件

插件的创建与调试可以参考上一篇:Android Gradle学习(四)- gradle插件开发和调试

首先需要了解两个概念:

  • gradle plugin 7.0之前,使用Transform,本篇基于此改造。gradle plugin 7.0开始,Transform废弃,这个方案后面再说。
  • class分为主项目编译生成的class和三方jar里面的class

class修改大致流程:

  • Transform遍历所有class(主项目和三方jar)
  • InputStream读取文件流
  • ClassReader读取并加载class
  • ClassVisitor解析class并筛选出要修改的方法体
  • MethodVisitor修改方法体
  • ClassWriter将修改后的字节码转换为class字节数组
  • OutputStreamclass字节数组写入文件

gradle插件遍历所有的资源方法基本一致,是可以拷贝的,以下是插件入口CommonPlugin.groovy方法

package com.kongge.commonplugin  
  
import com.android.build.gradle.AppExtension  
import com.kongge.commonplugin.doubleclick.DoubleClickTransform  
import org.gradle.api.Plugin  
import org.gradle.api.Project  
  
class CommonPlugin implements Plugin<Project> {  
  
    @Override  
    void apply(Project project) {  
        applyExtension(project)  
        println '----------CommonPlugin start----------'  
        doSomething(project)  
        println '----------CommonPlugin end----------'  
    }  

    private void applyExtension(Project project) {  
        // 扩展参数,此处可以忽略
        project.extensions.create(CommonParam.EXT_NAME, CommonParam.class)  
    }  

    private void doSomething(Project project) {  
        // 扩展参数,此处可以忽略
        AppExtension appExtension = project.extensions.findByType(AppExtension.class)  
        // 注册Transform
        appExtension.registerTransform(new DoubleClickTransform(project))  
        project.afterEvaluate {  
            // 扩展参数,此处可以忽略
            CommonParam commonParam = CommonParam.parseExt(project)  
            println "enable=${commonParam.enable}"  
            println "doubleClickTimeSpace=${commonParam.doubleClickTimeSpace}"  
        }  
    }  
}

3. Transform介绍

TransformAndroid Gradle Plugin 1.5 就引入的特性,主要用于在 Android 构建过程中,在 Class -> Dex 这个节点修改 Class 字节码。利用 Transform API,我们可以拿到所有参与构建的 Class 文件,借助 JavassistASM 等字节码编辑工具进行修改,插入自定义逻辑。

具体可以参考其实 Gradle Transform 就是个纸老虎,此处不再赘述

DoubleClickTransform.groovy

package com.kongge.commonplugin.doubleclick  
  
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.apache.commons.io.IOUtils  
import org.gradle.api.Project  
import org.objectweb.asm.ClassReader  
import org.objectweb.asm.ClassWriter  
import org.objectweb.asm.Opcodes  
  
import java.nio.file.Files  
import java.util.zip.ZipEntry  
import java.util.zip.ZipFile  
import java.util.zip.ZipOutputStream  
// 隐私处理类  
public class DoubleClickTransform extends Transform {  
  
    Project project  

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

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

    @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  
    }  

    @Override  
    void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {  
        super.transform(transformInvocation)  
        printCopyright()  
        TransformOutputProvider outputProvider = transformInvocation.getOutputProvider()  
        //清理文件  
        if (!transformInvocation.isIncremental()) {  
            outputProvider.deleteAll()  
        }  
        transformInvocation.inputs.each { TransformInput input ->  
            input.jarInputs.each { JarInput jarInput ->  
                // 处理jar  
                processJarInput(jarInput, outputProvider)  
            }  

            input.directoryInputs.each { DirectoryInput directoryInput ->  
                // 处理源码文件  
                processDirectoryInputs(directoryInput, outputProvider)  
            }  
        }  
    }  

    private void printCopyright() {  
        println()  
        println("**************************************************************")  
        println("****** ******")  
        println("****** 欢迎使用DoubleClick编译插件 ******")  
        println("****** ******")  
        println("**************************************************************")  
        println()  
    }  

    private void processJarInput(JarInput jarInput, TransformOutputProvider outputProvider) {  
        //必须给jar重新命名,否则会冲突  
        String jarName = jarInput.name  
        String md5 = DigestUtils.md5Hex(jarInput.file.absolutePath)  
        if (jarName.endsWith(".jar")) {  
            jarName = jarName.substring(0, jarName.length() - 4)  
        }  
        File dest = outputProvider.getContentLocation(md5 + jarName, jarInput.contentTypes, jarInput.scopes, Format.JAR)  
        visitorJar(jarInput.getFile(), dest)  
    }  

    private void processDirectoryInputs(DirectoryInput directoryInput, TransformOutputProvider outputProvider) {  
        directoryInput.getFile().eachFile { File file ->  
            findFiles(file)  
        }  

        File dest = outputProvider.getContentLocation(  
            directoryInput.getName(),  
            directoryInput.getContentTypes(),  
            directoryInput.getScopes(),  
            Format.DIRECTORY)  
        // 建立文件夹  
        FileUtils.mkdirs(dest)  

        // 将修改过的字节码copy到dest  
        FileUtils.copyDirectory(directoryInput.getFile(), dest)  
    }  

    private void findFiles(File file) {  
        if (file.isDirectory()) {  
            File[] files = file.listFiles()  
            files.each { File file1 ->  
                if (file1.isDirectory()) {  
                    findFiles(file1)  
                } else {  
                    visitorFile(file1)  
                }  
            }  
        } else {  
            visitorFile(file)  
        }  
    }  

    private void visitorJar(File src, File dest) {  
        try {  
            ZipFile inputZip = new ZipFile(src)  
            ZipOutputStream outputZip = new ZipOutputStream(new BufferedOutputStream(Files.newOutputStream(dest.toPath())))  
            Enumeration<? extends ZipEntry> inEntries = inputZip.entries()  
            while (inEntries.hasMoreElements()) {  
                ZipEntry entry = inEntries.nextElement()  
                InputStream originalFile = new BufferedInputStream(inputZip.getInputStream(entry))  
                String name = entry.getName()  
                println("-------------------entryname=" + name)  
                byte[] newEntryContent  
                // 仅对class文件做处理,使用AMS9修改字节码
                if (name.endsWith(".class")) {  
                    ClassReader classReader = new ClassReader(originalFile)  
                    ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS)  
                    DoubleClickClassVisitor doubleClickVisitor = new DoubleClickClassVisitor(Opcodes.ASM9, classWriter)  
                    classReader.accept(doubleClickVisitor, ClassReader.EXPAND_FRAMES)  
                    newEntryContent = classWriter.toByteArray()  
                } else {  
                    newEntryContent = IOUtils.toByteArray(originalFile)  
                }  
                originalFile.close()  
                ZipEntry outEntry = new ZipEntry(name)  
                outputZip.putNextEntry(outEntry)  
                outputZip.write(newEntryContent)  
                outputZip.closeEntry()  
            }  
            outputZip.flush()  
            outputZip.close()  
        } catch (Exception e) {  
            e.printStackTrace()  
        }  
    }  

    private void visitorFile(File file) {  
        try {  
            FileInputStream fileInputStream = new FileInputStream(file)  
            // 使用AMS9修改字节码
            ClassReader classReader = new ClassReader(fileInputStream)  
            ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_FRAMES)  
            DoubleClickClassVisitor doubleClickVisitor = new DoubleClickClassVisitor(Opcodes.ASM9, classWriter)  
            classReader.accept(doubleClickVisitor, ClassReader.SKIP_DEBUG)  
            byte[] bytes = classWriter.toByteArray()  
            FileOutputStream fileOutputStream = new FileOutputStream(file.absolutePath)  
            if (file.exists()) {  
                file.delete()  
            }  
            fileOutputStream.write(bytes)  
            fileOutputStream.close()  
            fileInputStream.close()  
        } catch (Exception e) {  
            e.printStackTrace()  
        }  
    }  
}

4. ASM

参考:Android使用ASM修改函数

ASM是一种基于java字节码层面的代码分析和修改工具,ASM的目标是生成,转换和分析已编译的java class文件。对class的修改基本分为3步

  1. 读取class文件 - ClassReader
  2. 扫描并修改class字节码 - ClassVisitor
  3. 输出class文件 - ClassWriter

集成AMS9依赖

dependencies {  
    implementation gradleApi()  
    implementation localGroovy()  
    implementation 'com.android.tools.build:gradle:4.1.3'  

    // 一些文件操作工具
    implementation 'commons-io:commons-io:2.4'
    // asm
    implementation 'org.ow2.asm:asm:9.2'  
    implementation 'org.ow2.asm:asm-util:9.2'  
}

DoubleClickTransform.groovy

public class DoubleClickTransform extends Transform {  
    // ...
    
    // 使用ASM对class文件进行处理
    private void visitorFile(File file) {  
        try {  
            FileInputStream fileInputStream = new FileInputStream(file)  
            ClassReader classReader = new ClassReader(fileInputStream)  
            ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_FRAMES)  
            DoubleClickClassVisitor doubleClickVisitor = new DoubleClickClassVisitor(Opcodes.ASM9, classWriter)  
            classReader.accept(doubleClickVisitor, ClassReader.SKIP_DEBUG)  
            byte[] bytes = classWriter.toByteArray()  
            FileOutputStream fileOutputStream = new FileOutputStream(file.absolutePath)  
            println("visitorFile file.absolutePath=" + file.absolutePath)  
            if (file.exists()) {  
                file.delete()  
            }  
            fileOutputStream.write(bytes)  
            fileOutputStream.close()  
            fileInputStream.close()  
        } catch (Exception e) {  
            e.printStackTrace()  
        }  
    }
}

DoubleClickClassVisitor.groovy: 用于遍历class和匹配onClick方法,之后交由MethodVisitor去处理具体逻辑

package com.kongge.commonplugin.doubleclick  
  
import org.objectweb.asm.ClassVisitor  
import org.objectweb.asm.MethodVisitor  
import org.objectweb.asm.Opcodes  
  
class DoubleClickClassVisitor extends ClassVisitor {  
  
    private String[] mInterfaces  

    DoubleClickClassVisitor(int api) {  
        super(api)  
    }  

    DoubleClickClassVisitor(int api, ClassVisitor classVisitor) {  
        super(api, classVisitor)  
    }  

    @Override  
    void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {  
        super.visit(version, access, name, signature, superName, interfaces)  
        mInterfaces = interfaces  
    }  

    @Override  
    public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
        // 此处mv是外部传入的ClassWriter
        MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions)  
        String mInterfaceStr = ""  
        if(mInterfaces != null && mInterfaces.length > 0){  
            for(int i = 0 ; i < mInterfaces.length ; i++){  
                mInterfaceStr += mInterfaces[i]  
            }  
        }  
        println("-------start------")  
        println("mv != null is " + (mv != null))  
        println("name=" + name)  
        println("descriptor=" + descriptor)  
        println("mInterfaceStr=" + mInterfaceStr)  
        if (mv != null && name.contains("onClick")  
            && mInterfaceStr.contains("android/view/View\$OnClickListener")  
            && descriptor.contains("(Landroid/view/View;)V")) {  
            boolean isAbstractMethod = (access & Opcodes.ACC_ABSTRACT) != 0  
            boolean isNativeMethod = (access & Opcodes.ACC_NATIVE) != 0  
            if (!isAbstractMethod && !isNativeMethod) {  
                return new DoubleClickMethodVisitor(api, mv, access, name, descriptor) 
            }  
        }  
        println("------end-------")  
        return mv  
    }  
}

DoubleClickMethodVisitor.groovy:

package com.kongge.commonplugin.doubleclick  
  
import org.objectweb.asm.Label  
import org.objectweb.asm.MethodVisitor  
import org.objectweb.asm.Type  
import org.objectweb.asm.commons.AdviceAdapter  
  
class DoubleClickMethodVisitor extends AdviceAdapter {  
  
    /**  
    * Constructs a new {@link AdviceAdapter}.  
    *  
    * @param api the ASM API version implemented by this visitor. Must be one of {@link  
    * Opcodes#ASM4}, {@link Opcodes#ASM5}, {@link Opcodes#ASM6} or {@link Opcodes#ASM7}.  
    * @param methodVisitor the method visitor to which this adapter delegates calls.  
    * @param access the method's access flags (see {@link Opcodes}).  
    * @param name the method's name.  
    * @param descriptor the method's descriptor (see {@link Type Type}).  
    */  
    protected DoubleClickMethodVisitor(int api, MethodVisitor methodVisitor, int access, String name, String descriptor) {  
        super(api, methodVisitor, access, name, descriptor)  
    }  

    @Override  
    protected void onMethodEnter() {  
        super.onMethodEnter()  
        println("DoubleClickMethodVisitor onMethodEnter()")  
        visitMethodInsn(INVOKESTATIC, "com/kongge/commonsdk/doubleclick/DoubleClickController", "isDoubleClick", "()Z", false)  
        Label label0 = new Label()  
        visitJumpInsn(IFEQ, label0)  
        visitInsn(RETURN)  
        visitLabel(label0)
    }  
}

5.Java字节码

参考:五分钟看懂Java字节码:极简手册,可以先简单了解下字节码指令集,这里推荐一个AndroidStudio的插件,用于方便快捷查看class字节码

AS -> File -> Settings -> Plugins 搜索asm,找到ASM Bytecode Viewer Support Kotlin -> install -> 重启AS

image.png

AS代码区右键,选择ASM Bytecode Viewer

image.png

右侧可看到Bytecode tab,可查看当前class字节码,ASMified tab可查看如何使用ASM生成当前class字节码。

image.png

如何使用ASM生成插桩代码,有个小技巧:先使用插件查看源代码,将ASMified tab所有内容复制到AS编辑代码区,然后将插桩代码加上,再使用插件查看,将ASMified tab全复制并和之前的内容对比。此时会发现不一样的即ASM插桩代码.

image.png

6.查看代码生成

app
    |-build
        |-intermediates
            |-transforms
                 |-DoubleClickTransform
                     |-debug
                         |-[数字]

image.png

即可看到项目所有onClick的方法体内部都加上了点击防抖逻辑

7. jar里面class处理

jar包的处理和主项目有点区别,其实质是一个压缩包,所以得使用ZipFile api加载class

DoubleClickTransform.groovy

public class DoubleClickTransform extends Transform {  
    // ...
    private void visitorJar(File src, File dest) {  
        try {  
            ZipFile inputZip = new ZipFile(src)  
            ZipOutputStream outputZip = new ZipOutputStream(new BufferedOutputStream(Files.newOutputStream(dest.toPath())))  
            Enumeration<? extends ZipEntry> inEntries = inputZip.entries()  
            while (inEntries.hasMoreElements()) {  
                ZipEntry entry = inEntries.nextElement()  
                InputStream originalFile = new BufferedInputStream(inputZip.getInputStream(entry))  
                String name = entry.getName()  
                println("-------------------entryname=" + name)  
                byte[] newEntryContent  
                // 仅对class文件做处理,使用AMS9修改字节码
                if (name.endsWith(".class")) {  
                    ClassReader classReader = new ClassReader(originalFile)  
                    ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS)  
                    DoubleClickClassVisitor doubleClickVisitor = new DoubleClickClassVisitor(Opcodes.ASM9, classWriter)  
                    classReader.accept(doubleClickVisitor, ClassReader.EXPAND_FRAMES)  
                    newEntryContent = classWriter.toByteArray()  
                } else {  
                    newEntryContent = IOUtils.toByteArray(originalFile)  
                }  
                originalFile.close()  
                ZipEntry outEntry = new ZipEntry(name)  
                outputZip.putNextEntry(outEntry)  
                outputZip.write(newEntryContent)  
                outputZip.closeEntry()  
            }  
            outputZip.flush()  
            outputZip.close()  
        } catch (Exception e) {  
            e.printStackTrace()  
        }  
    }
}

AS不支持直接查看Transform目录下的jar,可以安装File Expander插件,安装成功后就可以展开jar查看里面内容)

image.png

8. 注意事项

8.1 混淆问题

由于插件插入DoubleClickController.isDoubleClick()是通过文本名称插入的,所以该类路径和方法名不能被混淆。可以在DoubleClickController sdk muduleconsumer-rules.pro中加入keep,然后在build.gradle中指定此混淆文件。

-keep class com.kongge.commonsdk.doubleclick.DoubleClickController {*;}
android {

    defaultConfig {  
        // ...
        consumerProguardFiles "consumer-rules.pro"  
    }    
}

此时打出来的aar便携带了proguard.txt文件,外部就不需要再配置混淆文件了

image.png

8.2 Lambda表达式问题

上述匹配onClick()方法是通过OnClickListener接口判断的,但是Lambda表达式的写法没匹配上,后续再研究

findViewById(R.id.btnTestSdk).setOnClickListener(view -> {  
    // if (DoubleClickController.isDoubleClick()) return; // 要插入的代码  
    Toast.makeText(TestSdkActivity.this, "test sdk click", Toast.LENGTH_SHORT).show();  
});
8.3 多个进程同时打包,有一定概率出现类丢失问题

由于插件处理jar,会生成一个temp_xxx.jar文件,如果temp_xxx.jar是和源文件是同一个目录,此时多个进程同时打包,有一定概率同一时刻多进程读写同一个jar导致出现脏数据。可以将生成的temp_xxx.jar文件放到当前项目的build目录下。

参考: