谈谈Android AOP技术方案

谈谈Android AOP技术方案

Android小彩虹2021-07-18 4:27:53120A+A-

理解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动态代理 运行时扩展代理接口功能。 运行时动态增强。 仅支持代理接口,扩展性差,使用反射性能差。

点击这里复制本文地址 以上内容由权冠洲的博客整理呈现,请务必在转载分享时注明本文地址!如对内容有疑问,请联系我们,谢谢!

支持Ctrl+Enter提交

联系我们| 本站介绍| 留言建议 | 交换友链 | 域名展示
本站资源来自互联网收集,仅供用于学习和交流,请遵循相关法律法规,本站一切资源不代表本站立场,如有侵权、后门、不妥请联系本站删除

权冠洲的博客 © All Rights Reserved.  Copyright quanguanzhou.top All Rights Reserved
苏公网安备 32030302000848号   苏ICP备20033101号-1

联系我们