写给Android工程师的AOP知识

7,517 阅读10分钟

如果你接触过Java后台开发,一定听过AOP的概念,它到底是什么东西,对我们Android开发有什么用呢?本博客站在Android工程师的角度探索一下AOP这个熟悉又陌生的概念: 写给Android工程师的aop知识大纲.png

AOP是个啥

AOPAspect Oriented Program的首字母缩写,翻译过来就是面向切面编程。这个概念中面向容易理解,编程可以理解,最关键的是切面是指什么?

在理解切面之前,先回顾一下我们熟悉的OOP(Object-Oriented Programming),面向对象编程。

我们知道,面向对象的特点是继承、多态和封装。而封装就要求将功能分散到不同的类中去,这在软件设计中往往称为职责分配,这样做的好处是降低了代码的复杂程度,使类具备了可重用性。

但是在分散代码的同时,也增加了代码的重复性。什么意思呢?

举个栗子,

假设我们要对两个类的每个方法添加日志。按面向对象的设计方法,我们就必须在这两个类的方法中都加上日志的代码。也许添加的日志代码是完全相同的,但也正是因为面向对象的设计,让类与类之间无法联系,而不能将这些重复的代码统一起来。

也许你想到了方法:我们可以将这段代码写在一个独立的类的某个方法里(比如工具类),然后在两个类中调用。但是,这样一来,这两个类和新增独立的类就有耦合了,也就是说独立类代码的改变会直接影响这两个类。

那么,有没有什么办法,可以不需要干涉到两个类原本的关系而实现功能呢?

我们可以用抽象思维去思考这个问题,对类的每个方法添加日志这个操作是一个相对更泛的操作。

它不同于对A类的B方法添加日志这种偏向于具体的点的操作。所以它更像是对一个面来操作,这类操作就叫做切面

一般而言,我们管切入到指定类指定方法的代码片段称为切面,而切入到哪些类、哪些方法则叫切入点。有了AOP,我们就可以把几个类共有的代码,抽取到一个切片中,等到需要时再切入对象中去,从而改变其原有的行为。

由于OOP的编程思路,并不能帮我们实现这类切面操作,而我们确实有这类需求,所以就有了AOP的概念,AOP像OOP一样,只是一种编程范式,它本身并没有规定说一定要用什么方式去实现。从上面一大串的解释,也可以看出AOP其实是对OOP的一个补充。

简单的概括上面的内容就是,面向切面编程可以对多个没有关联的类的某一类功能进行管理。

AOP的实现方式

静态AOP

在编译器,切面直接以字节码的形式编译到目标字节码文件中。

1.AspectJ

AspectJ属于静态AOP,它是在编译时进行增强,会在编译时期将AOP逻辑织入到代码中。

由于是在编译器织入,所以它的优点是不影响运行时性能,缺点是不够灵活。

2.AbstractProcessor

自定义一个AbstractProcessor,在编译期去解析编译的类,并且根据需求生成一个实现了特定接口的子类(代理类)

动态AOP

1.JDK动态代理

通过实现InvocationHandler接口,可以实现对一个类的动态代理,通过动态代理可以生成代理类,从而在代理类方法中,在执行被代理类方法前后,添加自己的实现内容,从而实现AOP

2.动态字节码生成

在运行期,目标类加载后,动态构建字节码文件生成目标类的子类,将切面逻辑加入到子类中,没有接口也可以织入,但扩展类的实例方法为final时,则无法进行织入。比如Cglib

CGLIB是一个功能强大,高性能的代码生成包。它为没有实现接口的类提供代理,为JDK的动态代理提供了很好的补充。通常可以使用Java的动态代理创建代理,但当要代理的类没有实现接口或者为了更好的性能,CGLIB是一个好的选择。

3.自定义类加载器

在运行期,目标加载前,将切面逻辑加到目标字节码里。如:Javassist

Javassist是可以动态编辑Java字节码的类库。它可以在Java程序运行时定义一个新的类,并加载到JVM中;还可以在JVM加载时修改一个类文件。

4.ASM

ASM可以在编译期直接修改编译出的字节码文件,也可以像Javassit一样,在运行期,类文件加载前,去修改字节码。

AspectJ的应用

AspectJ的介绍

AspectJ提供了两套强大的机制:

第一套是切面语法。就是网上随便一搜的AspectJ使用方法,它把决定是否使用切面的权利还给了切面。就是说在写切面的时候就可以决定哪些类的哪些方法会被代理,从而从逻辑上不需要侵入业务代码。

由于这套语法实在是太有名,导致很多人都误以为AspectJ就是这一套切面语法,其实不然。

第二套是织入工具。上面介绍的切面语法能够让切面从逻辑上与业务代码解耦,但是从操作上来讲,当JVM运行业务代码的时候,他无从得知旁边还有个类想横插一刀。解决思路就是在编译期(或者类加载期)我们优先考虑一下切面代码,并将切面代码通过某种形式插入到业务代码中,这样业务代码不就知道自己被“切”了么?这种思路的一个实现就是aspectjweaver,就是这里的织入工具

AspectJ提供了两套对切面的描述方法:

一种就是我们常见的基于java注解切面描述的方法,这种方法兼容java语法,写起来十分方便,不需要IDE的额外语法检测支持。

另外一种是基于aspect文件的切面描述方法,这种语法本身并不是java语法,因此写的时候需要IDE的插件支持才能进行语法检查。

AspectJ的使用方法

本文主要介绍基于java注解的这种常用使用方式。

先了解下AspectJ提供的注解:

  • @Aspect:表明这是一个AspectJ文件,编译器在编译的时候,就会自动去解析,然后将代码注入到相应的JPonit
  • @Pointcut:表示具体的切入点,可以确定具体织入代码的地方。可以通过通配、正则表达式等指定点
  • @Before:表示在调用点之前,调用该方法
  • @After:表示在调用点之后,再调用该方法
  • @Around:使用该方法代替该点的执行

Join Point 表示连接点,即 AOP 可织入代码的点:

Join Point.png

Pointcuts是具体的切入点,可以确定具体织入代码的地方。可以通过通配、正则表达式等指定点,常用的有:

Pointcuts.png

区分execution和call:

execution: 用于匹配方法执行的连接点,意思是说,直接在方法内部的前或者后添加。

call: 调用匹配到的方法时调用

AspectJ在Android中的应用

AspectJ我们最常看到的是Spring中的应用,那么在Android中有没有它的用武之地呢?

那必然是有的,下面会用两个栗子来看看有什么应用场景会需要用到它

先准备好环境,需要使用AspectJX插件:AspectJX插件地址

project的build.gradle添加依赖

classpath 'com.hujiang.aspectjx:gradle-android-plugin-aspectjx:2.0.10'

modulebuild.gradle应用插件

plugins {
    id 'android-aspectjx'
}

AspectJX是一个基于AspectJ并在此基础上扩展出来可应用于Android开发平台的AOP框架,可作用于java源码,class文件及jar包,同时支持kotlin的应用。

为什么选用AspectJX而不是基础的AspectJ或其他?

目前其他的AspectJ相关插件和框架都不支持AAR或者JAR切入的,对于Kotlin更加无能为力(以下栗子均为kotlin实现)

栗子一:实现一种方式可防止View连续快速点击

相信客户端的同学应该都会被测试这样的手法摧残过吧:快速点击很多次某按钮导致xxx...

对于这个栗子来说,防止view快速点击这个操作就是一个切面,所以可以用AspectJ进行切面编程。

定义一个注解@FastClickView,参数interval表示多长时间内只有一次点击生效:

@Target(AnnotationTarget.FUNCTION)
@Retention(value = AnnotationRetention.RUNTIME)
annotation class FastClickView(val interval: Long = 3000L)

定义FastClickViewAspect类,把该类作为切面类:对该类添加@Aspect注解

确定切入点:只要使用了FastClickView注解的方法均生效。

使用execution匹配方法执行

// @com.example.aopdemo.FastClickView * *(..) 表示任何支持FastClickView注解的方法
@Pointcut("execution(@com.example.aopdemo.FastClickView * *(..))")

完整代码如下:

@Aspect
class FastClickViewAspect {
    @Pointcut("execution(@com.example.aopdemo.FastClickView * *(..))")
    fun executeFastClickViewLimit() { }
​
    @Around("executeFastClickViewLimit()")
    @Throws(Throwable::class)
    fun aroundExecuteFastClickViewLimit(joinPoint: ProceedingJoinPoint) {
        Log.d(TAG, "aroundClickCountLimit: ")
        val signature: MethodSignature = joinPoint.signature as MethodSignature
        // 取出JoinPoint的方法
        val method = signature.method
        if (method.isAnnotationPresent(FastClickView::class.java)) {
            val annotation: FastClickView? = method.getAnnotation(FastClickView::class.java)
            annotation?.let {
                val interval = annotation.interval
                val view = joinPoint.args[0] as View
                if (!FastClickCheckUtil.isFastClick(view, interval)) {
                    joinPoint.proceed()
                }
            }
        }
    }
}
​
​
object FastClickCheckUtil {
    private const val TAG = "FastClickCheckUtil"
    /**
     * 判断是否属于快速点击
     *
     * @param view     点击的View
     * @param interval 快速点击的阈值
     * @return true:快速点击
     */
    fun isFastClick(view: View, interval: Long): Boolean {
        val key: Int = view.id
        Log.d(TAG, "isFastClick: $view $interval")
        val currentClickTime: Long = System.currentTimeMillis()
        // 如果两次点击间隔超过阈值,则不是快速点击
        if (view.getTag(key) == null || currentClickTime - (view.getTag(key) as Long) > interval) {
            // 保存最近点击时间
            view.setTag(key, currentClickTime)
            return false
        }else{
            return true
        }
​
    }
}
​
// 测试:2s内防止快速点击
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_aop)
    btnFastClick.setOnClickListener(object: View.OnClickListener{
        @FastClickView(2000)
        override fun onClick(view: View?) {
            Log.d(TAG, "onClick: click me...")
        }
    })
}
栗子二:统计方法的耗时

在进行应用启动优化时,我们需要对方法进行方法耗时的统计,常规做法是手动在方法前后里添加代码,计算耗时时间。

这种方式侵入性强,且代码重复。此时使用AOP方式就很优雅:

定义注解@TimeConsume:只要在方法上添加该注解就可以统计出方法耗时并通过日志打印出来

@Target(AnnotationTarget.FUNCTION)
@Retention(value = AnnotationRetention.RUNTIME)
annotation class TimeConsume

定义切面类TimeConsumeAspect:

切入点:任何支持TimeConsume注解的方法

切入时机:注解的方法前@Before和方法后@After

@Aspect
class TimeConsumeAspect {
​
    companion object {
        private const val TAG = "TimeConsumeAspect"
    }
​
    var startTime: Long = 0
​
    @Pointcut("execution(@com.example.aopdemo.TimeConsume * *(..))")
    fun methodTimeConsumePoint() {}
​
    @Before("methodTimeConsumePoint()")
    fun doBefore(joinPoint: JoinPoint) {
        val signature: MethodSignature = joinPoint.signature as MethodSignature
        val method = signature.method
        Log.d(TAG, "doBefore: $method")
        startTime = System.currentTimeMillis()
    }
​
    @After("methodTimeConsumePoint()")
    fun doAfter() {
        val endTime = System.currentTimeMillis()
        val consumeTime = endTime - startTime
        Log.d(TAG, "开始于${startTime},结束于$endTime, 耗时 $consumeTime ms")
    }
}
​

测试代码:

//test: 
@TimeConsume
override fun onStart() {
    try {
        Thread.sleep(3000)
    }catch (e: Exception){
        e.printStackTrace()
    }
    super.onStart()
}
​
@TimeConsume
override fun onResume() {
    super.onResume()
}

查看打印结果:

D/TimeConsumeAspect: doBefore: protected void com.example.aopdemo.MainActivity.onStart()
D/TimeConsumeAspect: 开始于1645418155237,结束于1645418158240, 耗时 3003 ms
D/TimeConsumeAspect: doBefore: protected void com.example.aopdemo.MainActivity.onResume()
D/TimeConsumeAspect: 开始于1645418158247,结束于1645418158263, 耗时 16 ms

demo地址

AOP在Android的其他应用

AOP在Android的应用场景还有很多,比如:

  1. APT的应用:Dagger2、ButterKnife、ARouter
  2. Javassist:热更新

参考博客

以下博客对本文提供了很大的帮助:

原生AspectJ用法分析

AspectJ in Android (二),AspectJ 语法

Spring 之AOP AspectJ切入点语法详解