Android面向切面编程实战

回想一下当年你刚入门Java的时候,是不是有一句很高大上的话:一切都为对象。然后面试的时候经常会被问到面向对象的三大特性是什么?...没错面向对象思想(OOP)确实很棒,它将职责划分的很具体,不同的类干不同的事。但是有的时候很多不同类型的模块都需要实现一个相同的功能,比如记录日志。这样就有可能这个方法加一个Util.log(),又在另外一个方法加一个Util.log(),无形中让Util与调用它的类产生了耦合。有没有一个好办法让Util类与业务功能类不要产生耦合并且还能让日志顺利的记录下来呢?

面向切面编程(AOP)

有一个很棒的思想来解决刚才说的问题,就是面向切面编程。简单的来讲它是一种可以在不改变原来代码的基础上,通过“动态注入”代码,来改变原来执行结果的技术。“注入”的过程对你来说不是透明的,所以在代码编写过程中不会让你觉得有耦合的情况发生。
不过有一点我个人觉得要提醒一下读者,面向切面编程对于面向对象编程来说是一个很好的补充。为什么这么说呢,虽然面向切面编程解决了横向切割某一类方法、属性,但是当我们需要了解某一类方法、某一类属性的信息时,就必须要在每一个类的里面做文章。所以AOP只是弥补OOP的不足而已,这两者不应该对立起来看待,也不是谁为了替代谁而诞生出来的新概念

Android上使用AOP

有很多工具和库可以帮助我们使用AOP,但是在Android开发上还是建议使用AspectJ,因为AspectJ是非侵入式的并且学习成本低。

术语

先来看一个demo,简单并且直观的感受有助于你对AOP术语的理解

public String sayHi() {
    Log.d("CheckNetworkAspectJ", "HI AOP");
    return "HI AOP";
}

这是一个再普通不过的一个方法了,现在我想这个方法的头尾分别加上一段日志打印功能,以实现这样的效果

切入效果

按照之前提及的面向切面编程定义,我们需要把增强的代码从sayHi()方法切入才行

增强的代码如下:

@Around("executionSayHi()")
public Object hi(ProceedingJoinPoint joinPoint) throws Throwable {
    Log.d("CheckNetworkAspectJ", "execution(String *Hi(..)) start");
    Object obj = joinPoint.proceed();
    Log.d("CheckNetworkAspectJ", "execution(String *Hi(..)) end");
    return obj;
}

编译器怎么知道切入的位置?我们需要告诉它。

@Aspect
public class SingleClickAspectJ

SingleClickAspectJ加上@Aspect注解,这样AspectJ在编译时会自动查找被其注解的class。不是代码中任何位置都可以作为切入点,因此找到的class这里都会定义个规则,这里的规则就是executionSayHi()的定义。来看看规则定义的代码

@Pointcut("execution(String *Hi(..))")
public void executionSayHi() {}

上述代码的意思就是在,只要你定义的方法满足“在任何一个类中,方法返回类型为String,方法入参数量不定,方法名后缀为Hi”这个条件,这个方法所在的连接点就转化为切入点。这样在这个切入点的前或后增强代码就自动被修改,以实现预期功能

我想你现在应该有点懵懵懂懂的理解面向切面编程是如何实现的了吧。那我们趁热打铁,仔细学习一下


Advice(通知):注入的代码,也就是增强的方法。Advice有before、after和around等类型,它们分别表示在目标方法执行之前、执行之后和包含目标方法执行前后的代码。 如果你之前对xposed也有所了解,对这个概念理解应该更容易点

类型 描述
Before 前置通知,在目标方法执行之前执行通知
After 后置通知,目标方法执行之后执行通知
Around 环绕通知,在目标方法前后执行通知,控制目标执行时机
AfterReturning 后置返回通知,目标返回时执行通知
AfterThrowing 异常通知,目标抛出异常时执行通知

个人感觉around使用比较多。around可以控制原方法的执行与否,即可以选择执行也可以选择替换


Join Point(连接点):所有目标方法都是连接点,例如构造方法调用、方法调用、方法执行、异常等等,这些都是Join Point。简单来说就是类里面可以增强的部分,它们都可以成为连接点


PointCut(切入点):通过使用一些特定的表达式,也就是通过一定的规则过滤出来的点

这里我要稍微详细点介绍一下切入点的表达式:
表达式一般是这样组成的:execution(<访问权限>?<返回值的类型><类名>.<函数名>(<参数>)<异常>)
表达式的每一个组成部分都有一些特殊的符号表示一些特殊的含义:

符号 描述
* 匹配任何数量字符
* * 在类型模式中匹配任意类中的任意的方法
.. 在类型模式中匹配任何数量子包,而在方法参数模式中匹配任意数量参数
+ 匹配指定类型的子类型,它仅能作为后缀放在类型模式的后边,如* *..Activity+.*(..)

学习下切点匹配规则,死记硬背吧

符号 描述
execution(String *Hi(..)) 任意类中返回类型为String、名字后缀为Hi、参数数量任意的方法
execution(* *Hi(..)) 任意类中返回类型不限定、名字后缀为Hi、参数数量任意的方法
execution(* hi* (..)) 任意类中返回类型不限定、名字前缀为hi、参数数量任意的方法
execution(* com.renyu.aocdemo.*.* (..)) com.renyu.aocdemo包下任意类中返回类型不限定、参数数量任意的方法
execution(* com.renyu.aocdemo.Main2Activity.* (..)) com.renyu.aocdemo.Main2Activity类中返回类型不限定、参数数量任意的方法
execution(* com.renyu.aocdemo..*.* (..)) com.renyu.aocdemo包及其子包下的任意类中返回类型不限定、参数数量任意的方法
execution(* com.renyu.aocdemo.Main2Activity.hisay(..)) 指定一个方法,com.renyu.aocdemo.Main2Activity类中hisay方法
execution(* *..Activity+.*(..)) 指定Activity类及其子类的所有方法

再来看看切点参数匹配规则,返回参数除了(..)外还可以这样定义

符号 描述
() 表示方法没有任何参数
(..,java.lang.String) 表示匹配接受java.lang.String类型的参数结束,且其前边可以接受有任意个参数的方法
(java.lang.String,..) 表示匹配接受java.lang.String类型的参数开始,且其后边可以接受任意个参数的方法
(*,java.lang.String) 表示匹配接受java.lang.String类型的参数结束,且其前边接受有一个任意类型参数的方法
(java.lang.String ...) 表示表示接受不定个数的Object类型的参数

这里我使用的是execution,也就是切入点位置在方法的内部。在调用方法的同时,通知被执行。除了execution外,还有thistargetwithinwithincode等,这篇文章“AOP 之 AspectJ 全面剖析 in Android”可以帮助读者自行了解。

AspectJ使用 且(&&)、或(||)、非(!)来组合切入点表达式

最后我再举例补充说几个使用频率较高的表达式类型

call:execution是在被切入的方法中;call是在调用被切入的方法前或者后,不在方法内部

within:假设现在有多个类,有很多满足String *Hi(..))过滤条件的切入点,但是有几个类的点我不需要拦截。简单粗暴点就是用||来组合这些规则,但是这样肯定很麻烦,这个时候就用到了我们的within来处理类型过滤了。来看看限定在MainActivity中才可以执行的办法

@Pointcut("call(* com.renyu.aocdemo.MainActivity.sayHi(..))")
public void executionSayHi() {}

@Pointcut("within(com.renyu.aocdemo.MainActivity)")
public void executionAbc() {}

@Around("executionSayHi() && executionAbc()")
public Object hi(ProceedingJoinPoint joinPoint) throws Throwable {
    System.out.println("executionSayHi() && executionAbc()");
    return joinPoint.proceed();
}

withincode:除了刚才类型的过滤,还可以使用withincode对构造函数或方法进行过滤。下面这个例子就是MainActivity中的abc()方法调用完成sayHi()时才进行切入

@Pointcut("call(* com.renyu.aocdemo.MainActivity.sayHi(..))")
public void executionSayHi() {}

@Pointcut("withincode(* com.renyu.aocdemo.MainActivity.abc(..))")
public void executionAbc() {}

@Around("executionSayHi() && executionAbc()")
public Object hi(ProceedingJoinPoint joinPoint) throws Throwable {
    System.out.println("executionSayHi() && executionAbc()");
    return joinPoint.proceed();
}

get/set:在读取变量值或者设置变量值的时候产生效果,一般用于修改类的返回值或禁止用户修改类的变量

@Pointcut("get(String com.renyu.aocdemo.model.UserModel.name)")
public void changeModelValue() {}

@Around("changeModelValue()")
public String changeModelValue(ProceedingJoinPoint joinPoint) throws Throwable {
    System.out.println("get(String com.renyu.aocdemo.model.UserModel.name)");
    return "change";
}

Aspect(切面):PointCut和Advice的组合可以被看做为切面。


Weaving(织入):将Advice注入到目标位置Join Point的过程。


AspectJ术语就说完了,下面要开始真正的代码编写工作了

Gradle配置

前往Maven Repository查看AspectJ的最新版本,截止到本文发布时间,目前最新版本是1.9.1

AspectJ版本

aspectj的配置比较恶心,如果你准备在你的Library中进行配置,请不要忽略下面我说的步骤

前往项目根目录build.gradle进行配置,添加mavenCentral()仓库,并配置aspectjtoolsaspectjweaver的版本

buildscript {
    ext.kotlin_version = '1.2.41'
    repositories {
        google()
        jcenter()
        mavenCentral()
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:3.1.2'
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"

        // NOTE: Do not place your application dependencies here; they belong
        // in the individual module build.gradle files
        classpath 'org.aspectj:aspectjtools:1.9.1'
        classpath 'org.aspectj:aspectjweaver:1.9.1'
    }
}

allprojects {
    repositories {
        google()
        jcenter()
    }
}

task clean(type: Delete) {
    delete rootProject.buildDir
}

将aspectJ的配置在此拷贝,这里暴露一个模块结构类型的入参

import org.aspectj.bridge.IMessage
import org.aspectj.bridge.MessageHandler
import org.aspectj.tools.ajc.Main

def aop(variants) {
    def log = project.logger
    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
                }
            }
        }
    }
}

随后前往主模块的build.gradle进行配置

rootProject.aop(project.android.applicationVariants)

dependencies里添加aspectjrt的版本implementation 'org.aspectj:aspectjrt:1.9.1'

如果你在Library类型的Module里面进行配置,也要做相同的一件事,只不过传入aop的值会有所不同

rootProject.aop(project.android.libraryVariants)

千万不要忘记在dependencies里再添加一遍,这个比较恶心

千万留意一下配置的流程,这个折腾我半小时,主要是没啥资料,得在github大海捞针

代码编写

今天我们将实现一个功能:防按钮多次点击
先简单说一下思路。一般情况下我们要实现这个功能无非就是判断两次点击之间时间间隔有没有超过一个阈值,在阈值范围内则可以点击,反之则不让点击。对于AOP来说也是这么一回事,只要点击事件发生,则立刻从该切入点进入切面,在切面中进行阈值范围的判断

1. 切点编写

先看看切入点作用到的范围:项目中任意类的任意方法,只要你带有@com.renyu.aocdemo.singleclick.SingleClick这个注解,你就满足切入点条件。

@Pointcut("execution(@com.renyu.aocdemo.singleclick.SingleClick * *(..))")
public void executionSingleClick() {}

SingleClick是自定义的注解,来看看它的实现。定义了默认3s的阈值,并且添加了排除限制的视图数组

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface SingleClick {
    long interval() default 3000;
    int[] exceptViews() default {-1};
}
2. 处理逻辑编写

切点完成之后,就是处理逻辑的实现了。首先要拿到连接点JoinPoint提供的信息

Android面向切面编程实战_第1张图片
JoinPoint中主要方法

常用的方法为以下三个:
getThis():返回AOP代理对象,一般就是被切入方法所在类的对象
getArgs():返回被切入方法的参数列表
getTarget():返回AOP代理对象,一般就是被切入方法所在类的对象
getSignature():返回当前连接点签名信息

我没有发现getThis()getTarget()有什么区别,比较尴尬。。。

getThis()与getTarget()

在我选择@Around方便我控制点击事件的发送,这里的入参是JoinPoint的子接口ProceedingJoinPoint

@Around("executionSingleClick()")
public Object checkSingleClick(ProceedingJoinPoint joinPoint) throws Throwable

使用getSignature()拿到连接点的信息

Object[] args = joinPoint.getArgs();
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();

随后获取到连接点上注解参数的值,这里获取的是时间阈值以及排除视图数组

SingleClick singleClick = methodSignature.getMethod().getAnnotation(SingleClick.class);
long time = singleClick.interval();
int[] values = singleClick.exceptViews();

当我们的配置是正确的时候,即至少有一个当前被点击的View作为入参,并且这个View设置了Id

if (args != null && args.length>=1 && args[0] instanceof View && ((View) args[0]).getId() != View.NO_ID)

在配置正确的情况下,如果在被排除名单中,则直接放行。这里joinPoint.proceed()用来执行目标方法,它的返回值就是被我们监听的方法的返回值,我们甚至可以通过改变这个值中的一些属性,达到改变这个返回值的作用

for (int value : values) {
    if (value == ((View) args[0]).getId()) {
        return joinPoint.proceed();
    }
}

我在全局定义了一个ID映射,如果不在映射中则直接放行,因为是第一次点击;如果在映射中,则判断当前时间与上一次点击时间间隔有没有超过那个阈值,超过则放行,反之则结束

public HashMap caches = new HashMap<>();

// 存在ID的情况,证明之前被点击过
if (caches.containsKey(((View) args[0]).getId())) {
    // 超过点击限制时间,直接通过
    if (System.currentTimeMillis() - caches.get(((View) args[0]).getId()) > time) {
        // 保存当前点击时间
        caches.put(((View) args[0]).getId(), System.currentTimeMillis());
        return joinPoint.proceed();
    }
    else {
        return null;
    }
}
// 不存在ID的情况,直接通过
else {
    // 保存当前点击时间
    caches.put(((View) args[0]).getId(), System.currentTimeMillis());
    return joinPoint.proceed();
}

最后就是配置测试了

@SingleClick()
public void clickTest(View view) {
    System.out.println("clickTest");
}

快速点击的情况下,打印结果如下

快速点击

添加到排除名单之后

@SingleClick(exceptViews = {R.id.tv_main})
public void clickTest(View view) {
    System.out.println("clickTest");
}
Android面向切面编程实战_第2张图片
快速点击并添加到排除名单

在Kotlin中的使用

aspectjrt是不支持在Kotlin使用aop的,但是沪江的大神们已经做了一套插件实现了这个功能:gradle_plugin_android_aspectjx。这个插件配置起来就更简单一些了,在根目录的build.gradle添加

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

在主模块的build.gradle中添加

apply plugin: 'com.hujiang.android-aspectjx'

剩下就是在各个模块的dependencies中添加

api 'org.aspectj:aspectjrt:1.9.1'

使用过程如出一辙

@SingleClick
fun clockValue(view: View) {
    println("onViewClick")
}

总结

AOP的用途很多,在检测登录、打印日志、缓存设计、数据校验上都可以使用。个人理解是浅薄的,希望大家可以多多提出自己的想法。

参考文章

AOP 之 AspectJ 全面剖析 in Android
看AspectJ在Android中的强势插入
gradle_plugin_android_aspectjx
Android基于AOP的非侵入式监控之——AspectJ实战

你可能感兴趣的:(Android面向切面编程实战)