Android AspectJ詳解

AOP是一個老生常談的話題,全稱"Aspect Oriented Programming",表示面向切面編程。因爲面向對象的編程思想推崇高內聚、低耦合的架構風格,使得模塊間代碼的可見性變差,這使得實現下面的需求變得十分複雜:統計埋點、日誌輸出、權限攔截等等,若是手動編碼,代碼侵入性過高且不利於擴展,AOP技術應運而生。html

AOP

AOP中的切面比較形象,各個業務模塊就像平鋪在一個容器中,假如如今須要給各個模塊添加點擊事件埋點,AOP就像給全部業務模塊間插入一個虛擬的切面,後續全部的點擊事件經過這個切面時,咱們有機會作一些額外的事情。java

之因此說是虛擬,是由於整個過程對具體的業務場景是非侵入性的,業務代碼不用改動,新增的代碼邏輯也不須要作額外的適配。這個過程有點像OkHttp的攔截器,或者能夠說攔截器是面向切面的一個具體實現。android

本文是對AspectJ的使用介紹,經過這個工具,咱們能夠輕鬆的實現一些簡單的AOP需求,而不須要懂像編譯原理,字節碼結構等相對複雜的底層技術。git

在Android平臺,經常使用的是hujiang的一個aspectjx插件,它的工做原理是:經過Gradle Transform,在class文件生成後至dex文件生成前,遍歷並匹配全部符合AspectJ文件中聲明的切點,而後將事先聲明好的代碼在切點先後織入。github

經過描述可知,整個過程發生在編譯期,是一種靜態織入方式,因此會增長必定的編譯時長,但幾乎不會影響程序的運行時效率。express


本文大體分爲三個部分。編程

  1. AspectJ的語法和使用。
  2. 經過Jake Wharton大神的開源項目Hugo,實戰AspectJ。
  3. AspectJ面臨的問題。

AspectJ能作什麼?

一般來講,AOP都是爲一些相對基礎且固定的需求服務,實際常見的場景大體包括:bash

  • 統計埋點
  • 日誌打印/打點
  • 數據校驗
  • 行爲攔截
  • 性能監控
  • 動態權限控制

若是你在項目中也有這樣的需求(幾乎必定有),能夠考慮經過AspectJ來實現。架構

除了織入代碼,AspectJ還能爲類增長實現接口、添加成員變量,固然這不是本文的重點,感興趣的小夥伴能夠在學習完基礎知識後瞭解相關內容。app

環境配置

在Android平臺,咱們一般使用上文提到的Aspectjx插件來配置AspectJ環境,具體使用是經過AspectJ註解完成。

  1. 在項目根目錄的build.gradle裏依賴AspectJX
dependencies {
    classpath 'com.hujiang.aspectjx:gradle-android-plugin-aspectjx:2.0.4'
}
複製代碼
  1. 在須要支持AspectJ的module的build.gradle文件中聲明插件。
apply plugin: 'android-aspectjx'
複製代碼

在編譯階段AspectJ會遍歷工程中全部class文件(包括第三方類庫的class)尋找符合條件的切入點,爲加快這個過程或縮小代碼織入範圍,咱們可使用exclude排除掉指定包名的class。

# app/build.gradle
aspectjx {
    //排除全部package路徑中包含`android.support`的class文件及庫(jar文件)
	exclude 'android.support'
}
複製代碼

在debug階段咱們更注重編譯速度,能夠關閉代碼織入。

# app/build.gradle
aspectjx {
    //關閉AspectJX功能
	enabled false
}
複製代碼

但目前最新的2.0.4版本的插件有bug,若是關閉AspectJ,則會致使工程內全部class不能打入APK中,運行會出現各類ClassNotFoundException,已經有Issue提出但還沒有解決(坑貨)。筆者嘗試將版本回退到2.0.0版本,發現無此問題。若是你目前也有動態關閉的需求,建議不要使用最新版本。

基本語法

環境配置完成後,咱們須要用AspectJ註解編寫切面代碼。

  • @Aspect 用它聲明一個類,表示一個須要執行的切面。
  • @Pointcut 聲明一個切點。
  • @Before/@After/@Around/...(統稱爲Advice類型) 聲明在切點前、後、中執行切面代碼。

這麼說你可能有點蒙,咱們換個角度解釋。

假設你是一個AOP框架的設計者,最早須要理清的其基本組成要素。既然須要作代碼織入那是否是必定得配置代碼的織入點呢?這個織入點就是Pointcut,有了織入點咱們還須要指定具體織入的代碼,這個代碼寫在哪裏呢?就是寫在以@Before/@After/@Around註解的方法體內。有了織入點和織入代碼,還須要告訴框架本身是一個面向切面的配置文件,這就須要使用@Aspect聲明在類上。

咱們舉個簡單的栗子,所有示例參考github sample_aspectj

@Aspect  //①
public class MethodAspect {

    @Pointcut("call(* com.wandering.sample.aspectj.Animal.fly(..))")//②
    public void callMethod() {
    }

    @Before("callMethod()")//③
    public void beforeMethodCall(JoinPoint joinPoint) {
        Log.e(TAG, "before->" + joinPoint.getTarget().toString()); //④
    }
}
複製代碼

咱們事先準備好的Animal類中有一個fly方法。

public class Animal {
    public void fly() {
        Log.e(TAG, "animal fly method:" + this.toString() + "#fly");
    }
}
複製代碼

①處聲明瞭本類是一個AspectJ配置文件。

②處指定了一個代碼織入點,註解內的call(* com.wandering.sample.aspectj.Animal.fly(..)) 是一個切點表達式,第一個*號表示返回值可爲任意類型,後跟包名+類名+方法名,括號內表示參數列表, .. 表示匹配任意個參數,參數類型爲任何類型,這個表達式指定了一個時機:在Animal類的fly方法被調用時。

③處聲明Advice類型爲Before並指定切點爲上面callMethod方法所表示的那個切點。

④處爲實際織入的代碼。

翻譯成白話就是說在Animal類的fly方法被調用前插入④處的代碼。

編寫測試代碼並調用fly方法,運行觀察日誌輸出你會發現before->的日誌先於animal fly日誌被打印,具體可查看sample工程MethodAspect示例。

咱們再將APK反編譯看一下織入結果。

代碼織入結果.jpg

紅色框選部分就是AspectJ爲咱們織入的代碼。

經過上面的例子咱們瞭解了AspectJ的基本用法,但實際上AspectJ的語法能夠十分複雜,下面咱們來看看具體的語法。

Join Point

上面的例子中少講了一個鏈接點的概念,鏈接點表示可織入代碼的點,它屬於Pointcut的一部分。因爲語法內容較多,實際使用過程當中咱們能夠參考語法手冊,咱們列出其中一部分Join Point:

Joint Point 含義
Method call 方法被調用
Method execution 方法執行
Constructor call 構造函數被調用
Constructor execution 構造函數執行
Static initialization static 塊初始化
Field get 讀取屬性
Field set 寫入屬性
Handler 異常處理

Method call 和 Method execution的區別常拿來比較,其實就是調用與執行的區別,就拿上面Animal的fly方法舉例。demo代碼以下:

Animal a = Animal();
a.fly();
複製代碼

若是咱們聲明的織入點爲call,再假設Advice類型是before,則織入後代碼結構是這樣的。

Animal a = new Animal();
//...我是織入代碼
a.fly();
複製代碼

若是咱們聲明的織入點爲execution,則織入後代碼結構就成這樣了。

public class Animal {
    public void fly() {
        //...我是織入代碼
        Log.e(TAG, "animal fly method:" + this.toString() + "#fly");
    }
}
複製代碼

本質上的區別就是織入對象不一樣,call被織入在指定方法被調用的位置上,而execution被織入到指定的方法內部。

Pointcut

Pointcuts是具體的切入點,基本上Pointcuts 是和 Join Point 相對應的。

Joint Point Pointcuts 表達式
Method call call(MethodPattern)
Method execution execution(MethodPattern)
Constructor call call(ConstructorPattern)
Constructor execution execution(ConstructorPattern)
Static initialization staticinitialization(TypePattern)
Field get get(FieldPattern)
Field set set(FieldPattern)
Handler handler(TypePattern)

除了上面與 Join Point 對應的選擇外,Pointcuts 還有其餘選擇方法。

Pointcuts 表達式 說明
within(TypePattern) 符合 TypePattern 的代碼中的 Join Point
withincode(MethodPattern) 在某些方法中的 Join Point
withincode(ConstructorPattern) 在某些構造函數中的 Join Point
cflow(Pointcut) Pointcut 選擇出的切入點 P 的控制流中的全部 Join Point,包括 P 自己
cflowbelow(Pointcut) Pointcut 選擇出的切入點 P 的控制流中的全部 Join Point,不包括 P 自己
this(Type or Id) Join Point 所屬的 this 對象是否 instanceOf Type 或者 Id 的類型
target(Type or Id) Join Point 所在的對象(例如 call 或 execution 操做符應用的對象)是否 instanceOf Type 或者 Id 的類型
args(Type or Id, ...) 方法或構造函數參數的類型
if(BooleanExpression) 知足表達式的 Join Point,表達式只能使用靜態屬性、Pointcuts 或 Advice 暴露的參數、thisJoinPoint 對象

this vs. target

this和target是一個容易混淆的點。

# MethodAspect.java
public class MethodAspect {
    @Pointcut("call(* com.wandering.sample.aspectj.Animal.fly(..))")
    public void callMethod() {
        Log.e(TAG, "callMethod->");
    }

    @Before("callMethod()")
    public void beforeMethodCall(JoinPoint joinPoint) {
        Log.e(TAG, "getTarget->" + joinPoint.getTarget());
        Log.e(TAG, "getThis->" + joinPoint.getThis());
    }
}
複製代碼

fly調用方:

# MainActivity.java
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    Animal animal = new Animal();
    animal.fly();
}
複製代碼

運行結果以下:

getTarget->com.wandering.sample.aspectj.Animal@509ddfd
getThis->com.wandering.sample.aspectj.MainActivity@98c38bf
複製代碼

也就是說target指代的是切入點方法的全部者,而this指代的是被織入代碼所屬類的實例對象。

咱們稍加改動,將切點的call改成execution。

運行結果就成這個樣子了:

getTarget->com.wandering.sample.aspectj.Animal@509ddfd
getThis->com.wandering.sample.aspectj.Animal@509ddfd
複製代碼

按照上面的分析,與這個結果也是吻合的。

條件運算

Pointcut表達式中還可使用一些條件判斷符,好比 !、&&、||。

以Hugo爲例:

# Hugo.java
@Pointcut("within(@hugo.weaving.DebugLog *)")
public void withinAnnotatedClass() {}

@Pointcut("execution(!synthetic * *(..)) && withinAnnotatedClass()")
public void methodInsideAnnotatedType() {}
複製代碼

第一個切點指定範圍爲包含DebugLog註解的任意類和方法,第二個切點爲在第一個切點範圍內,且執行非內部類的任意方法。結合起來表述就是任意聲明瞭DebugLog註解的方法。

其中@hugo.weaving.DebugLog *!synthetic * *(..)分別對應上面表格中提到的TypePattern和MethodPattern。

接下來須要瞭解這些pattern具體的語法,經過語法咱們能夠寫出符合自身需求的表達式。

Pattern類型 語法
MethodPattern [!] [@Annotation] [public,protected,private] [static] [final] 返回值類型 [類名.]方法名(參數類型列表) [throws 異常類型]
ConstructorPattern [!] [@Annotation] [public,protected,private] [final] [類名.]new(參數類型列表) [throws 異常類型]
FieldPattern [!] [@Annotation] [public,protected,private] [static] [final] 屬性類型 [類名.]屬性名
TypePattern 其餘 Pattern 涉及到的類型規則也是同樣,可使用 '!'、''、'..'、'+','!' 表示取反,'' 匹配除 . 外的全部字符串,'*' 單獨使用事表示匹配任意類型,'..' 匹配任意字符串,'..' 單獨使用時表示匹配任意長度任意類型,'+' 匹配其自身及子類,還有一個 '...'表示不定個數

更多語法參見官網Pointcuts,很是有用。

再看幾個例子:

execution(void setUserVisibleHint(..)) && target(android.support.v4.app.Fragment) && args(boolean) --- 執行 Fragment 及其子類的 setUserVisibleHint(boolean) 方法時。

execution(void Foo.foo(..)) && cflowbelow(execution(void Foo.foo(..))) --- 執行 Foo.foo() 方法中再遞歸執行 Foo.foo() 時。

if條件

一般狀況下,Pointcuts註解的方法參數列表爲空,返回值爲void,方法體也爲空。可是若是表達式中聲明瞭:

  • args、target、this等類型參數,則可額外聲明參數列表。
  • if條件,則方法必須public static boolean

來看sample示例MethodAspect8:

@Aspect
public class MethodAspect8 {
    @Pointcut("call(boolean *.*(int)) && args(i) && if()")
    public static boolean someCallWithIfTest(int i, JoinPoint jp) {
        // any legal Java expression...
        return i > 0 && jp.getSignature().getName().startsWith("setAge");
    }

    @Before("someCallWithIfTest(i, jp)")
    public void aroundMethodCall(int i, JoinPoint jp) {
        Log.e(TAG, "before if ");
    }

}
複製代碼

切點方法someCallWithIfTest聲明的註解表示任意方法,此方法返回值爲boolean,參數簽名爲僅一個int類型的參數,後面跟上if條件,表示此int參數值大於0,且方法簽名以setAge開頭。

如此一來切面代碼的執行就具有了動態性,但不是說不知足if條件的切點就不會織入代碼。依然會織入,只是在調用織入代碼前會執行someCallWithIfTest方法,當返回值爲true時纔會執行織入代碼,下圖是反編譯class的結果。

if條件.png

瞭解了原理後,實際上if邏輯也徹底能夠放到織入點代碼中,理解起來會更容易一些。

Advice

直譯過來是通知,實際上表示一類代碼織入位置,在AspectJ中有五種類型的註解:Before、After、AfterReturning、AfterThrowing、Around,咱們將它們統稱爲Advice註解。

Advice 說明
@Before 切入點前織入
@After 切入點後織入,不管鏈接點執行如何,包括正常的 return 和 throw 異常
@AfterReturning 只有在切入點正常返回以後纔會執行,不指定返回類型時匹配全部類型
@AfterThrowing 只有在切入點拋出異常後才執行,不指定異常類型時匹配全部類型
@Around 替代原有切點,若是要執行原來代碼的話,調用 ProceedingJoinPoint.proceed()

Advice註解修飾的方法有一些約束:

  1. 方法必須爲public。
  2. Before、After、AfterReturning、AfterThrowing 四種類型方法返回值必須爲void。
  3. Around的目標是替代原切入點,它通常會有返回值,這就要求聲明的返回值類型必須與切入點方法的返回值保持一致;不能和其餘 Advice 一塊兒使用,若是在對一個 Pointcut 聲明 Around 以後還聲明 Before 或者 After 則會失效
  4. 方法簽名能夠額外聲明JoinPoint、JoinPointStaticPart、JoinPoint.EnclosingStaticPart。

JoinPoint、JoinPointStaticPart、JoinPoint.EnclosingStaticPart又是什麼呢?

在執行切面代碼時,AspectJ會將鏈接點處的上下文信息封裝成JoinPoint供咱們使用。這些信息中有些是在編譯階段就能夠肯定的,好比方法簽名 joinPoint.getSignature(),JoinPoint類型 joinPoint.getKind(),切點代碼位置類名+行數joinPoint.getSourceLocation() 等等,咱們將他們統稱爲JoinPointStaticPart。

而還有一些是在運行時才能肯定的,好比前文提到的this、target、實參等等。

  • JoinPoint 包含鏈接點處的靜態信息+動態信息。
  • JoinPointStaticPart 鏈接點處的靜態信息。
  • EnclosingStaticPart 包含了鏈接點的靜態信息,也就是鏈接點的上下文。

若是不須要動態信息,建議使用靜態類型的參數,以提升性能。

講了這麼多理論,看起來比較複雜,實際上咱們平常開發中的場景要相對簡單一些。

經常使用示例

  1. 爲全部點擊事件埋點
@Aspect
public class MethodAspect5 {
    @Pointcut("execution(* android.view.View.OnClickListener+.onClick(..))")
    public void callMethod() {
    }

    @Before("callMethod()")
    public void beforeMethodCall(JoinPoint joinPoint) {
        Log.e(TAG, "埋點");
    }
}
複製代碼

android.view.View.OnClickListener+表示OnClickListener及其子類。

  1. 偷天換日,MethodAspect3使用Around類型的Advice,將調用run方法前將實參除以10後執行。
@Aspect
public class MethodAspect3 {

    @Pointcut("execution(* com.wandering.sample.aspectj.Animal.run(..))")
    public void callMethod() {
    }

    @Around("callMethod()")
    public void aroundMethodCall(ProceedingJoinPoint joinPoint) {
        //獲取鏈接點參數列表
        Object[] args = joinPoint.getArgs();
        int params = 0;
        for (Object arg : args) {
            params = (int) arg / 10;
        }
        try {
            //改變參數 執行鏈接點代碼
            joinPoint.proceed(new Object[]{params});值
        } catch (Throwable throwable) {
            throwable.printStackTrace();
        }
    }
}
複製代碼

Around方法聲明ProceedingJoinPoint類型而不是JoinPoint,可使用其proceed方法調用鏈接點代碼。

AspectJ現存的問題

重複織入、不織入

假如咱們想對Activity生命週期織入埋點統計,咱們可能寫出這樣的切點代碼。

@Pointcut("execution(* android.app.Activity+.on*(..))")
public void callMethod() {}
複製代碼

因爲Activity.class不參與打包(android.jar位於android設備內),參與打包是那些支持庫好比support-v7中的AppCompatActivity,還有項目裏定義的Activity,這就致使:

  1. 若是咱們業務Activity中若是沒有複寫生命週期方法將不會織入。
  2. 若是咱們的Activity繼承樹上若是都複寫了生命週期方法,那麼繼承樹上的全部Activity都會織入統計代碼,這會致使重複統計。

解決辦法是項目內定義一個基類Activity(好比BaseActivity),而後複寫全部生命週期方法,而後將切點代碼精確到這個BaseActivity。

@Pointcut("execution(* com.xxx.BaseActivity.on*(..))")
public void callMethod() {}
複製代碼

但若是真這樣作的話,你確定會反問還須要AspectJ作什麼,攤手.jpg。

不支持Lambda表達式

Lambda表達式是Java8的語法糖,在編譯期會執行脫糖(desugar),脫糖後將Lambda表達式換成內部類實現。筆者尚不清楚AspectJ失效的緣由,多是脫糖發生在Ajx Transform以後,致使找不到鏈接點方法。

出問題難排查

這是AOP技術的實現方式決定的,修改字節碼過程,對上層應用無感知,容易將問題隱藏,排查難度大。所以若是項目中使用了AOP技術應當完善文檔,並知會協同開發人員。

編譯時間變長

Transform過程,會遍歷全部class文件,查找符合需求的切入點,而後插入字節碼。若是項目較大且織入代碼較多,會增長十幾秒左右的編譯時間。

如前文提到的,有兩種辦法解決這個問題:

  1. 使用exclude過濾掉不須要執行織入的包名。
  2. 若是織入代碼在debug環境不須要織入,好比埋點,則使用enabled false 關閉AspectJ功能。

兼容性

若是使用的三方庫也使用了AspectJ,可能致使未知的風險。

好比sample項目中同時使用Hugo,會致使工程中的class不會被打入APK中,運行時會出現ClassNotFoundException。這多是Hugo項目編寫的Plugin插件與Hujiang的AspectJX插件有衝突致使的。

一寫就收不住了,因爲篇幅限制,關於AspectJ的原理和Hugo項目的介紹,將獨立成篇,實戰Android AspectJ之Hugo

參考文章

  1. AspectJ in Android 系列
  2. AOP 之 AspectJ 全面剖析 in Android
  3. Android 使用 Aspectj 限制快速點擊
  4. AspectJ In Android Studio
  5. AOP技術學習之AspectJ
相關文章
相關標籤/搜索