之前几篇文章我们详细介绍了AOP的几种技术方案,由于AOP技术复杂多样,实际需求也不尽相同,那么我们应该如何做技术选型呢?
本篇将会对现有的AOP技术做一个统一的介绍,尤其侧重在Android方向的落地,希望对你有所帮助,文中内容、示例大都来自工作总结,如有偏颇不妥,欢迎指正。
AOP是一种面向切面编程的技术的统称,具体实现在java或者Android领域可以说是百花齐放。因为AOP框架最终都会围绕class字节码的操作展开,无论是对字节码的操作增删改,为方便描述,我们统称为 代码的织入 。
虽然AOP翻译过来叫面向 切面 编程,但在实际使用过程中,切面可能退化成了一个 点 ,比如我们想统计app的冷启动时间,这就非常具体了。如果我们用AOP的技术实现统计所有函数的耗时时间,自然能统计到类似启动这个阶段的时间。
从狭义来看实现AOP技术的框架必须是能将切面编程抽象成上层可以直接使用的工具,但当我们将 切面 降维后,最终面向的就是 切点 而已,换句话说。只要能将代码织入到某个点那这种技术就一定可以实现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。
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是一种严格意义上的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虽然好用,但也存在一些严重的问题。
AspectJ切面表达式支持继承语法,虽然方便了开发,但存在致命的问题,就是在继承树上的类可能都会织入代码,这在多数业务场景下是不适用的,比如无埋点。
另外使用java8语法编写的代码,不会被进入切面范围,也就无法织入代码。
更多详情参见旧文Android AspectJ详解。
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的不足。
更多详情参见旧文Android ASM框架详解。
javassit是一个开源的字节码创建、编辑类库,现属于Jboss web容器的一个子模块,特点是简单、快速,与AspectJ一样,使用它不需要了解字节码和虚拟机指令,这里是 官方文档 。
javassit核心的类库包含ClassPool,CtClass ,CtMethod和CtField。
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的父类。
详见 调试研究Shadow对字节码编辑的正确姿势 。
动态代理是代理模式的一种实现,用于在运行时动态增强原始类的行为,实现方式是运行时直接生成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动态代理 | 运行时扩展代理接口功能。 | ★ | 运行时动态增强。 | 仅支持代理接口,扩展性差,使用反射性能差。 |