Android面向切面程式設計(AOP)

GitLqr發表於2017-11-16

一、簡述

1、AOP的概念

如果你用java做過後臺開發,那麼你一定知道AOP這個概念。如果不知道也無妨,套用百度百科的介紹,也能讓你明白這玩意是幹什麼的:

AOP為Aspect Oriented Programming的縮寫,意為:面向切面程式設計,通過預編譯方式和執行期動態代理實現程式功能的統一維護的一種技術。AOP是OOP的延續,是軟體開發中的一個熱點,也是Spring框架中的一個重要內容,是函數語言程式設計的一種衍生範型。利用AOP可以對業務邏輯的各個部分進行隔離,從而使得業務邏輯各部分之間的耦合度降低,提高程式的可重用性,同時提高了開發的效率。

2、專案場景

專案開發過程中,可能會有這樣的需求,需要我們在方法執行完成後,記錄日誌(後臺開發中比較常見~),或是計算這個方法的執行時間,在不使用AOP的情況下,我們可以在方法最後呼叫另一個專門記錄日誌的方法,或是在方法體的首尾分別獲取時間,然後通過計算時間差來計算整個方法執行所消耗的時間,這樣也可以完成需求。那如果不只一個方法要這麼玩怎麼辦?每個方法都寫上一段相同的程式碼嗎?後期處理邏輯變了要怎麼辦?最後老闆說這功能不要了我們還得一個個刪除?

很明顯,這是不可能的,我們不僅僅是程式碼的搬運工,我們還是有思考能力的軟體開發工程師。這麼low的做法絕對不幹,這種問題我們完全可以用AOP來解決,不就是在方法前和方法後插入一段程式碼嗎?AOP分分鐘搞定。

3、AOP的實現方式

要注意了,AOP僅僅只是個概念,實現它的方式(工具和庫)有以下幾種:

  • AspectJ: 一個 JavaTM 語言的面向切面程式設計的無縫擴充套件(適用Android)。
  • Javassist for Android: 用於位元組碼操作的知名 java 類庫 Javassist 的 Android 平臺移植版。
  • DexMaker: Dalvik 虛擬機器上,在編譯期或者執行時生成程式碼的 Java API。
  • ASMDEX: 一個類似 ASM 的位元組碼操作庫,執行在Android平臺,操作Dex位元組碼。

本篇的主角就是AspectJ,下面就來看看AspectJ方式的AOP如何在Android開發中進行使用吧。

二、AspectJ的引入

對於eclipse與Android Studio的引入是不一樣的,本篇只介紹Android Studio如何引入AspectJ,eclipse請自行百度。Android Studio需要在app模組的build.gradle檔案中引入,總共分為3個步驟:

1)新增核心依賴

dependencies {
    ...
    compile 'org.aspectj:aspectjrt:1.8.9'
}複製程式碼

2)編寫gradle編譯指令碼

buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath 'org.aspectj:aspectjtools:1.8.9'
        classpath 'org.aspectj:aspectjweaver:1.8.9'
    }
}複製程式碼

AspectJ需要依賴maven倉庫。

3)新增gradle任務

dependencies {
    ...
}
// 貼上面那段沒用的程式碼是為了說明:下面的任務程式碼與dependencies同級

import org.aspectj.bridge.IMessage
import org.aspectj.bridge.MessageHandler
import org.aspectj.tools.ajc.Main
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;
            }
        }
    }
}複製程式碼

直接貼上到build.gradle檔案的末尾即可,不要巢狀在別的指令中。

三、AOP的基本知識

在使用AspectJ之前,還是需要先介紹下AOP的基本知識,熟悉的看官可以跳過這部分。

1、AOP術語

  1. 通知、增強處理(Advice):就是你想要的功能,也就是上面說的日誌、耗時計算等。
  2. 連線點(JoinPoint):允許你通知(Advice)的地方,那可就真多了,基本每個方法的前、後(兩者都有也行),或丟擲異常是時都可以是連線點(spring只支援方法連線點)。AspectJ還可以讓你在構造器或屬性注入時都行,不過一般情況下不會這麼做,只要記住,和方法有關的前前後後都是連線點。
  3. 切入點(Pointcut):上面說的連線點的基礎上,來定義切入點,你的一個類裡,有15個方法,那就有十幾個連線點了對吧,但是你並不想在所有方法附件都使用通知(使用叫織入,下面再說),你只是想讓其中幾個,在呼叫這幾個方法之前、之後或者丟擲異常時乾點什麼,那麼就用切入點來定義這幾個方法,讓切點來篩選連線點,選中那幾個你想要的方法。
  4. 切面(Aspect):切面是通知和切入點的結合。現在發現了吧,沒連線點什麼事,連線點就是為了讓你好理解切點搞出來的,明白這個概念就行了。通知說明了幹什麼和什麼時候幹(什麼時候通過before,after,around等AOP註解就能知道),而切入點說明了在哪幹(指定到底是哪個方法),這就是一個完整的切面定義。
  5. 織入(weaving) 把切面應用到目標物件來建立新的代理物件的過程。

上述術語的解釋引用自《AOP中的概念通知、切點、切面》這篇文章,作者的描述非常直白,很容易理解,點個贊。

2、AOP註解與使用

  • @Aspect:宣告切面,標記類
  • @Pointcut(切點表示式):定義切點,標記方法
  • @Before(切點表示式):前置通知,切點之前執行
  • @Around(切點表示式):環繞通知,切點前後執行
  • @After(切點表示式):後置通知,切點之後執行
  • @AfterReturning(切點表示式):返回通知,切點方法返回結果之後執行
  • @AfterThrowing(切點表示式):異常通知,切點丟擲異常時執行

@Pointcut、@Before、@Around、@After、@AfterReturning、@AfterThrowing需要在切面類中使用,即在使用@Aspect的類中。

1)切點表示式是什麼?

這就是切點表示式:execution (* com.lqr..*.*(..))。切點表示式的組成如下:

execution(<修飾符模式>? <返回型別模式> <方法名模式>(<引數模式>) <異常模式>?)複製程式碼

除了返回型別模式、方法名模式和引數模式外,其它項都是可選的。

修飾符模式指的是public、private、protected,異常模式指的是NullPointException等。

對於切點表示式的理解不是本篇重點,下面列出幾個例子說明一下就好了:

@Before("execution(public * *(..))")
public void before(JoinPoint point) {
    System.out.println("CSDN_LQR");
}複製程式碼

匹配所有public方法,在方法執行之前列印"CSDN_LQR"。

@Around("execution(* *to(..))")
public void around(ProceedingJoinPoint joinPoint) {
    System.out.println("CSDN");
    joinPoint.proceed();
    System.out.println("LQR");
}複製程式碼

匹配所有以"to"結尾的方法,在方法執行之前列印"CSDN",在方法執行之後列印"LQR"。

@After("execution(* com.lqr..*to(..))")
public void after(JoinPoint point) {
    System.out.println("CSDN_LQR");
}複製程式碼

匹配com.lqr包下及其子包中以"to"結尾的方法,在方法執行之後列印"CSDN_LQR"。

@AfterReturning("execution(int com.lqr.*(..))")
public void afterReturning(JoinPoint point, Object returnValue) {
    System.out.println("CSDN_LQR");
}複製程式碼

匹配com.lqr包下所有返回型別是int的方法,在方法返回結果之後列印"CSDN_LQR"。

@AfterThrowing(value = "execution(* com.lqr..*(..))", throwing = "ex")
public void afterThrowing(Throwable ex) {
    System.out.println("ex = " + ex.getMessage());
}複製程式碼

匹配com.lqr包及其子包中的所有方法,當方法丟擲異常時,列印"ex = 報錯資訊"。

2)@Pointcut的使用

@Pointcut是專門用來定義切點的,讓切點表示式可以複用。

你可能需要在切點執行之前和切點報出異常時做些動作(如:出錯時記錄日誌),可以這麼做:

@Before("execution(* com.lqr..*(..))")
public void before(JoinPoint point) {
    System.out.println("CSDN_LQR");
}

@AfterThrowing(value = "execution(* com.lqr..*(..))", throwing = "ex")
public void afterThrowing(Throwable ex) {
    System.out.println("記錄日誌");
}複製程式碼

可以看到,表示式是一樣的,那要怎麼重用這個表示式呢?這就需要用到@Pointcut註解了,@Pointcut註解是註解在一個空方法上的,如:

@Pointcut("execution(* com.lqr..*(..))")
public void pointcut() {}複製程式碼

這時,"pointcut()"就等價於"execution(* com.lqr..*(..))",那麼上面的程式碼就可以這麼改了:

@Before("pointcut()")
public void before(JoinPoint point) {
    System.out.println("CSDN_LQR");
}

@AfterThrowing(value = "pointcut()", throwing = "ex")
public void afterThrowing(Throwable ex) {
    System.out.println("記錄日誌");
}複製程式碼

四、實戰

經過上面的學習,下面是時候實戰一下了,這裡我們來一個簡單的例子。

1、切點

這是介面上一個按鈕的點選事件,就是一個簡單的方法而已,我們拿它來試刀。

public void test(View view) {
    System.out.println("Hello, I am CSDN_LQR");
}複製程式碼

2、切面類

要織入一段程式碼到目標類方法的前前後後,必須要有一個切面類,下面就是切面類的程式碼:

@Aspect
public class TestAnnoAspect {

    @Pointcut("execution(* com.lqr.androidaopdemo.MainActivity.test(..))")
    public void pointcut() {

    }    

    @Before("pointcut()")
    public void before(JoinPoint point) {
        System.out.println("@Before");
    }

    @Around("pointcut()")
    public void around(ProceedingJoinPoint joinPoint) throws Throwable {
        System.out.println("@Around");
    }

     @After("pointcut()")
    public void after(JoinPoint point) {
        System.out.println("@After");
    }

    @AfterReturning("pointcut()")
    public void afterReturning(JoinPoint point, Object returnValue) {
        System.out.println("@AfterReturning");
    }

    @AfterThrowing(value = "pointcut()", throwing = "ex")
    public void afterThrowing(Throwable ex) {
        System.out.println("@afterThrowing");
        System.out.println("ex = " + ex.getMessage());
    }
}複製程式碼

3、各通知的執行結果

先來試試看,這幾個註解的執行結果如何。

不對啊,按鈕的點選事件中有列印"Hello, I am CSDN_LQR"的,這裡沒有,怎麼肥事?

這裡因為@Around環繞通知會攔截原方法內容的執行,我們需要手動放行才可以。程式碼修改如下:

@Around("pointcut()")
public void around(ProceedingJoinPoint joinPoint) throws Throwable {
    System.out.println("@Around");
    joinPoint.proceed();// 目標方法執行完畢
}複製程式碼

也不對啊,少了一個@AfterThrowing通知。這個通知只有在切點丟擲異常時才會執行,我們可以讓程式碼出現一個簡單的執行時異常:

public void test(View view) {
    System.out.println("Hello, I am CSDN_LQR");
    int a = 1 / 0;
}複製程式碼

這下@AfterThrowing通知確實被呼叫了,而且也列印出了錯誤資訊(divide by zero)。但@AfterReturning通知反而不執行了,原因很簡單,都丟擲異常了,切點肯定是不能返回結果的。也就是說:@AfterThrowing通知與@AfterReturning通知是衝突的,在同個切點上不可能同時出現。

4、方法耗時計算的實現

因為@Around是環繞通知,可以在切點的前後分別執行一些操作,AspectJ為了能肯定操作是在切點前還是在切點後,所以在@Around通知中需要手動執行joinPoint.proceed()來確定切點已經執行,故在joinPoint.proceed()之前的程式碼會在切點執行前執行,在joinPoint.proceed()之後的程式碼會切點執行後執行。於是,方法耗時計算的實現就是這麼簡單:

@Around("pointcut()")
public void around(ProceedingJoinPoint joinPoint) throws Throwable {
    long beginTime = SystemClock.currentThreadTimeMillis();
    joinPoint.proceed();
    long endTime = SystemClock.currentThreadTimeMillis();
    long dx = endTime - beginTime;
    System.out.println("耗時:" + dx + "ms");
}複製程式碼

5、JoinPoint的作用

發現沒有,上面所有的通知都會至少攜帶一個JointPoint引數,這個引數包含了切點的所有資訊,下面就結合按鈕的點選事件方法test()來解釋joinPoint能獲取到的方法資訊有哪些:

MethodSignature signature = (MethodSignature) joinPoint.getSignature();
String name = signature.getName(); // 方法名:test
Method method = signature.getMethod(); // 方法:public void com.lqr.androidaopdemo.MainActivity.test(android.view.View)
Class returnType = signature.getReturnType(); // 返回值型別:void
Class declaringType = signature.getDeclaringType(); // 方法所在類名:MainActivity
String[] parameterNames = signature.getParameterNames(); // 引數名:view
Class[] parameterTypes = signature.getParameterTypes(); // 引數型別:View複製程式碼

6、註解切點

前面的切點表示式結構是這樣的:

execution(<修飾符模式>? <返回型別模式> <方法名模式>(<引數模式>) <異常模式>?)複製程式碼

但實際上,上面的切點表示式結構並不完整,應該是這樣的:

execution(<@註解型別模式>? <修飾符模式>? <返回型別模式> <方法名模式>(<引數模式>) <異常模式>?)複製程式碼

這就意味著,切點可以用註解來標記了。

1)自定義註解

如果用註解來標記切點,一般會使用自定義註解,方便我們擴充。

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface TestAnnoTrace {
    String value();
    int type();
}複製程式碼
  • @Target(ElementType.METHOD):表示該註解只能註解在方法上。如果想類和方法都可以用,那可以這麼寫:@Target({ElementType.METHOD,ElementType.TYPE}),依此類推。
  • @Retention(RetentionPolicy.RUNTIME):表示該註解在程式執行時是可見的(還有SOURCE、CLASS分別指定註解對於那個級別是可見的,一般都是用RUNTIME)。

其中的value和type是自己擴充的屬性,方便儲存一些額外的資訊。

2)使用自定義註解標記切點

這個自定義註解只能註解在方法上(構造方法除外,構造方法也叫構造器,需要使用ElementType.CONSTRUCTOR),像平常使用其它註解一樣使用它即可:

@TestAnnoTrace(value = "lqr_test", type = 1)
public void test(View view) {
    System.out.println("Hello, I am CSDN_LQR");
}複製程式碼

3)註解的切點表示式

既然用註解來標記切點,那麼切點表示式肯定是有所不同的,要這麼寫:

@Pointcut("execution(@com.lqr.androidaopdemo.TestAnnoTrace * *(..))")
public void pointcut() {}複製程式碼

切點表示式使用註解,一定是@+註解全路徑,如:@com.lqr.androidaopdemo.TestAnnoTrace。

親測可用 ,不貼圖了。

4)獲取註解屬性值

上面在編寫自定義註解時就宣告瞭兩個屬性,分別是value和type,而且在使用該註解時也都為之賦值了,那怎麼在通知中獲取這兩個屬性值呢?還記得JoinPoint這個引數吧,它就可以獲取到註解中的屬性值,如下所示:

MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
// 通過Method物件得到切點上的註解
TestAnnoTrace annotation = method.getAnnotation(TestAnnoTrace.class);
String value = annotation.value();
int type = annotation.type();複製程式碼

最後貼下Demo地址

github.com/GitLqr/Andr…

相關文章