理解AOP
之前几篇文章我们详细介绍了AOP的几种技术方案,由于AOP技术复杂多样,实际需求也不尽相同,那么我们应该如何做技术选型呢?
本篇将会对现有的AOP技术做一个统一的介绍,尤其侧重在Android方向的落地,希望对你有所帮助,文中内容、示例大都来自工作总结,如有偏颇不妥,欢迎指正。
这里先统一一下基本名词,以便表述。
- 切面: 对一类行为的抽象,是切点的集合,比如在用户访问所有模块前做的权限认证。
- 切点: 描述切面的具体的一个业务场景。
- 通知(Advice)类型: 通常分为切点前、切点后和切点内,比如在方法前织入代码是指切点前。
AOP是一种面向切面编程的技术的统称,AOP框架最终都会围绕class字节码的操作展开,无论是对字节码的操作增删改,为方便描述,我们统称为代码的织入。
虽然AOP翻译过来叫面向切面编程,但在实际使用过程中,切面可能退化成了一个点,比如我们想统计app的冷启动时间,这就非常具体了。如果我们用AOP的技术实现统计所有函数的耗时时间,自然能统计到类似启动这个阶段的时间。
从狭义来看实现AOP技术的框架必须是能将切面编程抽象成上层可以直接使用的工具或API,但当我们将切面降维后,最终面向的就是切点而已。换句话说,只要能将代码织入到某个点那这种技术就一定可以实现AOP,这样AOP技术所涵盖的领域就得以拓展,因为从狭义的角度看目前只有AspectJ符合这个标准。
从广义上来讲,AOP技术可以是任何能实现代码织入的技术或框架,对代码的改动最终都会体现在字节码上,而这类技术也可以叫做字节码增强,通用名词理解即可。
下面我们将介绍一些常用的AOP技术。
首先,从织入的时机的角度看,可以分为源码阶段、class阶段、dex阶段、运行时织入。
对于前三项源码阶段、class阶段、dex织入,由于他们都发生在class加载到虚拟机前,我们统称为静态织入,
而在运行阶段发生的改动,我们统称为动态织入。
常见的技术框架如下表:
织入时机 | 技术框架 |
---|---|
静态织入 | APT,AspectJ、ASM、Javassit |
动态织入 | java动态代理,cglib、Javassit |
静态织入发生在编译器,因此几乎不会对运行时的效率产生影响;动态织入发生在运行期,可直接将字节码写入内存,并通过反射完成类的加载,所以效率相对较低,但更灵活。
动态织入的前提是类还未被加载,你不能将一个已经加载的类经过修改再次加载,这是ClassLoader的限制。但是可以通过另一个ClassLoader进行加载,虚拟机允许两个相同类名的class被不同的ClassLoader加载,在运行时也会被认为是两个不同的类,因此需要注意不能相互赋值, 不然会抛出ClassCastException。
java动态代理、cglib只会创建新的代理类而不是对原有类的字节码直接修改,Javassit可修改原有字节码。
其实利用反射或者hook技术同样可以实现代码行为的改变,但由于这类技术并没有真正的改变原有的字节码,所以暂不在谈论范围内,比如xposed,dexposed。
其次,我们需要关注这些框架具备哪切面编程的能力,这有助于帮助我做技术选型,由于AspectJ、ASM 、Javassit是相对比较完善的AOP框架,因此只对三者进行比较。
能力 | AspectJ | ASM | Javassit |
---|---|---|---|
切面抽象 | ✓ | ||
切点抽象 | ✓ | ||
通知类型抽象 | ✓ | ✓ | ✓ |
其中:
切面抽象:具备筛选过滤class的能力,比如我们想为Activity的所有生命周期织入代码,那你是不是首先需要具备过滤Activity及其子类的能力。
切点抽象:具体到某个class,是否具备方法、字段、注解访问的能力。
通知类型抽象:是否直接支持在方法前、后、中直接织入代码。
当然不具备能力不代表不能做AOP编程,可以通过其他方法解决,只是易用性的问题。
下面我们将开始对上述框架逐一介绍,Let' go~~~
APT
APT(Annotation Processing Tool)即注解处理器,在Gradle 版本>=2.2后被annotationProcessor取代。
它用来在编译时扫描和处理注解,扫描过程可使用 auto-service 来简化寻找注解的配置,在处理过程中可生成java文件(创建java文件通常依赖 javapoet 这个库)。常用于生成一些模板代码或运行时依赖的类文件,比如常见的ButterKnife、Dagger、ARouter,它的优点是简单方便。
以ButterKnife为例:
public class MainActivity extends AppCompatActivity {
@BindView(R.id.toolbar)
Toolbar toolbar;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
ButterKnife.bind(this);
}
}
一句简单的ButterKnife.bind(this)
是如何实现控件的赋值的?
事实上@Bind注解在编译期会生成一个MainActivity_ViewBinding类,而ButterKnife.bind(this)这次调用最终会通过反射创建出MainActivity_ViewBinding对象,并把activity的引用传递给它。
# ButterKnife
public static Unbinder bind(@NonNull Object target, @NonNull View source) {
Class<?> targetClass = target.getClass();
Constructor<? extends Unbinder> constructor = findBindingConstructorForClass(targetClass);
...
//创建xxx_binding对象并把activity传入
return constructor.newInstance(target, source);
}
private static Constructor<? extends Unbinder> findBindingConstructorForClass(Class<?> cls) {
...
try {
//运行时通过反射加载在编译阶段生成的类
Class<?> bindingClass = cls.getClassLoader().loadClass(clsName + "_ViewBinding");
bindingCtor = (Constructor<? extends Unbinder>) bindingClass.getConstructor(cls, View.class);
}
...
return bindingCtor;
}
这样最终在MainActivity_ViewBinding的构造函数中完成控件的赋值。
public class MainActivity_ViewBinding<T extends MainActivity> implements Unbinder {
protected T target;
public MainActivity_ViewBinding(final T target, Finder finder, Object source) {
...
//为控件赋值 其中优化了控件的查找
target.toolbar = finder.findRequiredViewAsType(source, R.id.toolbar, "field 'toolbar'", Toolbar.class);
...
}
}
为了在此类中能访问到MainActivity中声明的属性,为此ButterKnife框架要求,使用@Bind注解声明的属性不能是private的。
可以看到ButterKnife中仍然用到了反射,这是为了统一API使用ButterKnife.bind(...)作出的牺牲,而Dagger则会通过Component,Module的名字通过动态生成不同的方法名,因此使用之前需要对工程进行build。
之所以会这样,是因为APT技术的不足,通常只是用来创建新的类,而不能对原有类进行改动,在不能改动的情况下,只能通过反射实现动态化。
AspectJ
AspectJ是一种严格意义上的AOP技术,因为它提供了完整的面向切面编程的注解,这样让使用者可以在不关心字节码原理的情况下完成代码的织入,因为编写的切面代码就是要织入的实际代码。
AspectJ实现代码织入有两种方式,一是自行编写.ajc文件,二是使用AspectJ提供的@Aspect、@Pointcut等注解,二者最终都是通过ajc编译器完成代码的织入。
举个简单的例子,假设我们想统计所有view的点击事件,使用AspectJ只需要写一个类即可。
@Aspect
public class MethodAspect {
private static final String TAG = "MethodAspect5";
//切面表达式,声明需要过滤的类和方法
@Pointcut("execution(* android.view.View.OnClickListener+.onClick(..))")
public void callMethod() {
}
//before表示在方法调用前织入
@before("callMethod()")
public void beforeMethodCall(ProceedingJoinPoint joinPoint) {
//编写业务代码
}
}
注解简明直观,上手难度近乎为0。
常用的函数耗时统计工具Hugo,就是AspectJ的一个实际应用,Android平台Hujiang开源的AspectJX插件灵感也来自于Hugo,详情见旧文Android 函数耗时统计工具之Hugo。
AspectJ虽然好用,但也存在一些严重的问题。
- 重复织入、不织入
- 不支持Java8
AspectJ切面表达式支持继承语法,虽然方便了开发,但存在致命的问题,就是在继承树上的类可能都会织入代码,这在多数业务场景下是不适用的,比如无埋点。
另外使用java8语法编写的代码,不会被进入切面范围,也就无法织入代码。
更多详情参见旧文 Android AspectJ详解 。
ASM
ASM是非常底层的面向字节码编程的AOP框架,理论上可以实现任何关于字节码的修改,非常硬核。许多字节码生成API底层都是用ASM实现,常见比如Groovy、cglib,因此在Android平台下使用ASM无需添加额外的依赖。完整的学习ASM必须了解字节码和JVM相关知识。
比如要织入一句简单的日志输出
Log.d("tag", " onCreate");
使用ASM编写是下面这个样子,没错因为JVM是基于栈的,函数的调用需要参数先入栈,然后执行函数入栈,最后出栈,总共四条JVM指令。
mv.visitLdcInsn("tag");
mv.visitLdcInsn("onCreate");
mv.visitMethodInsn(INVOKESTATIC, "android/util/Log", "d", "(Ljava/lang/String;Ljava/lang/String;)I", false);
mv.visitInsn(POP);
可以看出ASM与AspectJ有很大的不同,AspectJ织入的代码就是实际编写的代码,但ASM必须使用其提供的API编写指令。一行java代码可能对应多行ASM API代码,因为一行java代码背后可能隐藏这多个JVM指令。
你不必担心不会编写ASM代码,官方提供了ASM Bytecode Outline插件可以直接将java代码生成ASM代码。
ASM的实际使用场景非常广泛,我们以Matrix为例。
Matrix是微信开源的一个APM框架,其中TraceCanary子模块用于监测帧率低、卡顿、ANR等场景,具备函数耗时统计的功能。
为了实现函数的耗时统计,通常的做法都是在函数执行开始和结束为止进行插桩,最后以两个插桩点的时间差为函数的执行时间。
# -> MethodTracer.TraceMethodAdapter
@Override
protected void onMethodEnter() {
TraceMethod traceMethod = mCollectedMethodMap.get(methodName);
if (traceMethod != null) {
traceMethodCount.incrementAndGet();
mv.visitLdcInsn(traceMethod.id);
//入口插桩
mv.visitMethodInsn(INVOKESTATIC, TraceBuildConstants.MATRIX_TRACE_CLASS, "i", "(I)V", false);
}
}
@Override
protected void onMethodExit(int opcode) {
TraceMethod traceMethod = mCollectedMethodMap.get(methodName);
...
traceMethodCount.incrementAndGet();
mv.visitLdcInsn(traceMethod.id);
//出口插桩
mv.visitMethodInsn(INVOKESTATIC, TraceBuildConstants.MATRIX_TRACE_CLASS, "o", "(I)V", false);
}
总体上就是每个方法的开头和结尾处各添加一行代码,然后交由TraceMethod进行统计和计算。
详情见旧文Matrix系列文章(一) 卡顿分析工具之Trace Canary。
接下来,我们分析一下ASM的不足。
- 切面代码需要硬编码,通常是手动写过滤条件,不够灵活,试想一下如何用ASM实现统计所有Activity的生命周期方法。
- 很难实现在方法调用前后织入新的代码,而在AspectJ中一个call关键字就解决了。
更多详情参见旧文 Android ASM框架详解 。
javassit
javassit是一个开源的字节码创建、编辑类库,现属于Jboss web容器的一个子模块,特点是简单、快速,与AspectJ一样,使用它不需要了解字节码和虚拟机指令,这里是官方文档。
javassit核心的类库包含ClassPool,CtClass ,CtMethod和CtField。
- ClassPool:一个基于HashMap实现的CtClass对象容器。
- CtClass:表示一个类,可从ClassPool中通过完整类名获取。
- CtMethods:表示类中的方法。
- CtFields :表示类中的字段。
javassit API简洁直观,比如我们想动态创建一个类,并添加一个helloWorld方法。
ClassPool pool = ClassPool.getDefault();
//通过makeClass创建类
CtClass ct = pool.makeClass("test.helloworld.Test");//创建类
//为ct添加一个方法
CtMethod helloMethod = CtNewMethod.make("public void helloWorld(String des){ System.out.println(des);}",ct);
ct.addMethod(helloMethod);
//写入文件
ct.writeFile();
//加载进内存
// ct.toClass();
然后,我们想在helloWorld方法前后织入代码。
ClassPool pool = ClassPool.getDefault();
//获取class
CtClass ct = pool.getCtClass("test.helloworld.Test");
//获取helloWorld方法
CtMethod m = ct.getDeclaredMethod("helloWorld");
//在方法开头织入
m.insertBefore("{ System.out.print(\"before insert\");");
//在方法末尾织入 可使用this关键字
m.insertAfter("{System.out.println(this.x); }");
//写入文件
ct.writeFile();
javassit的语法直观简洁的特点,使得在很多开源项目中都有它的身影。
比如QQ zone的热修复方案,当时遇到的问题是补丁包加载做odex优化时,由于差分的patch包并不依赖其他dex,导致补丁包中的类被打上is_preverfied标签(这有助于运行时提升性能),但在补丁运行时实际会去引用其他dex中的类,就会抛出错误java.lang.IllegalAccessError:Class ref pre-verified class resovled to unexpected implement。
当时qq空间团队的解决方案是在编译阶段为对所有类的构造方法进行插桩,引用一个事先定义好的AnalyseLoad类,然后干预分包过程,让这个类处于一个独立的dex中,这样就避免了上述问题。
这里用的AOP方案就是javassit,详情见 QQ空间补丁方案解析 。
还有最近开源的插件化框架shadow,shadow框架中的一个需求是,插件包具备独立运行的能力,当运行插件工程时,插件中Activity的父类ShadowActivity继承Activity,当插件作为子模块加载到插件中时ShadowActivity不必继承系统Activity,只是作为一个代理类就够了。此时shadow团队封装了JavassistTransform,在编译期动态修改Activity的父类。
动态代理
动态代理是代理模式的一种实现,用于在运行时动态增强原始类的行为,实现方式是运行时直接生成class字节码并将其加载进虚拟机。
JDK本身就提供一个Proxy类用于实现动态代理。
我们通常使用下面的API创建代理类。
# java.lang.reflect.Proxy
public static Object newProxyInstance(ClassLoader loader,
Class<?>[] interfaces,
InvocationHandler h)
其中在InvocationHandler实现类中定义核心切点代码。
public class InvocationHandlerImpl implements InvocationHandler {
/** 被代理的实例 */
private Object mObj = null;
public InvocationHandlerImpl(Object obj){
this.mObj = obj;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
//前切入点
Object result = method.invoke(this.mObj, args);
//后切入点
return result;
}
}
这样在前后切入点的位置可以编写要织入的代码。
在我们常用的Retrofit框架中就用到了动态代理。Retrofit提供了一套易于开发网络请求的注解,而在注解中声明的参数正是通过代理包装之后发出的网络请求。
# Retrofit.create
public <T> T create(final Class<T> service) {
...
return (T) Proxy.newProxyInstance(service.getClassLoader(), new Class<?>[] { service },
new InvocationHandler() {
private final Platform platform = Platform.get();
private final Object[] emptyArgs = new Object[0];
@Override public Object invoke(Object proxy, Method method, @Nullable Object[] args)
throws Throwable {
// If the method is a method from Object then defer to normal invocation.
if (method.getDeclaringClass() == Object.class) {
return method.invoke(this, args);
}
if (platform.isDefaultMethod(method)) {
return platform.invokeDefaultMethod(method, service, proxy, args);
}
//代理
return loadServiceMethod(method).invoke(args != null ? args : emptyArgs);
}
});
}
java动态代理最大的问题是只能代理接口,而不能代理普通类或者抽象类,这是因为默认创建的代理类继承Porxy,而java又不支持多继承,这一点极大的限制了动态代理的使用场景,cglib可代理普通类。
更多详情参见 设计模式之代理模式 。
总结
最后我们总结一下 上述AOP框架的特点及优劣势,你可以根据自身需求进行技术选型。
技术框架 | 特点 | 开发难度 | 优势 | 不足 |
---|---|---|---|---|
APT | 常用于通过注解减少模板代码,对类的创建于增强需要依赖其他框架。 | ★★ | 开发注解简化上层编码。 | 使用注解对原工程具有侵入性。 |
AspectJ | 提供完整的面向切面编程的注解。 | ★★ | 真正意义的AOP,支持通配、继承结构的AOP,无需硬编码切面。 | 重复织入、不织入问题,不支持java8 |
ASM | 面向字节码指令编程,功能强大。 | ★★★ | 高效,ASM5开始支持java8。 | 切面能力不足,部分场景需硬编码。 |
Javassit | API简洁易懂,快速开发。 | ★ | 上手快,新人友好,具备运行时加载class能力。 | 切点代码编写需注意class path加载问题。 |
java动态代理 | 运行时扩展代理接口功能。 | ★ | 运行时动态增强。 | 仅支持代理接口,扩展性差,使用反射性能差。 |