Android AspectJ詳解

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

AOP

AOP中的切面比較形象,各個業務模塊就像平鋪在一個容器中,假如現在需要給各個模塊添加點擊事件埋點,AOP就像給所有業務模塊間插入一個虛擬的切面,后續所有的點擊事件通過這個切面時,我們有機會做一些額外的事情。

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

本文是對AspectJ的使用介紹,通過這個工具,我們可以輕松的實現一些簡單的AOP需求,而不需要懂像編譯原理,字節碼結構等相對復雜的底層技術。

在Android平臺,常用的是hujiang的一個aspectjx插件,它的工作原理是:通過Gradle Transform,在class文件生成后至dex文件生成前,遍歷并匹配所有符合AspectJ文件中聲明的切點,然后將事先聲明好的代碼在切點前后織入。

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


本文大致分為三個部分。

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

AspectJ能做什么?

通常來說,AOP都是為一些相對基礎且固定的需求服務,實際常見的場景大致包括:

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

如果你在項目中也有這樣的需求(幾乎一定有),可以考慮通過AspectJ來實現。

除了織入代碼,AspectJ還能為類增加實現接口、添加成員變量,當然這不是本文的重點,感興趣的小伙伴可以在學習完基礎知識后了解相關內容。

環境配置

在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
  6. 關于AspectJ,你需要知道的一切
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,797評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,179評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,628評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,642評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,444評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,948評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,040評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,185評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,717評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,602評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,794評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,316評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,045評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,418評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,671評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,414評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,750評論 2 370

推薦閱讀更多精彩內容

  • 本章內容: 面向切面編程的基本原理 通過POJO創建切面 使用@AspectJ注解 為AspectJ切面注入依賴 ...
    謝隨安閱讀 3,175評論 0 9
  • **** AOP 面向切面編程 底層原理 代理!!! 今天AOP課程1、 Spring 傳統 AOP2、 Spri...
    luweicheng24閱讀 1,377評論 0 1
  • 一、簡述 1、AOP的概念 如果你用java做過后臺開發,那么你一定知道AOP這個概念。如果不知道也無妨,套用百度...
    GitLqr閱讀 4,223評論 6 25
  • AOP實現可分為兩類(按AOP框架修改源代碼的時機): 靜態AOP實現:AOP框架在編譯階段對程序進行修改,即實現...
    數獨題閱讀 2,328評論 0 22
  • 在我們中國社會,“面子”可真是個比西方圣誕老人還老的面孔。它雖然這么“老”,卻是常用常新的,是伴隨著人一出生就接觸...
    劉星洪閱讀 498評論 0 1