谈谈Android AOP技术方案

15,309 阅读13分钟

理解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(this) 作出的牺牲,而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虽然好用,但也存在一些严重的问题。

  • 重复织入、不织入

AspectJ切面表达式支持继承语法,虽然方便了开发,但存在致命的问题,就是在继承树上的类可能都会织入代码,这在多数业务场景下是不适用的,比如无埋点。

另外Java8语法在aspectjx 2.0.0版本开始支持。

更多详情参见旧文 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的父类。

详见 调试研究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,无需硬编码切面。 重复织入、不织入问题
ASM 面向字节码指令编程,功能强大。 ★★★ 高效,ASM5开始支持java8。 切面能力不足,部分场景需硬编码。
Javassit API简洁易懂,快速开发。 上手快,新人友好,具备运行时加载class能力。 切点代码编写需注意class path加载问题。
java动态代理 运行时扩展代理接口功能。 运行时动态增强。 仅支持代理接口,扩展性差,使用反射性能差。