Android AOP编程——Gradle插件+TransformAPI+字节码插桩实战

Posted yubo_725

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Android AOP编程——Gradle插件+TransformAPI+字节码插桩实战相关的知识,希望对你有一定的参考价值。

开篇

在前面几篇博文中,我记录了android AOP编程的一些基础知识,包括Gradle插件的开发、TransformAPI的使用,以及一些操作字节码的工具如AspectJ,Javassist和ASM:

本篇将要记录的是将这些知识点串联起来的实战开发,要完成如下几个功能:

  1. 在Activity的onCreate方法中插入新代码(本篇主要是在每个Activity的onCreate中插入一个Toast)
  2. 处理点击事件重复触发问题(使用自定义注解处理快速点击时事件重复触发问题)
  3. 统计某个方法的执行时长(使用自定义注解统计某个方法的执行时长,类似JakeWharton开发的hugo
  4. 修复第三方jar包中的错误代码(比如修复某个jar包中的bug,直接修改字节码而不是修改源码后重新打包成jar)

下面请跟着我的步骤一步步完成上面几个功能吧!

开始

在实现上面说的4个功能之前,我们有一些通用的步骤:

  1. 创建新的Android项目AopDemo
  2. 在根目录下创建buildSrc目录并编译项目,buildSrc目录下会自动创建一些文件,我们的插件项目会基于buildSrc目录编写
  3. 在buildSrc目录下创建build.gradle文件并添加如下配置:
    apply plugin: 'java-library'
    apply plugin: 'groovy'
    apply plugin: 'maven'
    
    repositories {
        google()
        mavenCentral()
        mavenLocal()
    }
    
    dependencies {
        implementation gradleApi()
        implementation localGroovy()
        implementation 'com.android.tools.build:gradle:4.2.2'
        // javassist.jar文件可以在文末的源码中找到
        implementation files('libs/javassist.jar')
    }
    
    sourceSets {
        main {
            java {
                srcDir 'src/main/java'
            }
            resources {
                srcDir 'src/main/resources'
            }
        }
    }
    
    java {
        sourceCompatibility = JavaVersion.VERSION_1_8
        targetCompatibility = JavaVersion.VERSION_1_8
    }
    
  4. 在buildSrc目录下创建src/main/javasrc/main/resources目录
  5. src/main/java目录下创建包com.test.plugin,在src/main/resources目录下创建META-INF/gradle-plugins目录,并在该目录中添加文件,名为com.test.plugin.properties,文件内容为implementation-class=com.test.plugin.MyPlugin
  6. com.test.plugin包中加入如下两个类
    //MyPlugin.java
    package com.test.plugin;
    
    import com.android.build.gradle.BaseExtension;
    
    import org.gradle.api.Plugin;
    import org.gradle.api.Project;
    
    public class MyPlugin implements Plugin<Project> {
        @Override
        public void apply(Project target) {
            // 在Plugin中注册自定义的Transform
            BaseExtension baseExtension = target.getExtensions().findByType(BaseExtension.class);
            if (baseExtension != null) {
                baseExtension.registerTransform(new MyTransform());
            }
        }
    }
    
    //MyTransform.java
    package com.test.plugin;
    
    import com.android.build.api.transform.DirectoryInput;
    import com.android.build.api.transform.Format;
    import com.android.build.api.transform.JarInput;
    import com.android.build.api.transform.QualifiedContent;
    import com.android.build.api.transform.Transform;
    import com.android.build.api.transform.TransformException;
    import com.android.build.api.transform.TransformInput;
    import com.android.build.api.transform.TransformInvocation;
    import com.android.build.gradle.internal.pipeline.TransformManager;
    import com.android.utils.FileUtils;
    import com.test.plugin.utils.InjectUtil;
    
    import org.apache.commons.io.IOUtils;
    
    import java.io.File;
    import java.io.FileOutputStream;
    import java.io.IOException;
    import java.io.InputStream;
    import java.util.Collection;
    import java.util.Enumeration;
    import java.util.Set;
    import java.util.jar.JarEntry;
    import java.util.jar.JarFile;
    import java.util.jar.JarOutputStream;
    import java.util.zip.ZipEntry;
    
    public class MyTransform extends Transform {
    
        @Override
        public String getName() {
            return "MyCustomTransform";
        }
    
        @Override
        public Set<QualifiedContent.ContentType> getInputTypes() {
            return TransformManager.CONTENT_CLASS;
        }
    
        @Override
        public Set<? super QualifiedContent.Scope> getScopes() {
            return TransformManager.SCOPE_FULL_PROJECT;
        }
    
        @Override
        public boolean isIncremental() {
            return false;
        }
    
        @Override
        public void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
            super.transform(transformInvocation);
            if (!transformInvocation.isIncremental()) {
                // 非增量编译,则删除之前的所有输出
                transformInvocation.getOutputProvider().deleteAll();
            }
            // 拿到所有输入
            Collection<TransformInput> inputs = transformInvocation.getInputs();
            if (!inputs.isEmpty()) {
                for (TransformInput input : inputs) {
                    // directoryInputs保存的是存放class文件的所有目录,可以通过方法内打印的log查看具体的目录
                    Collection<DirectoryInput> directoryInputs = input.getDirectoryInputs();
                    handleDirInputs(transformInvocation, directoryInputs);
    
                    // jarInputs保存的是所有依赖的jar包的地址,可以通过方法内打印的log查看具体的jar包路径
                    Collection<JarInput> jarInputs = input.getJarInputs();
                    handleJarInputs(transformInvocation, jarInputs);
                }
            }
        }
    
        // 处理输入的目录
        private void handleDirInputs(TransformInvocation transformInvocation, Collection<DirectoryInput> directoryInputs) {
            for (DirectoryInput directoryInput : directoryInputs) {
                String absolutePath = directoryInput.getFile().getAbsolutePath();
    //            System.out.println(">>>> directory input file path: " + absolutePath);
                // 处理class文件
                InjectUtil.inject(absolutePath);
                // 获取目标地址
                File contentLocation = transformInvocation.getOutputProvider().getContentLocation(directoryInput.getName(),
                        directoryInput.getContentTypes(), directoryInput.getScopes(), Format.DIRECTORY);
                // 拷贝目录
                try {
                    FileUtils.copyDirectory(directoryInput.getFile(), contentLocation);
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    
        // 处理输入的Jar包
        private void handleJarInputs(TransformInvocation transformInvocation, Collection<JarInput> jarInputs) {
            for (JarInput jarInput : jarInputs) {
                String absolutePath = jarInput.getFile().getAbsolutePath();
    //            System.out.println(">>>> jar input file path: " + absolutePath);
                File contentLocation = transformInvocation.getOutputProvider().getContentLocation(jarInput.getName(),
                        jarInput.getContentTypes(), jarInput.getScopes(), Format.JAR);
                try {
                    // 匹配要修复的jar包
                    if (absolutePath.endsWith("calculator-0.0.1.jar")) {
                        // 原始的jar包
                        JarFile jarFile = new JarFile(absolutePath);
                        // 处理后的jar包路径
                        String tmpJarFilePath = jarInput.getFile().getParent() + File.separator + jarInput.getFile().getName() + "_tmp.jar";
                        File tmpJarFile = new File(tmpJarFilePath);
                        JarOutputStream jos = new JarOutputStream(new FileOutputStream(tmpJarFile));
    //                    System.out.println("origin jar file path: " + jarInput.getFile().getAbsolutePath());
    //                    System.out.println("tmp jar file path: " + tmpJarFilePath);
                        Enumeration<JarEntry> entries = jarFile.entries();
                        // 遍历jar包中的文件,找到需要修改的class文件
                        while (entries.hasMoreElements()) {
                            JarEntry jarEntry = entries.nextElement();
                            String name = jarEntry.getName();
                            jos.putNextEntry(new ZipEntry(name));
                            InputStream is = jarFile.getInputStream(jarEntry);
                            // 匹配到有问题的class文件
                            if ("com/bug/calculator/BugCalculator.class".equals(name)) {
                                // 处理有问题的class文件并将新的数据写入到新jar包中
                                jos.write(InjectUtil.fixJarBug(absolutePath));
                            } else {
                                // 没有问题的直接写入到新的jar包中
                                jos.write(IOUtils.toByteArray(is));
                            }
                            jos.closeEntry();
                        }
                        // 关闭IO流
                        jos.close();
                        jarFile.close();
                        // 拷贝新的Jar文件
    //                    System.out.println(">>>>>>>>copy to dest: " + contentLocation.getAbsolutePath());
                        FileUtils.copyFile(tmpJarFile, contentLocation);
                        // 删除临时文件
    //                    System.out.println(">>>>>>>>tmpJarFile: " + tmpJarFile.getAbsolutePath());
                        tmpJarFile.delete();
                    } else {
                        FileUtils.copyFile(jarInput.getFile(), contentLocation);
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }
    }
    
    

以上代码主要是创建了一个gradle插件并在其中添加了一个自定义的Transform,这样Android项目在编译过程中,会自动执行我们定义的MyTransform类中的transform方法。

上面的代码中最主要的是MyTransform类中的transform方法,其编写格式基本固定如上面的代码所示,通过TransformInvocation对象拿到所有的class输入,其中又包括目录和jar包,对目录和jar包要单独处理,分别对应上面代码中的handleDirInputs() handleJarInputs()方法。

handleDirInputs()方法主要是通过InjectUtil.inject()方法完成对class文件的处理。

handleJarInputs()方法主要是匹配需要处理的jar包,然后通过Java提供的一些操作Jar包的API来读取Jar包内需要修改的class文件,再将jar包拷贝到指定的目录下。

InjectUtil类在com.test.plugin.utils包下,它主要完成对class文件的一些操作。

在开篇中我们需要完成的四个功能,主要逻辑都集中在InjectUtil类中,下面就看看每个功能是如何实现的吧!

在方法中插入新代码

在本例子中,我们将通过一个gradle插件,在代码编译期,向所有Activity的onCreate方法中插入一句Toast代码,弹出当前Activity的名称。下面直接上源码:

/**
 * 使用Javassist操作Class字节码,在所有Activity的onCreate方法中插入Toast
 * @param filePath class文件路径
 */
private static void addToast(String filePath) {
    try {
        CtClass ctClass = classPool.getCtClass(getFullClassName(filePath));
        if (ctClass.isFrozen()) {
            ctClass.defrost();
        }
        // 获取Activity中的onCreate方法
        CtMethod onCreate = ctClass.getDeclaredMethod("onCreate");
        // 要插入的代码
        // getSimpleClassName()方法返回的是类名如"MainActivity"
        String insertCode = String.format(Locale.getDefault(),
                "Toast.makeText(this, \\"%s\\", Toast.LENGTH_SHORT).show();",
                getSimpleClassName(filePath));
        // 在onCreate方法开始处插入上面的Toast代码
        onCreate.insertBefore(insertCode);
        // 写回原来的目录下,覆盖原来的class文件
        // mainModuleClassPath是Android项目主module存放class的路径,一般是"<Android项目根目录>/app/build/intermediates/javac/debug/classes/"
        ctClass.writeFile(mainModuleClassPath);
        ctClass.detach();
    } catch (Exception e) {
        e.printStackTrace();
    }
}

在Activity的onCreate方法中插入Toast的处理代码非常简短,以上代码仅仅调用了CtMethod的insertBefore方法即可在某个方法开始处插入新代码。

要验证以上代码是否正常工作也非常简单,直接打开编译后的class文件即可,或者运行项目到手机或模拟器,可以看到每个Activity在打开时都会弹出toast提示当前的Activity名称。

处理点击事件重复触发

Android AOP编程(二)——AspectJ语法&实战这篇文章中,我记录过使用AspectJ处理点击事件重复触发的问题,也是使用自定义注解,结合AspectJ匹配注解来完成的。本篇将使用Javassist库完成同样的功能。

首先需要创建一个java-library,这里命名为annotation,在annotation的根目录下编辑build.gradle文件,内容如下:

apply plugin: 'java-library'
apply plugin: 'maven'

repositories {
    mavenLocal()
}

dependencies {
    implementation gradleApi()
}

//publish to local directory
group "com.example.annotation"
version "1.0.0"

uploadArchives{ //当前项目可以发布到本地文件夹中
    repositories {
        mavenDeployer {
            repository(url: uri('./repo')) //定义本地maven仓库的地址
        }
    }
}

java {
    sourceCompatibility = JavaVersion.VERSION_1_8
    targetCompatibility = JavaVersion.VERSION_1_8
}

之所以上面要配置uploadArchives,是因为在buildSrc中无法直接引用annotation库,需要将annotation库上传到本地maven,再在buildSrc中依赖它。

annotation库中的代码很简单,在src/main/java目录下创建一个com.example.annotation包并在其中添加ClickOnce这个注解,代码如下:

package com.example.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.CLASS)
public @interface ClickOnce {
}

然后我们将annotation这个library上传到本地maven,上传的方法为打开AndroidStudio右侧的gradle视图,在其中找到uploadArchives菜单双击即可,如下图所示:
在这里插入图片描述
执行uploadArchives成功后,会在annotation目录下生成repo目录,在其中可以看到上传的library。

为了让buildSrc插件项目可以引用我们创建的annotation,需要在buildSrc目录的build.gradle文件中,加入如下配置:

repositories {
    ...
    maven {
    	// 这个地址填
        url '/Users/xxx/IdeaProjects/AopDemo/annotation/repo/'
    }
}

dependencies {
    ...
    implementation 'com.example.annotation:annotation:1.0.0'
}

我们需要实现的功能是,使用@ClickOnce注解的方法,在600ms内只执行一次,这样就能保证在快速点击时,不重复触发点击事件,在MainActivity中我们加入测试代码如下:

public class MainActivity extends AppCompatActivity implements View.OnClickListener {

    private TextView textView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        textView = findViewById(R.id.textView);
        textView.setOnClickListener(this);
    }

    @Override
    @ClickOnce
    public void onClick(View v) {
        startActivity(new Intent(this, OtherActivity.class));
    }
}

然后我们使用InjectUtil工具类来操作class文件,找到被@ClickOnce注解的方法,主要逻辑如下:

/**
 * 处理被@ClickOnce注解的方法,确保这个方法在600ms内只执行一次
 * @param filePath
 */
private static void ensureClickOnce(String filePath) {
    try {
        CtClass ctClass = classPool.getCtClass(getFullClassName(filePath));
        if (ctClass.isFrozen()) {
            ctClass.defrost();
        }
        CtMethod[] declaredMethods = ctClass.getDeclaredMethods();
        // 类中是否有被@ClickOnce注解的方法
        boolean clzHasClickAnnotation = false;
        for (CtMethod m : declaredMethods) {
            if (m.hasAnnotation(ClickOnce.class)) {
                clzHasClickAnnotation = true;
                break;
            }
        }
        // 如果类中有被@ClickOnce注解的方法,则创建新方法,并在所有被@ClickOnce注解的方法开始处插入检查代码
        if (clzHasClickAnnotation) {
            // 创建新方法并添加到类中
            createClickOnceMethod(ctClass);
            // 重新读取并加载class,因为上一步中写入了新的方法
            ctClass = classPool.get(getFullClassName(filePath));
            if (ctClass.isFrozen()) ctClass.defrost();
            declaredMethods = ctClass.getDeclaredMethods();
            for (CtMethod m : declaredMethods) {
                if (m.hasAnnotation(ClickOnce.class)) {
                    System.out.println("found @ClickOnce method: " + m.getName());
                    // 在当前被@ClickOnce注解的方法体前面执行上面创建的新方法
                    m.insertBefore("if (!is$Click$Valid()) {" +
                            "Log.d(\\"ClickOnce\\", \\"Click too fast, ignore this click...\\");" +
                            "return;" +
                            "}");
           

以上是关于Android AOP编程——Gradle插件+TransformAPI+字节码插桩实战的主要内容,如果未能解决你的问题,请参考以下文章

Android AOP编程——Gradle插件+TransformAPI+字节码插桩实战

AOP 面向切面编程Android Studio 中配置 AspectJ ( 下载并配置AS中 jar 包 | 配置 Gradle 和 Gradle 插件版本 | 配置 Gradle 构建脚本 )(代

Android Gradle 插件Gradle 构建工具简介 ② ( Android 项目构建打包流程 | 构建工具发展 -> 手动配置 -> Ant -> Maven -> Gradle )

Android Gradle 插件Gradle 构建工具简介 ③ ( Gradle 构建脚本编程语言 | Groovy 语言简介 | Groovy 语言特性 )

通过AOP的思想 打造万能动态权限申请框架Demo完全解析

Android AspectJ的AOP切面编程学习(个人录)