AOP
AOP 是 Aspect Oriented Programming 的缩写,译为面向切向编程。AOP和OOP(Object Oriented Programming,面向对象编程)是不同的编程思想。OOP 是把问题划分到单个模块,AOP 是把涉及到众多模块的某一类问题进行统一管理。AOP的目标是把某个功能集中起来,放到一个统一的地方来控制和管理。利用 AOP 思想,对业务逻辑的各个部分进行了隔离,从而降低业务逻辑各部分之间的耦合,提高程序的可重用性,提高开发效率。
常用的AOP工具:
- APT:是一种处理注解的工具,可以在编译期帮我们生成Java文件(需要手动拼接代码,或使用Javapoet),但无法修改已有Java文件,应用案例:ButterKnife、Dragger2、EventBus3、DataBinding
- AspectJ:可以修改Java文件和Class字节码,功能强大,最常见的AOP库,但是存在切入点固定和性能较低等缺点。应用案例:Hugo、AspectJx
- Javassist:它是一个编辑Class字节码的类库,编程简单,直接使用Java编码的形式,而不需要了解虚拟机指令,就能动态改变类的结构或者动态生成类。应用案例:HotFix
- ASM:可以修改Class字节码,在编译时插入逻辑,性能好,有ASM Btyecode Viewer等插件支持生成ASM代码。应用案例:booster、lancet等框架
- ASMDEX、DexMaker:也是静态织入代码,学习成本太高
- cglib:运行时织入代码,作用于class字节码,常用的动态代理库,比JVM自带的动态代理更灵活,但不适用于Android,因为Android运行时是dex文件,不是class文件
- xposed、dexposed、epic:运行时hook,有兼容性问题,只适合调试时玩玩,不适合生产环境。
常用的编译期静态织入AOP方式:APT、AspectJ、Javassist、ASM。
字节码插桩:
字节码插桩就是在.class文件转为.dex之前,修改.class文件从而达到修改代码的目的。常用的框架有:AspectJ、Javassist、ASM。
AspectJ 框架虽然比较成熟并且使用简单的优点,但是存在切入点固定和性能较低等缺点。
目前主流的字节码修改框架是ASM和Javaassist,两者对比:
| 特性 | Javassist | ASM |
|---|---|---|
| 包大小 | 771 KB (3.27) | 265 KB (6.0 BETA) |
| 性能 | 劣于 ASM | 优于 Javassist |
| API 封装程度 | 高 | 低 |
| 功能完备程度 | 完备 | 完备 |
| 对开发者的要求 | 基本了解 class 文件格式和 JVM 指令集 | 需要精通 class 文件格式和 JVM 指令集 |
| 学习曲线 | 平缓 | 陡峭 |
| 文档及手册 | 简单明了 | 有些繁琐(Vistor 模式让初学者有点懵) |
从上面的对比来看,如果是初学者,建议选择 Javassist,毕竟上手快,学习起来比较容易,如果是对性能、包体积方面要求比较高,建议选择 ASM。Android的相关开源框架中,使用ASM较多。我们下面选择的也是ASM。
在下面的介绍中,介绍常用的编译时插桩的方式为:Gradle Plugin+Transform+ASM。
应用场景:
处理三方库的代码:当某些第三方 SDK 库没有源码,而想对它的class直接做点手脚;或者给它内部的一个崩溃函数增加 try catch,实现”异常处理“。
性能监控:对一些class插桩,做UI,内存,网络等等方面的性能监控。
API代理:对隐私相关的API进行插桩代理,实现隐私合规。
以及权限控制、无痕埋点、安全控制、日志记录、事件防抖、大图检测、多线程优化等等。
ASM的使用
做项目优化时,我们通常会先打印出方法的执行时间,再根据方法的耗时情况对其进行优化。代码如下:
public static void main(String[] args) {
long start = System.currentTimeMillis();
//...
long end = System.currentTimeMillis();
System.out.println("execute:" + (end - start));
}
如果是一两个方法我们手动插入代码没有问题,但是整个项目都要我们手动去插入的话,那会把手废掉的。接下来就以使用字节码插桩的方式,批量添加方法的耗时打印。
1、引入ASM
首先找到ASM的官网:asm.ow2.io/。可以找到最新的版本ASM 9.3(成稿时)。
因此,我们可以在app中build.gradle中的dependencies加入:
testImplementation('org.ow2.asm:asm:9.3')
testImplementation('org.ow2.asm:asm-commons:9.3')//一些通用封装,使用更方便
需要注意的是:我们使用testImplementation引入,这表示我们只能在Java的单元测试中使用这个框架,对我们Android中的依赖关系没有任何影响。
2、准备待插桩Class
在工程中创建一个Java类:
public class InjectMethod {
@MethodTime
public static void main(String[] args) {
System.out.println("my words");
}
}
由于我们操作的是字节码插桩,所以先执行build让这个类进行编译生成对应的class文件。在{projectDir}/build/intermediates/javac/debug/classes/{pakagename}/路径下会有InjectMethod.class文件。
创建MethodTime注解类:
@Retention(RetentionPolicy.CLASS)
@Target({ElementType.METHOD})
public @interface MethodTime {
}
创建此注解类后,给指定的方法加上注解,会在接下来只插桩被此注解的方法,而不是插桩所有的方法。
3、执行插桩
我们输入命令:java InjectMethod执行这个Class只会输出“my words”,没有执行时间的log输出。那么我们接下来利用ASM,向main方法中插入记录函数执行时间的日志输出。
在单元测试中(test/java目录中)写入测试方法:
public class Test {
@org.junit.Test
public void test() throws IOException {
/**
* 1、准备待分析的class
*/
FileInputStream fis = new FileInputStream
("{projectDir}/build/intermediates/javac/debug/classes/{pakagename}/InjectMethod.class");
/**
* 2、执行分析与插桩
*/
//用于读取已经编译好的 .class 文件
ClassReader classReader = new ClassReader(fis);
// 用于重新构建编译后的类,如修改类名、属性以及方法,也可以生成新的类的字节码文件
// COMPUTE_MAXS 告诉ASM 自动计算栈的最大值以及最大数量的方法的本地变量。
// COMPUTE_FRAMES 标识让ASM 自动计算方法的栈桢
ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
// 分析,使ClassAdapterVisitor访问类文件结构,处理结果写入classWriter,
// EXPAND_FRAMES:说明在读取 class 的时候需要同时展开栈映射帧(StackMap Frame)
classReader.accept(new ClassAdapterVisitor(classWriter), ClassReader.EXPAND_FRAMES);
/**
* 3、获得结果并输出
*/
String pathName = "{projectDir}/src/test/result/";
byte[] newClassBytes = classWriter.toByteArray();
File file = new File(pathName);
file.mkdirs();
FileOutputStream fos = new FileOutputStream
(pathName + "InjectMethod.class");
fos.write(newClassBytes);
fos.close();
}
}
上面的代码会获取上一步生成的class,然后由ASM执行完插桩之后,将结果输出到test/result目录下。
第1步是读取class文件,第3步是将最终的文件保存,最主要的是第2步的处理流程。
把class数据交给ClassReader,然后进行分析,类似于XML解析,分析结果会以事件驱动的形式告知给accept的第一个参数ClassAdapterVisitor。
public class ClassAdapterVisitor extends ClassVisitor {
public ClassAdapterVisitor(ClassVisitor cv) {
super(Opcodes.ASM9, cv);
}
@Override
public MethodVisitor visitMethod(int access, String name, String desc, String signature,
String[] exceptions) {
System.out.println("method's name:" + name + " desc:" + desc);
MethodVisitor mv = super.visitMethod(access, name, desc, signature,
exceptions);
return new MethodAdapterVisitor(api, mv, access, name, desc);
// return new MethodAdapterVisitorByAsmByteCoderViewer(api, mv, access, name, desc);
}
}
我们只关注class的method插桩。所以,此处只覆写了visitMethod方法。在这个方法中我们返回一个MethodVisitor方法分析器对象。一个方法的参数、注解以及方法体需要在MethodVisitor中进行分析与处理。
public class MethodAdapterVisitor extends AdviceAdapter {
private boolean inject;
protected MethodAdapterVisitor(int api, MethodVisitor methodVisitor, int access, String name, String descriptor) {
super(api, methodVisitor, access, name, descriptor);
}
/**
* 分析方法上面的注解
* 判断当前这个方法是不是使用了MethodTime,如果使用了,我们就需要对这个方法插桩
*/
@Override
public AnnotationVisitor visitAnnotation(String desc, boolean visible) {
System.out.println("desc: " + desc);
if (Type.getDescriptor(MethodTime.class).equals(desc)) {
System.out.println(desc);
inject = true;
}
return super.visitAnnotation(desc, visible);
}
private int start;
/**
* 方法进入的时候执行
*/
@Override
protected void onMethodEnter() {
super.onMethodEnter();
System.out.println("onMethodEnter inject: " + inject);
if (inject) {
//invokeStatic指令,调用静态方法
invokeStatic(Type.getType("Ljava/lang/System;"),
new Method("currentTimeMillis", "()J"));
//创建本地 LONG类型变量
start = newLocal(Type.LONG_TYPE); //创建本地 LONG类型变量
System.out.println("start: " + start);
//记录 方法执行结果给创建的本地变量
//store指令 将方法执行结果从操作数栈存储到局部变量
storeLocal(start);
}
}
/**
* 方法返回的时候执行
*
* @param opcode
*/
@Override
protected void onMethodExit(int opcode) {
super.onMethodExit(opcode);
System.out.println("onMethodExit inject: " + inject);
if (inject) {
invokeStatic(Type.getType("Ljava/lang/System;"),
new Method("currentTimeMillis", "()J"));
int end = newLocal(Type.LONG_TYPE);
//store指令 将方法执行结果从操作数栈存储到局部变量
storeLocal(end);
getStatic(Type.getType("Ljava/lang/System;"), "out", Type.getType("Ljava/io/PrintStream;"));
//分配内存 并dup压入栈顶让下面的INVOKESPECIAL 知道执行谁的构造方法创建StringBuilder
newInstance(Type.getType("Ljava/lang/StringBuilder;"));
dup();
invokeConstructor(Type.getType("Ljava/lang/StringBuilder;"), new Method("<init>", "()V"));
visitLdcInsn("execute:");
invokeVirtual(Type.getType("Ljava/lang/StringBuilder;"), new Method("append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;"));
//减法
loadLocal(end);
loadLocal(start);
math(SUB, Type.LONG_TYPE);
invokeVirtual(Type.getType("Ljava/lang/StringBuilder;"), new Method("append", "(J)Ljava/lang/StringBuilder;"));
invokeVirtual(Type.getType("Ljava/lang/StringBuilder;"), new Method("toString", "()Ljava/lang/String;"));
invokeVirtual(Type.getType("Ljava/io/PrintStream;"), new Method("println", "(Ljava/lang/String;)V"));
}
}
}
MethodAdapterVisitor继承自AdviceAdapter(asm-common库中的封装对象),其实就是MethodVisitor 的子类,AdviceAdapter封装了指令操作的方法,更为直观与简单。
在visitAnnotation中判断此方法是否被MethodTime注解了,只有被注解的,才会对方法插桩。
上述代码中onMethodEnter在进入一个方法时候回调,因此在这个方法中插入指令就是在整个方法最开始加入一些代码。我们需要在这个方法中插入long start = System.currentTimeMillis();。
onMethodExit在退出一个方法时候回调,我们需要在这个方法中插入long end = System.currentTimeMillis(); System.out.println("execute:" + (end - start));;。
这两部分的代码怎么写呢?这就需要有一些字节码的知识了。字节码知识不是本文的重点,再次不展开描述。字节码的相关知识,可以上网查找资料。
接下来我们介绍两种查看字节码的方式:
第一种:使用javap查看字节码:
首先创建一个目标Java类:
public class TargetMethod {
public static void main(String[] args) {
long start = System.currentTimeMillis();
System.out.println("my words");
long end = System.currentTimeMillis();
System.out.println("execute:" + (end - start));
}
}
将java编译为class文件:javac TargetMethod.java
查看字节码:javap -c TargetMethod.class
可以得到下面的字节码命令:
对应关系为:
1:long start = System.currentTimeMillis();
2: System.out.println("my words");
3:long end = System.currentTimeMillis(); System.out.println("execute:" + (end - start));
2是原来方法中的代码,不需要处理,只需要关注1和3处的字节码。
所以通过字节码与ASM API的对应关系,可以写出MethodAdapterVisitor中的onMethodEnter和onMethodExitASM插桩代码。
第二种:使用ASM Bytecode Viewer插件
既然通过字节码这种方式这么麻烦,还需要对照字节码,通过ASM的方法自己实现插桩的逻辑,有没有更简单的方法呢?答案是有的。就是借助插件ASM Bytecode Viewer查看。
AS安装ASM Bytecode Viewer后,找到{projectDir}/build/intermediates/javac/debug/classes/{pakagename}/路径下的TargetMethod.class文件。在class类源码中点击右键,选择ASM Bytecode Viewer,会弹出ASM Plugin界面。
在此界面的上面会有三项内容:
- Bytecode 表示对应的class字节码文件
- ASMified 表示利用ASM框架生成字节码对应的代码
- Groovified 对应的是class字节码指令
Bytecode中也可以类似使用javap查看字节码。我们主要看ASMified中的代码,这里面的代码就是ASM的插桩代码。将TargetMethod.class与InjectMethod.class中展示的ASMified代码对比之后,可以得到如下的代码:
public class MethodAdapterVisitorByAsmByteCoderViewer extends AdviceAdapter {
private boolean inject;
private static final String ANNOTATION_TRACK_METHOD = "Lcom/example/asmdemo/MethodTime;";
protected MethodAdapterVisitorByAsmByteCoderViewer(int api, MethodVisitor methodVisitor, int access, String name, String descriptor) {
super(api, methodVisitor, access, name, descriptor);
}
/**
* 分析方法上面的注解
* 判断当前这个方法是不是使用了ASMTest,如果使用了,我们就需要对这个方法插桩
*/
@Override
public AnnotationVisitor visitAnnotation(String desc, boolean visible) {
System.out.println("desc: " + desc);
if (ANNOTATION_TRACK_METHOD.equals(desc)) {
System.out.println(desc);
inject = true;
}
return super.visitAnnotation(desc, visible);
}
private int start;
/**
* 方法进入的时候执行
*/
@Override
protected void onMethodEnter() {
super.onMethodEnter();
System.out.println("onMethodEnter inject: " + inject);
if (inject) {
mv.visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false);
//存储局部变量1
mv.visitVarInsn(LSTORE, 1);
}
}
/**
* 方法返回的时候执行
*
* @param opcode
*/
@Override
protected void onMethodExit(int opcode) {
super.onMethodExit(opcode);
System.out.println("onMethodExit inject: " + inject);
if (inject) {
mv.visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false);
//存储局部变量3
mv.visitVarInsn(LSTORE, 3);
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
//分配内存
mv.visitTypeInsn(NEW, "java/lang/StringBuilder");
mv.visitInsn(DUP);
mv.visitMethodInsn(INVOKESPECIAL, "java/lang/StringBuilder", "<init>", "()V", false);
mv.visitLdcInsn("execute:");
mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false);
//减法
mv.visitVarInsn(LLOAD, 3);
mv.visitVarInsn(LLOAD, 1);
mv.visitInsn(LSUB);
mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(J)Ljava/lang/StringBuilder;", false);
mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "toString", "()Ljava/lang/String;", false);
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
}
}
}
在ASM当中,Label类可以用于实现选择(if、switch)、循环(for、while)和try-catch语句。此处只是简单的顺序结构,不需要跳转的逻辑。所以,上面代码删除了Label相关的代码。
将ClassAdapterVisitor中visitMethod中的 return new MethodAdapterVisitor(api, mv, access, name, desc);替换为 return new MethodAdapterVisitorByAsmByteCoderViewer(api, mv, access, name, desc);。可以实现同样的效果。
使用这种方式,只需要了解少量的字节码知识,就可以实现ASM插桩功能。
4、生成插桩之后的class
通过上面的分析过程,已经完成对注解方法的字节码插桩的实现。运行Test的方法,会在test/result目录下生成以下class类:
public class InjectMethod {
public InjectMethod() {
}
@MethodTime
public static void main(String[] var0) {
long var1 = System.currentTimeMillis();
System.out.println("my words");
long var3 = System.currentTimeMillis();
System.out.println("execute:" + (var3 - var1));
}
}
这就完成了对此方法的插桩实现。
Transform的使用
通过上面的介绍,已经具备对字节码修改的能力了,但是想要在编译过程中自动对字节码修改,还需要引入Gradle Transform的相关知识。
上面这张图片描述的apk编译流程对Android开发者来说,应该是比较熟悉的。我们主要关注的是将class文件打包成dex文件之前的这个阶段(如图中箭头处所示),中间就涉及到Android Gradle Plugin的Transform流程。
Transform 是由Android Gradle Plugin提供,允许第三方插件在将已编译的类文件转换为dex文件之前对其进行操作。
Transform原理图如下所示:
将class文件、jar文件、资源文件作为输入,经过一系列的Transform处理,首先是自定义的Transform处理,然后是系统的Transform处理,最后一个Transform是负责生成dex文件。
可以通过自定义Plugin,注册一个自定义的Transform到编译流程中去,目的是拿到所有.class文件,再结合ASM 工具修改字节码。
自定义Plugin
使用buildSrc的方式自定义Plugin(自定义的Plugin的作用范围是整个工程),介入到编译流程中。
为了能够引入Gradle的能力,将buildSrc内的build.gradle的内容修改成如下的形式。
apply plugin: 'groovy'
dependencies {
implementation gradleApi()//gradle sdk
implementation 'com.android.tools.build:gradle:3.5.4'
implementation 'com.android.tools.build:gradle-api:3.5.4'
//ASM依赖
implementation 'org.ow2.asm:asm:9.3'
implementation 'org.ow2.asm:asm-util:9.3'
implementation 'org.ow2.asm:asm-commons:9.3'
}
repositories {
google()
jcenter()
}
上述内容完成sync以后,然后在java下自定义Plugin:
public class AsmPlugin implements Plugin<Project> {
@Override
public void apply(Project project) {
System.out.println("=========== AsmPlugin ============");
AppExtension appExtension = project.getExtensions().findByType(AppExtension.class);
assert appExtension != null;
//注册自定义Transform
appExtension.registerTransform(new AsmTransform(project));
}
}
自定义Transform
public class AsmTransform extends Transform {
Project project;
public AsmTransform(Project project) {
this.project = project;
}
/**
* 获取Transform的名字
*/
@Override
public String getName() {
return AsmTransform.class.getName();
}
/**
* 需要处理的数据类型,有两种枚举类型
* CONTENT_CLASS:编译后的字节码文件(jar 包或目录)
* CONTENT_RESOURCES:标准的 Java 资源
*/
@Override
public Set<QualifiedContent.ContentType> getInputTypes() {
return TransformManager.CONTENT_CLASS;
}
/**
* 指 Transform 要操作内容的范围,官方文档 Scope 有 7 种类型:
* 1. EXTERNAL_LIBRARIES 只有外部库
* 2. PROJECT 只有项目内容
* 3. PROJECT_LOCAL_DEPS 只有项目的本地依赖(本地jar)
* 4. PROVIDED_ONLY 只提供本地或远程依赖项
* 5. SUB_PROJECTS 只有子项目。
* 6. SUB_PROJECTS_LOCAL_DEPS 只有子项目的本地依赖项(本地jar)。
* 7. TESTED_CODE 由当前变量(包括依赖项)测试的代码
*/
@Override
public Set<? super QualifiedContent.Scope> getScopes() {
return TransformManager.SCOPE_FULL_PROJECT;
}
/**
* 是否支持增量更新
*/
@Override
public boolean isIncremental() {
return true;
}
@Override
public void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
super.transform(transformInvocation);
//输入,可以从中获取jar包和class文件夹路径。需要输出给下一个任务
Collection<TransformInput> inputs = transformInvocation.getInputs();
//OutputProvider管理输出路径
TransformOutputProvider outputProvider = transformInvocation.getOutputProvider();
//遍历目录
for (TransformInput input : inputs) {
// 遍历jar 第三方引入的 class
for (JarInput jarInput : input.getJarInputs()) {
File dest = outputProvider.getContentLocation(
jarInput.getFile().getAbsolutePath(),
jarInput.getContentTypes(),
jarInput.getScopes(),
Format.JAR);
//不处理jar文件,直接copy
FileUtils.copyFile(jarInput.getFile(), dest);
}
//
for (DirectoryInput directoryInput : input.getDirectoryInputs()) {
handleDirectoryInput(directoryInput, outputProvider);
}
}
}
private static void handleDirectoryInput(DirectoryInput directoryInput, TransformOutputProvider outputProvider) throws IOException {
//是否是目录
if (directoryInput.getFile().isDirectory()) {
File dest = outputProvider.getContentLocation(directoryInput.getName(),
directoryInput.getContentTypes(), directoryInput.getScopes(),
Format.DIRECTORY);
transformDir(directoryInput.getFile(), dest);
}
}
private static void transformDir(File input, File dest) throws IOException {
if (dest.exists()) {
FileUtils.forceDelete(dest);
}
FileUtils.forceMkdir(dest);
String srcDirPath = input.getAbsolutePath();
String destDirPath = dest.getAbsolutePath();
for (File file : Objects.requireNonNull(input.listFiles())) {
String destFilePath = file.getAbsolutePath().replace(srcDirPath, destDirPath);
File destFile = new File(destFilePath);
if (file.isDirectory()) {
transformDir(file, destFile);
} else if (file.isFile()) {
if (canHandle(file.getName())) {
FileUtils.touch(destFile);
weave(file.getAbsolutePath(), destFile.getAbsolutePath());
}
}
}
}
private static void weave(String inputPath, String outputPath) {
try {
FileInputStream is = new FileInputStream(inputPath);
ClassReader cr = new ClassReader(is);
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS);
ClassAdapterVisitor adapter = new ClassAdapterVisitor(cw);
cr.accept(adapter, ClassReader.EXPAND_FRAMES);
FileOutputStream fos = new FileOutputStream(outputPath);
fos.write(cw.toByteArray());
fos.close();
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* 检查class文件是否需要处理
*/
static boolean canHandle(String name) {
return (name.endsWith(".class")
&& !name.startsWith("R\$")
&& !"R.class".equals(name)
&& !"BuildConfig.class".equals(name));
}
}
可以看到weave方法中的实现和上面使用ASM读取、操作、写入字节码的功能是相同的。将上面使用ASM操作字节码的类,拷贝到此处,功能就完成了。如下:
自定义Plugin插件的功能完成后,需要在app project的build.gradle中引入自定义Plugin:
import com.example.asm.AsmPlugin
apply plugin: AsmPlugin
之后执行app project的build task。执行完成后,在(文件路径:app/build/intermediates/transform /{包名}/debug / {文件}/{包名})路径下,可以看到插桩成功的class文件。
其他:
目前也有一些开源的编译时插桩的库,例如饿了么开源的lancet,原理也是 Gradle Plugin+Transform+ASM。
如果想深入学习字节码插桩,推荐booster、Hunter和ByteX,里面有好多字节码操作可以学习,例如大图监控,网络监控等等。
总结:
本文先介绍了AOP,然后引出了字节码插桩,对比了常用的框架。之后介绍了使用ASM进行字节码插桩的实现方式,然后结合Transform实现了Android编译过程中对字节码的插桩。
参考链接:
time.geekbang.org/column/arti…