阅读 860

Android AspectJ详解

AOP是一个老生常谈的话题,全称"Aspect Oriented Programming",表示面向切面编程。由于面向对象的编程思想推崇高内聚、低耦合的架构风格,使得模块间代码的可见性变差,这使得实现下面的需求变得十分复杂:统计埋点、日志输出、权限拦截等等,如果手动编码,代码侵入性太高且不利于扩展,AOP技术应运而生。

AOP

AOP中的切面比较形象,各个业务模块就像平铺在一个容器中,假如现在需要给各个模块添加点击事件埋点,AOP就像给所有业务模块间插入一个虚拟的切面,后续所有的点击事件通过这个切面时,我们有机会做一些额外的事情。

之所以说是虚拟,是因为整个过程对具体的业务场景是非侵入性的,业务代码不用改动,新增的代码逻辑也不需要做额外的适配。这个过程有点像OkHttp的拦截器,或者可以说拦截器是面向切面的一个具体实现。

本文是对AspectJ的使用介绍,通过这个工具,我们可以轻松的实现一些简单的AOP需求,而不需要懂像编译原理,字节码结构等相对复杂的底层技术。

在Android平台,常用的是hujiang的一个aspectjx插件,它的工作原理是:通过Gradle Transform,在class文件生成后至dex文件生成前,遍历并匹配所有符合AspectJ文件中声明的切点,然后将事先声明好的代码在切点前后织入。

通过描述可知,整个过程发生在编译期,是一种静态织入方式,所以会增加一定的编译时长,但几乎不会影响程序的运行时效率。


本文大致分为三个部分。

  1. AspectJ的语法和使用。
  2. 通过Jake Wharton大神的开源项目Hugo,实战AspectJ。
  3. AspectJ面临的问题。

AspectJ能做什么?

通常来说,AOP都是为一些相对基础且固定的需求服务,实际常见的场景大致包括:

  • 统计埋点
  • 日志打印/打点
  • 数据校验
  • 行为拦截
  • 性能监控
  • 动态权限控制

如果你在项目中也有这样的需求(几乎一定有),可以考虑通过AspectJ来实现。

除了织入代码,AspectJ还能为类增加实现接口、添加成员变量,当然这不是本文的重点,感兴趣的小伙伴可以在学习完基础知识后了解相关内容。

环境配置

在Android平台,我们通常使用上文提到的Aspectjx插件来配置AspectJ环境,具体使用是通过AspectJ注解完成。

  1. 在项目根目录的build.gradle里依赖AspectJX
dependencies {
    classpath 'com.hujiang.aspectjx:gradle-android-plugin-aspectjx:2.0.4'
}
复制代码
  1. 在需要支持AspectJ的module的build.gradle文件中声明插件。
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注解编写切面代码。

  • @Aspect 用它声明一个类,表示一个需要执行的切面。
  • @Pointcut 声明一个切点。
  • @Before/@After/@Around/...(统称为Advice类型) 声明在切点前、后、中执行切面代码。

这么说你可能有点蒙,我们换个角度解释。

假设你是一个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反编译看一下织入结果。

代码织入结果.jpg

红色框选部分就是AspectJ为我们织入的代码。

通过上面的例子我们了解了AspectJ的基本用法,但实际上AspectJ的语法可以十分复杂,下面我们来看看具体的语法。

Join Point

上面的例子中少讲了一个连接点的概念,连接点表示可织入代码的点,它属于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被织入到指定的方法内部。

Pointcut

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 vs. target

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() 时。

if条件

通常情况下,Pointcuts注解的方法参数列表为空,返回值为void,方法体也为空。但是如果表达式中声明了:

  • args、target、this等类型参数,则可额外声明参数列表。
  • if条件,则方法必须public static boolean

来看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条件.png

了解了原理后,实际上if逻辑也完全可以放到织入点代码中,理解起来会更容易一些。

Advice

直译过来是通知,实际上表示一类代码织入位置,在AspectJ中有五种类型的注解:Before、After、AfterReturning、AfterThrowing、Around,我们将它们统称为Advice注解。

Advice 说明
@Before 切入点前织入
@After 切入点后织入,无论连接点执行如何,包括正常的 return 和 throw 异常
@AfterReturning 只有在切入点正常返回之后才会执行,不指定返回类型时匹配所有类型
@AfterThrowing 只有在切入点抛出异常后才执行,不指定异常类型时匹配所有类型
@Around 替代原有切点,如果要执行原来代码的话,调用 ProceedingJoinPoint.proceed()

Advice注解修饰的方法有一些约束:

  1. 方法必须为public。
  2. Before、After、AfterReturning、AfterThrowing 四种类型方法返回值必须为void。
  3. Around的目标是替代原切入点,它一般会有返回值,这就要求声明的返回值类型必须与切入点方法的返回值保持一致;不能和其他 Advice 一起使用,如果在对一个 Pointcut 声明 Around 之后还声明 Before 或者 After 则会失效
  4. 方法签名可以额外声明JoinPoint、JoinPointStaticPart、JoinPoint.EnclosingStaticPart。

JoinPoint、JoinPointStaticPart、JoinPoint.EnclosingStaticPart又是什么呢?

在执行切面代码时,AspectJ会将连接点处的上下文信息封装成JoinPoint供我们使用。这些信息中有些是在编译阶段就可以确定的,比如方法签名 joinPoint.getSignature(),JoinPoint类型 joinPoint.getKind(),切点代码位置类名+行数joinPoint.getSourceLocation() 等等,我们将他们统称为JoinPointStaticPart。

而还有一些是在运行时才能确定的,比如前文提到的this、target、实参等等。

  • JoinPoint 包含连接点处的静态信息+动态信息。
  • JoinPointStaticPart 连接点处的静态信息。
  • EnclosingStaticPart 包含了连接点的静态信息,也就是连接点的上下文。

如果不需要动态信息,建议使用静态类型的参数,以提高性能。

讲了这么多理论,看起来比较复杂,实际上我们日常开发中的场景要相对简单一些。

常用示例

  1. 为所有点击事件埋点
@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及其子类。

  1. 偷天换日,MethodAspect3使用Around类型的Advice,将调用run方法前将实参除以10后执行。
@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方法调用连接点代码。

AspectJ现存的问题

重复织入、不织入

假如我们想对Activity生命周期织入埋点统计,我们可能写出这样的切点代码。

@Pointcut("execution(* android.app.Activity+.on*(..))")
public void callMethod() {}
复制代码

由于Activity.class不参与打包(android.jar位于android设备内),参与打包是那些支持库比如support-v7中的AppCompatActivity,还有项目里定义的Activity,这就导致:

  1. 如果我们业务Activity中如果没有复写生命周期方法将不会织入。
  2. 如果我们的Activity继承树上如果都复写了生命周期方法,那么继承树上的所有Activity都会织入统计代码,这会导致重复统计。

解决办法是项目内定义一个基类Activity(比如BaseActivity),然后复写所有生命周期方法,然后将切点代码精确到这个BaseActivity。

@Pointcut("execution(* com.xxx.BaseActivity.on*(..))")
public void callMethod() {}
复制代码

但如果真这样做的话,你肯定会反问还需要AspectJ做什么,摊手.jpg。

不支持Lambda表达式

Lambda表达式是Java8的语法糖,在编译期会执行脱糖(desugar),脱糖后将Lambda表达式换成内部类实现。笔者尚不清楚AspectJ失效的原因,可能是脱糖发生在Ajx Transform之后,导致找不到连接点方法。

出问题难排查

这是AOP技术的实现方式决定的,修改字节码过程,对上层应用无感知,容易将问题隐藏,排查难度大。因此如果项目中使用了AOP技术应当完善文档,并知会协同开发人员。

编译时间变长

Transform过程,会遍历所有class文件,查找符合需求的切入点,然后插入字节码。如果项目较大且织入代码较多,会增加十几秒左右的编译时间。

如前文提到的,有两种办法解决这个问题:

  1. 使用exclude过滤掉不需要执行织入的包名。
  2. 如果织入代码在debug环境不需要织入,比如埋点,则使用enabled false 关闭AspectJ功能。

兼容性

如果使用的三方库也使用了AspectJ,可能导致未知的风险。

比如sample项目中同时使用Hugo,会导致工程中的class不会被打入APK中,运行时会出现ClassNotFoundException。这可能是Hugo项目编写的Plugin插件与Hujiang的AspectJX插件有冲突导致的。

一写就收不住了,由于篇幅限制,关于AspectJ的原理和Hugo项目的介绍,将独立成篇,实战Android AspectJ之Hugo

参考文章

  1. AspectJ in Android 系列
  2. AOP 之 AspectJ 全面剖析 in Android
  3. Android 使用 Aspectj 限制快速点击
  4. AspectJ In Android Studio
  5. AOP技术学习之AspectJ
  6. 关于AspectJ,你需要知道的一切
关注下面的标签,发现更多相似文章
评论