Android AOP编程——Gradle插件+TransformAPI+字节码插桩实战
Posted yubo_725
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Android AOP编程——Gradle插件+TransformAPI+字节码插桩实战相关的知识,希望对你有一定的参考价值。
开篇
在前面几篇博文中,我记录了android AOP编程的一些基础知识,包括Gradle插件的开发、TransformAPI的使用,以及一些操作字节码的工具如AspectJ,Javassist和ASM:
- Android AOP编程(一)——AspectJ基础知识
- Android AOP编程(二)——AspectJ语法&实战
- Android AOP编程(三)——Javassist基础
- Android Gradle插件开发基础
- Android Transform API的使用
- Android AOP编程(四)——ASM基础
本篇将要记录的是将这些知识点串联起来的实战开发,要完成如下几个功能:
- 在Activity的onCreate方法中插入新代码(本篇主要是在每个Activity的onCreate中插入一个Toast)
- 处理点击事件重复触发问题(使用自定义注解处理快速点击时事件重复触发问题)
- 统计某个方法的执行时长(使用自定义注解统计某个方法的执行时长,类似JakeWharton开发的hugo)
- 修复第三方jar包中的错误代码(比如修复某个jar包中的bug,直接修改字节码而不是修改源码后重新打包成jar)
下面请跟着我的步骤一步步完成上面几个功能吧!
开始
在实现上面说的4个功能之前,我们有一些通用的步骤:
- 创建新的Android项目AopDemo
- 在根目录下创建buildSrc目录并编译项目,buildSrc目录下会自动创建一些文件,我们的插件项目会基于buildSrc目录编写
- 在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 }
- 在buildSrc目录下创建
src/main/java
和src/main/resources
目录 - 在
src/main/java
目录下创建包com.test.plugin
,在src/main/resources
目录下创建META-INF/gradle-plugins
目录,并在该目录中添加文件,名为com.test.plugin.properties
,文件内容为implementation-class=com.test.plugin.MyPlugin
- 在
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 语言特性 )