Android AOP学习之:AspectJ实践

7,841 阅读6分钟

AOP

AOP(Aspect Oriented Programming),中文通常翻译成面向切面编程。在Java当中我们常常提及到的是OOP(Object Oriented Programming)面向对象编程。其实这些都只是编程中从不同的思考方向得出的一种编程思想、编程方法。

在面向对象编程中,我们常常提及到的是“everything is object”一切皆对象。我们在编程过程中,将一切抽象成对象模型,思考问题、搭建模型的时候,优先从对象的属性和行为职责出发,而不执拗于具体实现的过程。

可是当我们深挖里面的细节的时候,就会发现一些很矛盾的地方。比如,我们要完成一个事件埋点的功能,我们希望在原来整个系统当中,加入一些事件的埋点,监控并获取用户的操作行为和操作数据。

按照面向对象的思想,我们会设计一个埋点管理器,然后在每个需要埋点的地方都加上一段埋点管理器的方法调用的逻辑。咋看起来,这样子并没有什么问题。但是我们会发现一个埋点的功能已经侵入到了我们系统的内部,埋点的功能方法调用到处都是。如果我们要对埋点的功能进行撤销、迁移或者重构的时候,都会存在不小的代价。

那么AOP的思想能干什么呢?AOP提倡的是针对同一类问题的统一处理。比如我们前面提及到的事件埋点功能,我们的埋点功能散落在系统的每个角落(虽然我们的核心逻辑可以抽象在一个对象当中)。如果我们将AOP与OOP两者相结合,将功能的逻辑抽象成对象(OOP,同一类问题,单一的原则),再在一个统一的地方,完成逻辑的调用(AOP,将问题的处理,也即是逻辑的调用统一)。这样子,我们就可以用更加完美的结构完成系统的功能。

上面其实已经揭示了AOP的实际使用场景:无侵入的在宿主系统中插入一些核心的代码逻辑:日志埋点、性能监控、动态权限控制、代码调试等等。

实现AOP的的核心技术其实就是代码织入技术(code injection),对应的编程手段和工具其实有很多种,比如AspectJ、JavaAssit、ASMDex、Dynamic Proxy等等。关于这些技术的实践的对比,可以参考这篇文章。Practical Introduction into Code Injection with AspectJ, Javassist, and Java Proxy

AspectJ

AspectJ实际上是对AOP编程思想的一个实践。AspectJ提供了一套全新的语法实现,完全兼容Java(其实跟Java之间的区别,只是多了一些关键词而已)。同时,还提供了纯Java语言的实现,通过注解的方式,完成代码编织的功能。因此我们在使用AspectJ的时候有以下两种方式:

  • 使用AspectJ的语言进行开发
  • 通过AspectJ提供的注解在Java语言上开发

因为最终的目的其实都是需要在字节码文件中织入我们自己定义的切面代码,不管使用哪种方式接入AspectJ,都需要使用AspectJ提供的代码编译工具ajc进行编译。

常用术语

在了解AspectJ的具体使用之前,先了解一下其中的一些基本的术语概念,这有利于我们掌握AspectJ的使用以及AOP的编程思想。

在下面的关于AspectJ的使用相关介绍都是以注解的方式使用作为说明的。

JoinPoints

JoinPoints(连接点),程序中可能作为代码注入目标的特定的点。在AspectJ中可以作为JoinPoints的地方包括:

JoinPoints 说明 示例
method call 函数调用 比如调用Log.e(),这是一处Joint point
method execution 函数执行 比如Log.e()的执行内部,是一处Joint Point
constructor call 构造函数调用 与方法的调用类型
constructor executor 构造函数执行 与方法的执行执行
field get 获取某个变量
field set 设置某个变量
static initialization 类初始化
initialization object在构造函数中做的一些工作
handler 异常处理 对应try-catch()中,对应的catch块内的执行

PointCuts

PointCuts(切入点),其实就是代码注入的位置。与前面的JoinPoints不同的地方在于,其实PointCuts是有条件限定的JoinPoints。比如说,在一个Java源文件中,会有很多的JoinPoints,但是我们只希望对其中带有@debug注解的地方才注入代码。所以,PointCuts是通过语法标准给JoinPoints添加了筛选条件限定。

Advice

Advice(通知),其实就是注入到class文件中的代码片。典型的 Advice 类型有 before、after 和 around,分别表示在目标方法执行之前、执行后和完全替代目标方法执行的代码。

Aspect

Aspect(切面),Pointcut 和 Advice 的组合看做切面。

Weaving

注入代码(advices)到目标位置(joint points)的过程

AspectJ使用配置

在android studio的android工程中使用AspectJ的时候,我们需要在项目的build.gradle的文件中添加一些配置:

  • 首先在项目的根目录的build.gradle中添加如下配置:
buildscript {
    ...
    dependencies {        
        classpath 'org.aspectj:aspectjtools:1.8.6'
        ...
    }
}
  • 单独定一个module用于编写aspect的切面代码,在该module的build.gradle目录中添加如下配置(如果我们的切面代码并不是独立为一个module的可以忽略这一步):
apply plugin: 'com.android.library'

android {
    ...
}

android.libraryVariants.all { variant ->
    JavaCompile javaCompile = variant.javaCompile
    javaCompile.doLast {
        //下面的1.8是指我们兼容的jdk的版本
        String[] args = ["-showWeaveInfo",
                         "-1.8",
                         "-inpath", javaCompile.destinationDir.toString(),
                         "-aspectpath", javaCompile.classpath.asPath,
                         "-d", javaCompile.destinationDir.toString(),
                         "-classpath", javaCompile.classpath.asPath,
                         "-bootclasspath", android.bootClasspath.join(File.pathSeparator)]

        MessageHandler handler = new MessageHandler(true);
        new Main().run(args, handler)

        def log = project.logger
        for (IMessage message : handler.getMessages(null, true)) {
            switch (message.getKind()) {
                case IMessage.ABORT:
                case IMessage.ERROR:
                case IMessage.FAIL:
                    log.error message.message, message.thrown
                    break;
                case IMessage.WARNING:
                case IMessage.INFO:
                    log.info message.message, message.thrown
                    break;
                case IMessage.DEBUG:
                    log.debug message.message, message.thrown
                    break;
            }
        }
    }
}

dependencies {
    ...
    compile 'org.aspectj:aspectjrt:1.8.6'
    ...

}
  • 在我们的app module的build.gradle文件中添加如下配置:
import org.aspectj.bridge.IMessage
import org.aspectj.bridge.MessageHandler
import org.aspectj.tools.ajc.Main

apply plugin: 'com.android.application'

android {
    ...
}

final def log = project.logger
final def variants = project.android.applicationVariants
//在构建工程时,执行编织
variants.all { variant ->
    if (!variant.buildType.isDebuggable()) {
        log.debug("Skipping non-debuggable build type '${variant.buildType.name}'.")
        return;
    }

    JavaCompile javaCompile = variant.javaCompile
    javaCompile.doLast {
        String[] args = ["-showWeaveInfo",
                         "-1.8",
                         "-inpath", javaCompile.destinationDir.toString(),
                         "-aspectpath", javaCompile.classpath.asPath,
                         "-d", javaCompile.destinationDir.toString(),
                         "-classpath", javaCompile.classpath.asPath,
                         "-bootclasspath", project.android.bootClasspath.join(File.pathSeparator)]
        log.debug "ajc args: " + Arrays.toString(args)

        MessageHandler handler = new MessageHandler(true);
        new Main().run(args, handler);
        for (IMessage message : handler.getMessages(null, true)) {
            switch (message.getKind()) {
                case IMessage.ABORT:
                case IMessage.ERROR:
                case IMessage.FAIL:
                    log.error message.message, message.thrown
                    break;
                case IMessage.WARNING:
                    log.warn message.message, message.thrown
                    break;
                case IMessage.INFO:
                    log.info message.message, message.thrown
                    break;
                case IMessage.DEBUG:
                    log.debug message.message, message.thrown
                    break;
            }
        }
    }
}


dependencies {
    ...
    compile 'org.aspectj:aspectjrt:1.8.6'
    //自己定义的切面代码的模块
    compile project(":aspectj")
    ...
}

其实,第二步和第三步的配置是一样的,并且在配置当中,我们使用了gradle的log日志打印对象logger。因此我们在编译的时候,可以获得关于代码织入的一些异常信息。我们可以利用这些异常信息帮助检查我们的切面代码片段是否语法正确。要注意的是:logger的日志输出是在android studio的Gradle Console控制台显示的,并不是我们常规的logcat

通过上面的方式,我们就完成了在android studio中的android项目工程接入AspectJ的配置工作。这个配置有点繁琐,因此网上其实已经有人写了相应的gradle插件,具体可以参考:AspectJ Gradle插件

Pointcut使用语法

在前面术语当中提及到,Pointcut其实是加了筛选条件限制的JoinPoints,而每种类型的JoinPoint都会对应有自己的筛选条件的匹配格式,Pointcut的定义就是要根据不同的JoinPoint声明合适的筛选条件表达式。

直接对JoinPoint的选择

JoinPoint类型 Pointcut语法
Method Execution(方法执行) execution(MethodSignature)
Method Call(方法调用) call(MethodSignature)
Constructor Execution(构造器执行) execution(ConstructorSignature)
Construtor Call(构造器调用) call(ConstructorSignature)
Class Initialization(类初始化) staticinitialization(TypeSignature)
Field Read(属性读) get(FieldSignature)
Field Set(属性写) set(FieldSignature)
Exception Handler(异常处理) handler(TypeSignature)
Object Initialization(对象初始化) initialization(ConstructorSignature)
Object Pre-initialization(对象预初始化) preinitialization(ConstructorSignature)
Advice Execution(advice执行) adviceexecution()
  1. 在上面表格中所提及到的MethodSignature、ConstructorSignature、TypeSignature、FieldSignature,它们的表达式都可以使用通配符进行匹配。
  2. 表格当中的execution、call、set、get、initialization、preinitialization、adviceexecution、staticinitialization这些都是属于AspectJ当中的关键词
  3. 表格当中的handler只能与advice中的before(advice的相应关键词及使用参考后文)一起使用
  • 常用通配符
通配符 意义 示例
* 表示除”.”以外的任意字符串 java.*.Date:可以表示java.sql. Date,java.util. Date
.. 表示任意子package或者任意参数参数列表 java..*:表示java任意子包;void getName(..):表示方法参数为任意类型任意个数
+ 表示子类 java..*Model+:表示java任意包中以Model结尾的子类
  • MethodSignature

    定义MethodSignature的条件表达式与定义一个方法类型,其结构如下:

    • 表达式:

      [@注解] [访问权限] 返回值的类型 类全路径名(包名+类名).函数名(参数)

    • 说明:

      1. []当中的内容表示可选项。当没有设定的时候,表示全匹配
      2. 返回值类型、类全路径、函数名、参数都可以使用上面的通配符进行描述。
    • 例子:

      public (..) :表示任意参数任意包下的任意函数名任意返回值的public方法

      @com.example.TestAnnotation com.example..(int) :表示com.example下被TestAnnotation注解了的带一个int类型参数的任意名称任意返回值的方法

  • ConstructorSignature

    Constructorsignature和Method Signature类似,只不过构造函数没有返回值,而且函数名必须叫new.

    • 表达式:

      [@注解] [访问权限] 类全路径名(包名+类名).new(参数)

    • 例子:

      public *..People.new(..) :表示任意包名下面People这个类的public构造器,参数列表任意

  • FieldSignature

    与在类中定一个一个成员变量的格式相类似。

    • 表达式:

      [@注解] [访问权限] 类型 类全路径名.成员变量名

    • 例子:

      String com.example..People.lastName :表示com.example包下面的People这个类中名为lastName的String类型的成员变量

  • TypeSignature

    TypeSignature其实就是用来指定一个类的。因此我们只需要给出一个类的全路径的表达式即可

间接对JoinPoint进行选择

除了上面表格当中提及到的直接对Join Point选择之外,还有一些Pointcut关键字是间接的对Join Point进行选择的。如下表所示:

Pointcut语法 说明 示例
within(TypeSignature) 表示在某个类中所有的Join Point within(com.example.Test):表示在com.example.Test类当中的全部Join Point
withincode(ConstructorSignature/MethodSignature) 表示在某个函数/构造函数当中的Join Point withincode( ..Test(..)):表示在任意包下面的Test函数的所有Join Point
args(TypeSignature) 对Join Point的参数进行条件筛选 args(int,..):表示第一个参数是int,后面参数不限的Join Point

除了上面几个之外,其实还有target、this、cflow、cflowbelow。因为对这几个掌握不是很清楚,这里不详细说明。有兴趣的可以参考这篇文章的说明:深入理解Android之AOP

组合Pointcut进行选择

Pointcut之间可以使用“&& | !”这些逻辑符号进行拼接,将两个Pointcut进行拼接,完成一个最终的对JoinPoint的选择操作。(其实就是将上面的间接选择JoinPoint表中关键字定义的Pointcut与直接选择JoinPoint表关键字定义的Pointcut进行拼接)

Advice语法使用

AspectJ提供的Advice类型如下表所示:

Advice语法 说明
before 在选择的JoinPoint的前面插入切片代码
after 在选择的JoinPoint的后面插入切片代码
around around会替代原来的JoinPoint(我们可以完全修改一个方法的实现),如果需要调用原来的JoinPoint的话,可以调用proceed()方法
AfterThrowing 在选择的JoinPoint异常抛出的时候插入切片的代码
AfterReturning 在选择的JoinPoint返回之前插入切片的代码

AspectJ实践

以下关于AspectJ的实践都是使用AspectJ提供的Java注解的方式来实现。

直接使用Pointcut

定义一个People类,里面包含一个静态代码块

public class People {
    ...
    static {
        int a = 10;
    }
    ...
}

接下来定义一个切片,里面包含一个Advice往静态代码块当中插入一句日志打印

// 这里使用@Aspect注解,表示这个类是一个切片代码类。
// 每一个定义了切片代码的类都应该添加上这个注解

@Aspect
public class TestAspect {

    public static final String TAG = "TestAspect";

    //@After,表示使用After类型的advice,里面的value其实就是一个poincut

    @After(value = "staticinitialization(*..People)")
    public void afterStaticInitial(){
        Log.d(TAG,"the static block is initial");
    }
}

最后,在apk当中的dex文件的People的class文件中会多出下面这样的一段代码:

static {
    TestAspect.aspectOf().afterStaticInitial();
}

自定义Pointcut && 组合Pointcut

我们可以使用AspectJ提供的“@Pointcut”注解完成自定义的Pointcut。下面通过“在MainActivity这个类里面完成异常捕捉的切片代码”这个例子来演示自定义Pointcut和组合Pointcut的使用

public class MainActivity extends Activity {

 protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        test("this is tag for test");
    }

public void test(String test) {
    try {
        throw new IllegalArgumentException("self throw exception");
        } catch (Exception e) {

        }
    }
}
@Aspect
public class TestAspect {

    @Pointcut(value = "handler(Exception)")
    public void handleException(){

    }

    @Pointcut(value = "within(*..MainActivity)")
    public void codeInMain(){

    }

    // 这里通过&&操作符,将两个Pointcut进行了组合
    // 表达的意思其实就是:在MainActivity当中的catch代码块

    @Before(value = "codeInMain() && handleException()")
    public void catchException(JoinPoint joinPoint){
        Log.d(TAG,"this is a try catch block");
    }
}

最后编译后的MainActivity当中test方法变成了:

public void test(String test) {
        try {
            throw new IllegalArgumentException("self throw exception");
        } catch (Object e) {
            TestAspect.aspectOf().catchException(Factory.makeJP(ajc$tjp_0, (Object) this, null, e));
        }
    }

使用总结

经过上面两个简单的小例子基本上能够明白了在android studio的android项目中接入AspectJ的流程,这里简单总结一下:

  1. 环境搭建(主要是配置代码编译使用ajc编译器,并且添加gralde的logger日志输出,方便调试)
  2. 使用@Aspect注解,标示某个类作为我们的切片类
  3. 使用@Pointcut注解定义pointcut。这一步其实是可选的。但是为了提高代码的可读性,可以通过合理拆分粒度,定义切点,并通过逻辑操作符进行组合,达到强大的切点描述
  4. 根据实际需要,通过注解定义advice的代码,这些注解包括:@Before,@After,@AfterThrowing,@AfterReturning,@Around.

参考文献

  1. 深入理解Android之AOP
  2. @AspectJ cheat sheet
  3. Practical Introduction into Code Injection with AspectJ, Javassist, and Java Proxy