AOP是一個老生常談的話題,全稱"Aspect Oriented Programming",表示面向切面編程。由于面向對象的編程思想推崇高內聚、低耦合的架構風格,使得模塊間代碼的可見性變差,這使得實現下面的需求變得十分復雜:統計埋點、日志輸出、權限攔截等等,如果手動編碼,代碼侵入性太高且不利于擴展,AOP技術應運而生。
AOP
AOP中的切面比較形象,各個業務模塊就像平鋪在一個容器中,假如現在需要給各個模塊添加點擊事件埋點,AOP就像給所有業務模塊間插入一個虛擬的切面,后續所有的點擊事件通過這個切面時,我們有機會做一些額外的事情。
之所以說是虛擬,是因為整個過程對具體的業務場景是非侵入性的,業務代碼不用改動,新增的代碼邏輯也不需要做額外的適配。這個過程有點像OkHttp的攔截器,或者可以說攔截器是面向切面的一個具體實現。
本文是對AspectJ的使用介紹,通過這個工具,我們可以輕松的實現一些簡單的AOP需求,而不需要懂像編譯原理,字節碼結構等相對復雜的底層技術。
在Android平臺,常用的是hujiang的一個aspectjx插件,它的工作原理是:通過Gradle Transform,在class文件生成后至dex文件生成前,遍歷并匹配所有符合AspectJ文件中聲明的切點,然后將事先聲明好的代碼在切點前后織入。
通過描述可知,整個過程發生在編譯期,是一種靜態織入方式,所以會增加一定的編譯時長,但幾乎不會影響程序的運行時效率。
本文大致分為三個部分。
- AspectJ的語法和使用。
- 通過Jake Wharton大神的開源項目Hugo,實戰AspectJ。
- AspectJ面臨的問題。
AspectJ能做什么?
通常來說,AOP都是為一些相對基礎且固定的需求服務,實際常見的場景大致包括:
- 統計埋點
- 日志打印/打點
- 數據校驗
- 行為攔截
- 性能監控
- 動態權限控制
如果你在項目中也有這樣的需求(幾乎一定有),可以考慮通過AspectJ來實現。
除了織入代碼,AspectJ還能為類增加實現接口、添加成員變量,當然這不是本文的重點,感興趣的小伙伴可以在學習完基礎知識后了解相關內容。
環境配置
在Android平臺,我們通常使用上文提到的Aspectjx插件來配置AspectJ環境,具體使用是通過AspectJ注解完成。
- 在項目根目錄的build.gradle里依賴AspectJX
dependencies {
classpath 'com.hujiang.aspectjx:gradle-android-plugin-aspectjx:2.0.4'
}
- 在需要支持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反編譯看一下織入結果。
紅色框選部分就是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邏輯也完全可以放到織入點代碼中,理解起來會更容易一些。
Advice
直譯過來是通知,實際上表示一類代碼織入位置,在AspectJ中有五種類型的注解:Before、After、AfterReturning、AfterThrowing、Around,我們將它們統稱為Advice注解。
Advice | 說明 |
---|---|
@Before | 切入點前織入 |
@After | 切入點后織入,無論連接點執行如何,包括正常的 return 和 throw 異常 |
@AfterReturning | 只有在切入點正常返回之后才會執行,不指定返回類型時匹配所有類型 |
@AfterThrowing | 只有在切入點拋出異常后才執行,不指定異常類型時匹配所有類型 |
@Around | 替代原有切點,如果要執行原來代碼的話,調用 ProceedingJoinPoint.proceed() |
Advice注解修飾的方法有一些約束:
- 方法必須為public。
- Before、After、AfterReturning、AfterThrowing 四種類型方法返回值必須為void。
- Around的目標是替代原切入點,它一般會有返回值,這就要求聲明的返回值類型必須與切入點方法的返回值保持一致;不能和其他 Advice 一起使用,如果在對一個 Pointcut 聲明 Around 之后還聲明 Before 或者 After 則會失效。
- 方法簽名可以額外聲明JoinPoint、JoinPointStaticPart、JoinPoint.EnclosingStaticPart。
JoinPoint、JoinPointStaticPart、JoinPoint.EnclosingStaticPart又是什么呢?
在執行切面代碼時,AspectJ會將連接點處的上下文信息封裝成JoinPoint供我們使用。這些信息中有些是在編譯階段就可以確定的,比如方法簽名 joinPoint.getSignature(),JoinPoint類型 joinPoint.getKind(),切點代碼位置類名+行數joinPoint.getSourceLocation() 等等,我們將他們統稱為JoinPointStaticPart。
而還有一些是在運行時才能確定的,比如前文提到的this、target、實參等等。
- JoinPoint 包含連接點處的靜態信息+動態信息。
- JoinPointStaticPart 連接點處的靜態信息。
- EnclosingStaticPart 包含了連接點的靜態信息,也就是連接點的上下文。
如果不需要動態信息,建議使用靜態類型的參數,以提高性能。
講了這么多理論,看起來比較復雜,實際上我們日常開發中的場景要相對簡單一些。
常用示例
- 為所有點擊事件埋點
@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及其子類。
- 偷天換日,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,這就導致:
- 如果我們業務Activity中如果沒有復寫生命周期方法將不會織入。
- 如果我們的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文件,查找符合需求的切入點,然后插入字節碼。如果項目較大且織入代碼較多,會增加十幾秒左右的編譯時間。
如前文提到的,有兩種辦法解決這個問題:
- 使用exclude過濾掉不需要執行織入的包名。
- 如果織入代碼在debug環境不需要織入,比如埋點,則使用enabled false 關閉AspectJ功能。
兼容性
如果使用的三方庫也使用了AspectJ,可能導致未知的風險。
比如sample項目中同時使用Hugo,會導致工程中的class不會被打入APK中,運行時會出現ClassNotFoundException。這可能是Hugo項目編寫的Plugin插件與Hujiang的AspectJX插件有沖突導致的。
一寫就收不住了,由于篇幅限制,關于AspectJ的原理和Hugo項目的介紹,將獨立成篇,實戰Android AspectJ之Hugo。