AOP是一个老生常谈的话题,全称"Aspect Oriented Programming",表示面向切面编程。因为面向对象的编程思想推崇高内聚、低耦合的架构风格,使得模块间代码的可见性变差,这使得实现下面的需求变得十分复杂:统计埋点、日志输出、权限拦截等等,若是手动编码,代码侵入性过高且不利于扩展,AOP技术应运而生。html
AOP中的切面比较形象,各个业务模块就像平铺在一个容器中,假如如今须要给各个模块添加点击事件埋点,AOP就像给全部业务模块间插入一个虚拟的切面,后续全部的点击事件经过这个切面时,咱们有机会作一些额外的事情。java
之因此说是虚拟,是由于整个过程对具体的业务场景是非侵入性的,业务代码不用改动,新增的代码逻辑也不须要作额外的适配。这个过程有点像OkHttp的拦截器,或者能够说拦截器是面向切面的一个具体实现。android
本文是对AspectJ的使用介绍,经过这个工具,咱们能够轻松的实现一些简单的AOP需求,而不须要懂像编译原理,字节码结构等相对复杂的底层技术。git
在Android平台,经常使用的是hujiang的一个aspectjx插件,它的工做原理是:经过Gradle Transform,在class文件生成后至dex文件生成前,遍历并匹配全部符合AspectJ文件中声明的切点,而后将事先声明好的代码在切点先后织入。github
经过描述可知,整个过程发生在编译期,是一种静态织入方式,因此会增长必定的编译时长,但几乎不会影响程序的运行时效率。express
本文大体分为三个部分。编程
一般来讲,AOP都是为一些相对基础且固定的需求服务,实际常见的场景大体包括:bash
若是你在项目中也有这样的需求(几乎必定有),能够考虑经过AspectJ来实现。架构
除了织入代码,AspectJ还能为类增长实现接口、添加成员变量,固然这不是本文的重点,感兴趣的小伙伴能够在学习完基础知识后了解相关内容。app
在Android平台,咱们一般使用上文提到的Aspectjx插件来配置AspectJ环境,具体使用是经过AspectJ注解完成。
dependencies {
classpath 'com.hujiang.aspectjx:gradle-android-plugin-aspectjx:2.0.4'
}
复制代码
apply plugin: 'android-aspectjx'
复制代码
在编译阶段AspectJ会遍历工程中全部class文件(包括第三方类库的class)寻找符合条件的切入点,为加快这个过程或缩小代码织入范围,咱们可使用exclude排除掉指定包名的class。
# app/build.gradle
aspectjx {
//排除全部package路径中包含`android.support`的class文件及库(jar文件)
exclude 'android.support'
}
复制代码
在debug阶段咱们更注重编译速度,能够关闭代码织入。
# app/build.gradle
aspectjx {
//关闭AspectJX功能
enabled false
}
复制代码
但目前最新的2.0.4版本的插件有bug,若是关闭AspectJ,则会致使工程内全部class不能打入APK中,运行会出现各类ClassNotFoundException,已经有Issue提出但还没有解决(坑货)。笔者尝试将版本回退到2.0.0版本,发现无此问题。若是你目前也有动态关闭的需求,建议不要使用最新版本。
环境配置完成后,咱们须要用AspectJ注解编写切面代码。
这么说你可能有点蒙,咱们换个角度解释。
假设你是一个AOP框架的设计者,最早须要理清的其基本组成要素。既然须要作代码织入那是否是必定得配置代码的织入点呢?这个织入点就是Pointcut,有了织入点咱们还须要指定具体织入的代码,这个代码写在哪里呢?就是写在以@Before/@After/@Around注解的方法体内。有了织入点和织入代码,还须要告诉框架本身是一个面向切面的配置文件,这就须要使用@Aspect声明在类上。
咱们举个简单的栗子,所有示例参考github sample_aspectj。
@Aspect //①
public class MethodAspect {
@Pointcut("call(* com.wandering.sample.aspectj.Animal.fly(..))")//②
public void callMethod() {
}
@Before("callMethod()")//③
public void beforeMethodCall(JoinPoint joinPoint) {
Log.e(TAG, "before->" + joinPoint.getTarget().toString()); //④
}
}
复制代码
咱们事先准备好的Animal类中有一个fly方法。
public class Animal {
public void fly() {
Log.e(TAG, "animal fly method:" + this.toString() + "#fly");
}
}
复制代码
①处声明了本类是一个AspectJ配置文件。
②处指定了一个代码织入点,注解内的call(* com.wandering.sample.aspectj.Animal.fly(..)) 是一个切点表达式,第一个*号表示返回值可为任意类型,后跟包名+类名+方法名,括号内表示参数列表, .. 表示匹配任意个参数,参数类型为任何类型,这个表达式指定了一个时机:在Animal类的fly方法被调用时。
③处声明Advice类型为Before并指定切点为上面callMethod方法所表示的那个切点。
④处为实际织入的代码。
翻译成白话就是说在Animal类的fly方法被调用前插入④处的代码。
编写测试代码并调用fly方法,运行观察日志输出你会发现before->的日志先于animal fly日志被打印,具体可查看sample工程MethodAspect示例。
咱们再将APK反编译看一下织入结果。
红色框选部分就是AspectJ为咱们织入的代码。
经过上面的例子咱们了解了AspectJ的基本用法,但实际上AspectJ的语法能够十分复杂,下面咱们来看看具体的语法。
上面的例子中少讲了一个链接点的概念,链接点表示可织入代码的点,它属于Pointcut的一部分。因为语法内容较多,实际使用过程当中咱们能够参考语法手册,咱们列出其中一部分Join Point:
Joint Point | 含义 |
---|---|
Method call | 方法被调用 |
Method execution | 方法执行 |
Constructor call | 构造函数被调用 |
Constructor execution | 构造函数执行 |
Static initialization | static 块初始化 |
Field get | 读取属性 |
Field set | 写入属性 |
Handler | 异常处理 |
Method call 和 Method execution的区别常拿来比较,其实就是调用与执行的区别,就拿上面Animal的fly方法举例。demo代码以下:
Animal a = Animal();
a.fly();
复制代码
若是咱们声明的织入点为call,再假设Advice类型是before,则织入后代码结构是这样的。
Animal a = new Animal();
//...我是织入代码
a.fly();
复制代码
若是咱们声明的织入点为execution,则织入后代码结构就成这样了。
public class Animal {
public void fly() {
//...我是织入代码
Log.e(TAG, "animal fly method:" + this.toString() + "#fly");
}
}
复制代码
本质上的区别就是织入对象不一样,call被织入在指定方法被调用的位置上,而execution被织入到指定的方法内部。
Pointcuts是具体的切入点,基本上Pointcuts 是和 Join Point 相对应的。
Joint Point | Pointcuts 表达式 |
---|---|
Method call | call(MethodPattern) |
Method execution | execution(MethodPattern) |
Constructor call | call(ConstructorPattern) |
Constructor execution | execution(ConstructorPattern) |
Static initialization | staticinitialization(TypePattern) |
Field get | get(FieldPattern) |
Field set | set(FieldPattern) |
Handler | handler(TypePattern) |
除了上面与 Join Point 对应的选择外,Pointcuts 还有其余选择方法。
Pointcuts 表达式 | 说明 |
---|---|
within(TypePattern) | 符合 TypePattern 的代码中的 Join Point |
withincode(MethodPattern) | 在某些方法中的 Join Point |
withincode(ConstructorPattern) | 在某些构造函数中的 Join Point |
cflow(Pointcut) | Pointcut 选择出的切入点 P 的控制流中的全部 Join Point,包括 P 自己 |
cflowbelow(Pointcut) | Pointcut 选择出的切入点 P 的控制流中的全部 Join Point,不包括 P 自己 |
this(Type or Id) | Join Point 所属的 this 对象是否 instanceOf Type 或者 Id 的类型 |
target(Type or Id) | Join Point 所在的对象(例如 call 或 execution 操做符应用的对象)是否 instanceOf Type 或者 Id 的类型 |
args(Type or Id, ...) | 方法或构造函数参数的类型 |
if(BooleanExpression) | 知足表达式的 Join Point,表达式只能使用静态属性、Pointcuts 或 Advice 暴露的参数、thisJoinPoint 对象 |
this和target是一个容易混淆的点。
# MethodAspect.java
public class MethodAspect {
@Pointcut("call(* com.wandering.sample.aspectj.Animal.fly(..))")
public void callMethod() {
Log.e(TAG, "callMethod->");
}
@Before("callMethod()")
public void beforeMethodCall(JoinPoint joinPoint) {
Log.e(TAG, "getTarget->" + joinPoint.getTarget());
Log.e(TAG, "getThis->" + joinPoint.getThis());
}
}
复制代码
fly调用方:
# MainActivity.java
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Animal animal = new Animal();
animal.fly();
}
复制代码
运行结果以下:
getTarget->com.wandering.sample.aspectj.Animal@509ddfd
getThis->com.wandering.sample.aspectj.MainActivity@98c38bf
复制代码
也就是说target指代的是切入点方法的全部者,而this指代的是被织入代码所属类的实例对象。
咱们稍加改动,将切点的call改成execution。
运行结果就成这个样子了:
getTarget->com.wandering.sample.aspectj.Animal@509ddfd
getThis->com.wandering.sample.aspectj.Animal@509ddfd
复制代码
按照上面的分析,与这个结果也是吻合的。
Pointcut表达式中还可使用一些条件判断符,好比 !、&&、||。
以Hugo为例:
# Hugo.java
@Pointcut("within(@hugo.weaving.DebugLog *)")
public void withinAnnotatedClass() {}
@Pointcut("execution(!synthetic * *(..)) && withinAnnotatedClass()")
public void methodInsideAnnotatedType() {}
复制代码
第一个切点指定范围为包含DebugLog注解的任意类和方法,第二个切点为在第一个切点范围内,且执行非内部类的任意方法。结合起来表述就是任意声明了DebugLog注解的方法。
其中@hugo.weaving.DebugLog *
和!synthetic * *(..)
分别对应上面表格中提到的TypePattern和MethodPattern。
接下来须要了解这些pattern具体的语法,经过语法咱们能够写出符合自身需求的表达式。
Pattern类型 | 语法 |
---|---|
MethodPattern | [!] [@Annotation] [public,protected,private] [static] [final] 返回值类型 [类名.]方法名(参数类型列表) [throws 异常类型] |
ConstructorPattern | [!] [@Annotation] [public,protected,private] [final] [类名.]new(参数类型列表) [throws 异常类型] |
FieldPattern | [!] [@Annotation] [public,protected,private] [static] [final] 属性类型 [类名.]属性名 |
TypePattern | 其余 Pattern 涉及到的类型规则也是同样,可使用 '!'、''、'..'、'+','!' 表示取反,'' 匹配除 . 外的全部字符串,'*' 单独使用事表示匹配任意类型,'..' 匹配任意字符串,'..' 单独使用时表示匹配任意长度任意类型,'+' 匹配其自身及子类,还有一个 '...'表示不定个数 |
更多语法参见官网Pointcuts,很是有用。
再看几个例子:
execution(void setUserVisibleHint(..)) && target(android.support.v4.app.Fragment) && args(boolean) --- 执行 Fragment 及其子类的 setUserVisibleHint(boolean) 方法时。
execution(void Foo.foo(..)) && cflowbelow(execution(void Foo.foo(..))) --- 执行 Foo.foo() 方法中再递归执行 Foo.foo() 时。
一般状况下,Pointcuts注解的方法参数列表为空,返回值为void,方法体也为空。可是若是表达式中声明了:
来看sample示例MethodAspect8:
@Aspect
public class MethodAspect8 {
@Pointcut("call(boolean *.*(int)) && args(i) && if()")
public static boolean someCallWithIfTest(int i, JoinPoint jp) {
// any legal Java expression...
return i > 0 && jp.getSignature().getName().startsWith("setAge");
}
@Before("someCallWithIfTest(i, jp)")
public void aroundMethodCall(int i, JoinPoint jp) {
Log.e(TAG, "before if ");
}
}
复制代码
切点方法someCallWithIfTest声明的注解表示任意方法,此方法返回值为boolean,参数签名为仅一个int类型的参数,后面跟上if条件,表示此int参数值大于0,且方法签名以setAge开头。
如此一来切面代码的执行就具有了动态性,但不是说不知足if条件的切点就不会织入代码。依然会织入,只是在调用织入代码前会执行someCallWithIfTest方法,当返回值为true时才会执行织入代码,下图是反编译class的结果。
了解了原理后,实际上if逻辑也彻底能够放到织入点代码中,理解起来会更容易一些。
直译过来是通知,实际上表示一类代码织入位置,在AspectJ中有五种类型的注解:Before、After、AfterReturning、AfterThrowing、Around,咱们将它们统称为Advice注解。
Advice | 说明 |
---|---|
@Before | 切入点前织入 |
@After | 切入点后织入,不管链接点执行如何,包括正常的 return 和 throw 异常 |
@AfterReturning | 只有在切入点正常返回以后才会执行,不指定返回类型时匹配全部类型 |
@AfterThrowing | 只有在切入点抛出异常后才执行,不指定异常类型时匹配全部类型 |
@Around | 替代原有切点,若是要执行原来代码的话,调用 ProceedingJoinPoint.proceed() |
Advice注解修饰的方法有一些约束:
JoinPoint、JoinPointStaticPart、JoinPoint.EnclosingStaticPart又是什么呢?
在执行切面代码时,AspectJ会将链接点处的上下文信息封装成JoinPoint供咱们使用。这些信息中有些是在编译阶段就能够肯定的,好比方法签名 joinPoint.getSignature(),JoinPoint类型 joinPoint.getKind(),切点代码位置类名+行数joinPoint.getSourceLocation() 等等,咱们将他们统称为JoinPointStaticPart。
而还有一些是在运行时才能肯定的,好比前文提到的this、target、实参等等。
若是不须要动态信息,建议使用静态类型的参数,以提升性能。
讲了这么多理论,看起来比较复杂,实际上咱们平常开发中的场景要相对简单一些。
@Aspect
public class MethodAspect5 {
@Pointcut("execution(* android.view.View.OnClickListener+.onClick(..))")
public void callMethod() {
}
@Before("callMethod()")
public void beforeMethodCall(JoinPoint joinPoint) {
Log.e(TAG, "埋点");
}
}
复制代码
android.view.View.OnClickListener+
表示OnClickListener及其子类。
@Aspect
public class MethodAspect3 {
@Pointcut("execution(* com.wandering.sample.aspectj.Animal.run(..))")
public void callMethod() {
}
@Around("callMethod()")
public void aroundMethodCall(ProceedingJoinPoint joinPoint) {
//获取链接点参数列表
Object[] args = joinPoint.getArgs();
int params = 0;
for (Object arg : args) {
params = (int) arg / 10;
}
try {
//改变参数 执行链接点代码
joinPoint.proceed(new Object[]{params});值
} catch (Throwable throwable) {
throwable.printStackTrace();
}
}
}
复制代码
Around方法声明ProceedingJoinPoint类型而不是JoinPoint,可使用其proceed方法调用链接点代码。
假如咱们想对Activity生命周期织入埋点统计,咱们可能写出这样的切点代码。
@Pointcut("execution(* android.app.Activity+.on*(..))")
public void callMethod() {}
复制代码
因为Activity.class不参与打包(android.jar位于android设备内),参与打包是那些支持库好比support-v7中的AppCompatActivity,还有项目里定义的Activity,这就致使:
解决办法是项目内定义一个基类Activity(好比BaseActivity),而后复写全部生命周期方法,而后将切点代码精确到这个BaseActivity。
@Pointcut("execution(* com.xxx.BaseActivity.on*(..))")
public void callMethod() {}
复制代码
但若是真这样作的话,你确定会反问还须要AspectJ作什么,摊手.jpg。
Lambda表达式是Java8的语法糖,在编译期会执行脱糖(desugar),脱糖后将Lambda表达式换成内部类实现。笔者尚不清楚AspectJ失效的缘由,多是脱糖发生在Ajx Transform以后,致使找不到链接点方法。
这是AOP技术的实现方式决定的,修改字节码过程,对上层应用无感知,容易将问题隐藏,排查难度大。所以若是项目中使用了AOP技术应当完善文档,并知会协同开发人员。
Transform过程,会遍历全部class文件,查找符合需求的切入点,而后插入字节码。若是项目较大且织入代码较多,会增长十几秒左右的编译时间。
如前文提到的,有两种办法解决这个问题:
若是使用的三方库也使用了AspectJ,可能致使未知的风险。
好比sample项目中同时使用Hugo,会致使工程中的class不会被打入APK中,运行时会出现ClassNotFoundException。这多是Hugo项目编写的Plugin插件与Hujiang的AspectJX插件有冲突致使的。
一写就收不住了,因为篇幅限制,关于AspectJ的原理和Hugo项目的介绍,将独立成篇,实战Android AspectJ之Hugo。