AOP是一個老生常談的話題,全稱"Aspect Oriented Programming",表示面向切面編程。因爲面向對象的編程思想推崇高內聚、低耦合的架構風格,使得模塊間代碼的可見性變差,這使得實現下面的需求變得十分複雜:統計埋點、日誌輸出、權限攔截等等,若是手動編碼,代碼侵入性過高且不利於擴展,AOP技術應運而生。html
AOP中的切面比較形象,各個業務模塊就像平鋪在一個容器中,假如如今須要給各個模塊添加點擊事件埋點,AOP就像給全部業務模塊間插入一個虛擬的切面,後續全部的點擊事件經過這個切面時,咱們有機會作一些額外的事情。java
之因此說是虛擬,是由於整個過程對具體的業務場景是非侵入性的,業務代碼不用改動,新增的代碼邏輯也不須要作額外的適配。這個過程有點像OkHttp的攔截器,或者能夠說攔截器是面向切面的一個具體實現。android
本文是對AspectJ的使用介紹,經過這個工具,咱們能夠輕鬆的實現一些簡單的AOP需求,而不須要懂像編譯原理,字節碼結構等相對複雜的底層技術。git
在Android平臺,經常使用的是hujiang的一個aspectjx插件,它的工做原理是:經過Gradle Transform,在class文件生成後至dex文件生成前,遍歷並匹配全部符合AspectJ文件中聲明的切點,而後將事先聲明好的代碼在切點先後織入。github
經過描述可知,整個過程發生在編譯期,是一種靜態織入方式,因此會增長必定的編譯時長,但幾乎不會影響程序的運行時效率。express
本文大體分爲三個部分。編程
一般來講,AOP都是爲一些相對基礎且固定的需求服務,實際常見的場景大體包括:bash
若是你在項目中也有這樣的需求(幾乎必定有),能夠考慮經過AspectJ來實現。架構
除了織入代碼,AspectJ還能爲類增長實現接口、添加成員變量,固然這不是本文的重點,感興趣的小夥伴能夠在學習完基礎知識後瞭解相關內容。app
在Android平臺,咱們一般使用上文提到的Aspectjx插件來配置AspectJ環境,具體使用是經過AspectJ註解完成。
dependencies {
classpath 'com.hujiang.aspectjx:gradle-android-plugin-aspectjx:2.0.4'
}
複製代碼
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註解編寫切面代碼。
這麼說你可能有點蒙,咱們換個角度解釋。
假設你是一個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反編譯看一下織入結果。
紅色框選部分就是AspectJ爲咱們織入的代碼。
經過上面的例子咱們瞭解了AspectJ的基本用法,但實際上AspectJ的語法能夠十分複雜,下面咱們來看看具體的語法。
上面的例子中少講了一個鏈接點的概念,鏈接點表示可織入代碼的點,它屬於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被織入到指定的方法內部。
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和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() 時。
一般狀況下,Pointcuts註解的方法參數列表爲空,返回值爲void,方法體也爲空。可是若是表達式中聲明瞭:
來看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邏輯也徹底能夠放到織入點代碼中,理解起來會更容易一些。
直譯過來是通知,實際上表示一類代碼織入位置,在AspectJ中有五種類型的註解:Before、After、AfterReturning、AfterThrowing、Around,咱們將它們統稱爲Advice註解。
Advice | 說明 |
---|---|
@Before | 切入點前織入 |
@After | 切入點後織入,不管鏈接點執行如何,包括正常的 return 和 throw 異常 |
@AfterReturning | 只有在切入點正常返回以後纔會執行,不指定返回類型時匹配全部類型 |
@AfterThrowing | 只有在切入點拋出異常後才執行,不指定異常類型時匹配全部類型 |
@Around | 替代原有切點,若是要執行原來代碼的話,調用 ProceedingJoinPoint.proceed() |
Advice註解修飾的方法有一些約束:
JoinPoint、JoinPointStaticPart、JoinPoint.EnclosingStaticPart又是什麼呢?
在執行切面代碼時,AspectJ會將鏈接點處的上下文信息封裝成JoinPoint供咱們使用。這些信息中有些是在編譯階段就能夠肯定的,好比方法簽名 joinPoint.getSignature(),JoinPoint類型 joinPoint.getKind(),切點代碼位置類名+行數joinPoint.getSourceLocation() 等等,咱們將他們統稱爲JoinPointStaticPart。
而還有一些是在運行時才能肯定的,好比前文提到的this、target、實參等等。
若是不須要動態信息,建議使用靜態類型的參數,以提升性能。
講了這麼多理論,看起來比較複雜,實際上咱們平常開發中的場景要相對簡單一些。
@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及其子類。
@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方法調用鏈接點代碼。
假如咱們想對Activity生命週期織入埋點統計,咱們可能寫出這樣的切點代碼。
@Pointcut("execution(* android.app.Activity+.on*(..))")
public void callMethod() {}
複製代碼
因爲Activity.class不參與打包(android.jar位於android設備內),參與打包是那些支持庫好比support-v7中的AppCompatActivity,還有項目裏定義的Activity,這就致使:
解決辦法是項目內定義一個基類Activity(好比BaseActivity),而後複寫全部生命週期方法,而後將切點代碼精確到這個BaseActivity。
@Pointcut("execution(* com.xxx.BaseActivity.on*(..))")
public void callMethod() {}
複製代碼
但若是真這樣作的話,你確定會反問還須要AspectJ作什麼,攤手.jpg。
Lambda表達式是Java8的語法糖,在編譯期會執行脫糖(desugar),脫糖後將Lambda表達式換成內部類實現。筆者尚不清楚AspectJ失效的緣由,多是脫糖發生在Ajx Transform以後,致使找不到鏈接點方法。
這是AOP技術的實現方式決定的,修改字節碼過程,對上層應用無感知,容易將問題隱藏,排查難度大。所以若是項目中使用了AOP技術應當完善文檔,並知會協同開發人員。
Transform過程,會遍歷全部class文件,查找符合需求的切入點,而後插入字節碼。若是項目較大且織入代碼較多,會增長十幾秒左右的編譯時間。
如前文提到的,有兩種辦法解決這個問題:
若是使用的三方庫也使用了AspectJ,可能致使未知的風險。
好比sample項目中同時使用Hugo,會致使工程中的class不會被打入APK中,運行時會出現ClassNotFoundException。這多是Hugo項目編寫的Plugin插件與Hujiang的AspectJX插件有衝突致使的。
一寫就收不住了,因爲篇幅限制,關於AspectJ的原理和Hugo項目的介紹,將獨立成篇,實戰Android AspectJ之Hugo。