Android AOP學習之:AspectJ實踐

LemonYang發表於2017-02-22

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

相關文章