在Android項目中使用AspectJ的詳細攻詻
AOP
全稱“Aspect Oriented Programming”,面向切面編程,由於面向對象的思想要求高內聚,低耦合的風格,使模塊代碼間的可見性變差,對於埋點,日志輸出等需求,就會變的十分復雜,如果手動編寫代碼,入侵性很大,不利於擴展,AOP應運而生。
AspectJ
AspectJ實際上是對AOP編程的實踐,目前還有很多的AOP實現,如ASMDex,但筆者選用的是AspectJ。
使用場景
當我們需要在某個方法運行前和運行後做一些處理時,便可使用AOP技術。具體有:
- 統計埋點
- 日志打印/打點
- 數據校驗
- 行為攔截
- 性能監控
- 動態權限控制
AOP(aspect-oriented programming),指的是面向切面編程。而AspectJ是實現AOP的其中一款框架,內部通過處理字節碼實現代碼註入。
AspectJ從2001年發展至今,已經非常成熟穩定,同時使用簡單是它的一大優點。至於它的使用場景,可以看本文中的一些小例子,獲取能給你啟發。
1.集成AspectJ
使用插件gradle-android-aspectj-plugin
這種方式接入簡單。但是此插件截止目前已經一年多沒有維護瞭,考慮到AGP的兼容性,害怕以後無法使用。這裡就不推薦瞭。(這裡存在特殊情況,文章後面會提到。)
常規的Gradle 配置方式
這種方法相對配置會多一些,但相對可控。
首先在項目根目錄的build.gradle中添加:
classpath "com.android.tools.build:gradle:4.2.1" classpath 'org.aspectj:aspectjtools:1.9.6'
然後在app的build.gradle中添加:
dependencies { ... implementation 'org.aspectj:aspectjrt:1.9.6' } import org.aspectj.bridge.IMessage import org.aspectj.bridge.MessageHandler import org.aspectj.tools.ajc.Main final def log = project.logger final def variants = project.android.applicationVariants variants.all { variant -> // 註意這裡控制debug下生效,可以自行控制是否生效 if (!variant.buildType.isDebuggable()) { log.debug("Skipping non-debuggable build type '${variant.buildType.name}'.") return } JavaCompile javaCompile = variant.javaCompileProvider.get() 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 } } } }
在 module 使用的話一樣需要添加配置代碼(略有不同):
dependencies { ... implementation 'org.aspectj:aspectjrt:1.9.6' } import org.aspectj.bridge.IMessage import org.aspectj.bridge.MessageHandler import org.aspectj.tools.ajc.Main final def log = project.logger android.libraryVariants.all{ variant -> if (!variant.buildType.isDebuggable()) { log.debug("Skipping non-debuggable build type '${variant.buildType.name}'.") return } JavaCompile javaCompile = variant.javaCompileProvider.get() 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 } } } }
2.AspectJ基礎語法
Join Points
連接點,用來連接我們需要操作的位置。比如連接普通方法、構造方法還是靜態初始化塊等位置,以及是調用方法外部還是調用方法內部。常用類型有Method call
、Method execution
、Constructor call
、Constructor execution
等。
Pointcuts
切入點,是帶條件的Join Points,確定切入點位置。
Pointcuts語法 | 說明 |
---|---|
execution(MethodPattern) | 方法執行 |
call(MethodPattern) | 方法被調用 |
execution(ConstructorPattern) | 構造方法執行 |
call(ConstructorPattern) | 構造方法被調用 |
get(FieldPattern) | 讀取屬性 |
set(FieldPattern) | 設置屬性 |
staticinitialization(TypePattern) | static 塊初始化 |
handler(TypePattern) | 異常處理 |
execution和call的區別如下圖:
Pattern規則如下:
Pattern | 規則(註意空格) |
---|---|
MethodPattern | [@註解] [訪問權限] 返回值類型 [類名.]方法名(參數) [throws 異常類型] |
ConstructorPattern | [@註解] [訪問權限] [類名.]new(參數) [throws 異常類型] |
FieldPattern | [@註解] [訪問權限] 變量類型 [類名.]變量名 |
TypePattern | * 單獨使用事表示匹配任意類型,.. 匹配任意字符串,.. 單獨使用時表示匹配任意長度任意類型,+ 匹配其自身及子類,還有一個 ... 表示不定個數。也可以使用&&,||,! 進行邏輯運算。 |
- 上表中中括號為可選項,沒有可以不寫
- 方法匹配例子:
java.*.Date:可以表示java.sql.Date,也可以表示java.util.Date Test*:可以表示TestBase,也可以表示TestDervied java..*:表示java任意子類 java..*Model+:表示Java任意package中名字以Model結尾的子類,比如TabelModel,TreeModel 等
參數匹配例子:
(int, char):表示參數隻有兩個,並且第一個參數類型是int,第二個參數類型是char (String, ..):表示至少有一個參數。並且第一個參數類型是String,後面參數類型不限. ..代表任意參數個數和類型 (Object ...):表示不定個數的參數,且類型都是Object,這裡的...不是通配符,而是Java中代表不定參數的意思
Advice
用來指定代碼插入到Pointcuts的什麼位置。
Advice | 說明 |
---|---|
@Before | 在執行JPoint之前 |
@After | 在執行JPoint之後 |
@AfterReturning | 方法執行後,返回結果後再執行。 |
@AfterThrowing | 處理未處理的異常。 |
@Around | 可以替換原代碼。如果需要執行原代碼,可以使用ProceedingJoinPoint#proceed()。 |
After、Before 示例
這裡我們實現一個功能,在所有Activity的onCreate方法中添加Trace方法,來統計onCreate方法耗時。
@Aspect // <-註意添加,才會生效參與編譯 public class TraceTagAspectj { @Before("execution(* android.app.Activity+.onCreate(..))") public void before(JoinPoint joinPoint) { Trace.beginSection(joinPoint.getSignature().toString()); } @After("execution(* android.app.Activity+.onCreate(..))") public void after() { Trace.endSection(); } }
編譯後的class代碼如下:
可以看到經過處理後,它並不會直接把 Trace 函數直接插入到代碼中,而是經過一系列自己的封裝。如果想針對所有的函數都做插樁,AspectJ 會帶來不少的性能影響。
不過大部分情況,我們可能隻會插樁某一小部分函數,這樣 AspectJ 帶來的性能影響就可以忽略不計瞭。
AfterReturning示例
獲取切點的返回值,比如這裡我們獲取TextView,打印它的text值。
private TextView testAfterReturning() { return findViewById(R.id.tv); }
@Aspect public class TextViewAspectj { @AfterReturning(pointcut = "execution(* *..*.testAfterReturning())", returning = "textView") // "textView"必須和下面參數名稱一樣 public void getTextView(TextView textView) { Log.d("weilu", "text--->" + textView.getText().toString()); } }
編譯後的class代碼如下:
log打印:
使用@AfterReturning
你可以對方法的返回結果做一些修改(註意是“=”賦值,String無法通過此方法修改)。
AfterThrowing示例
當方法執行出現異常,且異常沒有處理時,可以使用@AfterThrowing
。比如下面的例子中,我們捕獲異常並上報(這裡用log輸出實現)
public class MainActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); testAfterThrowing(); } private void testAfterThrowing() { TextView textView = null; textView.setText("aspectj"); } }
@Aspect public class ReportExceptionAspectj { @AfterThrowing(pointcut = "call(* *..*.testAfterThrowing())", throwing = "throwable") // "throwable"必須和下面參數名稱一樣 public void reportException(Throwable throwable) { Log.e("weilu", "throwable--->" + throwable); } }
編譯後的class代碼如下:
log打印:
這裡要註意的是,程序最終還是會崩潰,因為最後執行瞭throw var3
。如果你想不崩潰,可以使用@Around。
Around示例
接著上面的例子,我們這次直接try catch住異常代碼:
@Aspect public class TryCatchAspectj { @Pointcut("execution(* *..*.testAround())") public void methodTryCatch() { } @Around("methodTryCatch()") public void aroundTryJoinPoint(ProceedingJoinPoint joinPoint) throws Throwable { try { joinPoint.proceed(); // <- 調用原代碼 } catch (Exception e) { e.printStackTrace(); } } }
編譯後的class代碼如下:
@Around
明顯更加靈活,我們可以自定義,實現”偷梁換柱”的效果,比如上面提到的替換方法的返回值。
3.進階
withincode
withincode
表示某個方法執行過程中涉及到的JPoint,通常用來過濾切點。例如我們有一個Person對象:
public class Person { private String name; private int age; public Person() { this.name = "weilu"; this.age = 18; } public String getName() { return name; } public void setName(String name) { this.name = name; } public int getAge() { return age; } public void setAge(int age) { this.age = age; } }
Person
對象中有兩處set age的地方,如果我們隻想讓構造方法的生效,讓setAge
方法失效,可以使用@Around("execution(* com.weilu.aspectj.demo.Person.setAge(..))")
不過如果有更多處set age的地方,我們這樣一個個去匹配就很麻煩。
這裡就可以考慮使用set
這個Pointcuts:
public class FieldAspectJ { @Around("set(int com.weilu.aspectj.demo.Person.age)") public void aroundFieldSet(ProceedingJoinPoint joinPoint) throws Throwable { Log.e("weilu", "around->" + joinPoint.getTarget().toString() + "#" + joinPoint.getSignature().getName()); } }
由於set(FieldPattern)
的FieldPattern限制,不能指定參數,這樣會將所有的set age都切入:
這時就可以使用withincode
添加過濾條件:
@Aspect public class FieldAspectJ { @Pointcut("!withincode(com.weilu.aspectj.demo.Person.new())") public void invokePerson() { } @Around("set(int com.weilu.aspectj.demo.Person.age) && invokePerson()") public void aroundFieldSet(ProceedingJoinPoint joinPoint) throws Throwable { Log.e("weilu", "around->" + joinPoint.getTarget().toString() + "#" + joinPoint.getSignature().getName()); } }
結果如下:
還有一個within
,它和withincode
類似。不同的是,它的范圍是類,而withincode
是方法。例如:within(com.weilu.activity.*)
表示此包下任意的JPoint。
args
用來指定當前執行方法的參數條件。比如上一個例子中,如果需要指定第一個參數是int,後面參數不限。就可以這樣寫。
@Around("execution(* com.weilu.aspectj.withincode.Person.setAge(..)) && args(int,..)")
cflow
cflow是call flow的意思,cflow的條件是一個
pointcut
舉一個例子來說明一下它的用途,a方法中調用瞭b、c、d方法。此時要統計各個方法的耗時,如果按之前掌握的語法,我們最多需要寫四個Pointcut,方法越多越麻煩。
使用cflow,我們可以方便的掌握方法的“調用流”。我們測試方法如下:
private void test() { testAfterReturning(); testAround(); testWithInCode(); }
實現如下:
@Aspect public class TimingAspect { @Around("execution(* *(..)) && cflow(execution(* com.weilu.aspectj.demo.MainActivity.test(..)))") public Object measureExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable { long startTime = currentTimeMillis(); Object result = joinPoint.proceed(); long endTime = currentTimeMillis(); Log.e("weilu", joinPoint.getSignature().toString() + " -> " + (endTime - startTime) + " ms"); return result; } }
cflow(execution(* com.weilu.aspectj.demo.MainActivity.test(..)))
表示調用test方法時所包含的JPoint,包括自身JPoint。
execution(* *(..))
的作用是去除TimingAspect
自身的代碼,避免自己攔截自己,形成死循環。
log結果如下:
還有一個cflowbelow
,它和cflow
類似。不同的是,它不包括自身JPoint。也就是例子中不會獲取test方法的耗時。
4.實戰 攔截點擊
攔截點擊的目的是避免因快速點擊控件,導致重復執行點擊事件。例如打開多次頁面,彈出多次彈框,請求多次接口,我之前發現在部分機型上,很容易復現此類情況。所以避免抖動這算是項目中的一個常見需求。
例如butterknife
中就自帶DebouncingOnClickListener
來避免此類問題。
如果你已不在使用butterknife
,也可以復制這段代碼。一個個的替換已有的View.OnClickListener
。還有以前使用Rxjava操作符來處理防抖。但這些方式侵入式大且替換的工作量也大。
這種場景就可以考慮AOP的方式處理。攔截onClick方法,判斷是否可以點擊。
@Aspect public class InterceptClickAspectJ { // 最後一次點擊的時間 private Long lastTime = 0L; // 點擊間隔時長 private static final Long INTERVAL = 300L; @Around("execution(* android.view.View.OnClickListener.onClick(..))") public void clickIntercept(ProceedingJoinPoint joinPoint) throws Throwable { // 大於間隔時間可點擊 if (System.currentTimeMillis() - lastTime >= INTERVAL) { // 記錄點擊時間 lastTime = System.currentTimeMillis(); // 執行點擊事件 joinPoint.proceed(); } else { Log.e("weilu", "重復點擊"); } } }
實現代碼很簡單,效果如下:
考慮到有些view的點擊事件不需要防抖,例如checkBox。否則checkBox狀態變瞭,但事件沒有執行。我們可以定義一個註解,用withincode
過濾有此註解的方法。具體需求可以根據實際項目自行拓展,這裡僅提供思路。
埋點
前面的例子中都是無侵入的方式使用AspectJ。這裡說一下侵入式的方式,簡單說就是使用自定義註解,用註解作為切入點的規則。(其實也可以自定義一種方法命名,來當做切入規則)
首先定義兩個註解,一個用來傳固定參數比如eventName、eventId,同時負責當做切入點,一個用來傳動態參數的key。
@Retention(RetentionPolicy.RUNTIME) public @interface TrackEvent { /** * 事件名稱 */ String eventName() default ""; /** * 事件id */ String eventId() default ""; } @Target(ElementType.PARAMETER) @Retention(RetentionPolicy.RUNTIME) public @interface TrackParameter { String value() default ""; }
Aspectj代碼如下:
@Aspect public class TrackEventAspectj { @Around("execution(@com.weilu.aspectj.tracking.TrackEvent * *(..))") public void trackEvent(ProceedingJoinPoint joinPoint) throws Throwable { MethodSignature signature = (MethodSignature) joinPoint.getSignature(); // 獲取方法上的註解 TrackEvent trackEvent = signature.getMethod().getAnnotation(TrackEvent.class); String eventName = trackEvent.eventName(); String eventId = trackEvent.eventId(); JSONObject params = new JSONObject(); params.put("eventName", eventName); params.put("eventId", eventId); // 獲取方法參數的註解 Annotation[][] parameterAnnotations = signature.getMethod().getParameterAnnotations(); if (parameterAnnotations.length != 0) { int i = 0; for (Annotation[] parameterAnnotation : parameterAnnotations) { for (Annotation annotation : parameterAnnotation) { if (annotation instanceof TrackParameter) { // 獲取key value String key = ((TrackParameter) annotation).value(); params.put(key, joinPoint.getArgs()[i++]); } } } } // 上報 Log.e("weilu", "上報數據---->" + params.toString()); try { joinPoint.proceed(); } catch (Throwable throwable) { throwable.printStackTrace(); } } }
使用方法:
@TrackEvent(eventName = "點擊按鈕", eventId = "100") private void trackMethod(@TrackParameter("uid") int uid, String name) { Intent intent = new Intent(this, KotlinActivity.class); intent.putExtra("uid", uid); intent.putExtra("name", name); startActivity(intent); } trackMethod(10, "weilu");
結果如下:
由於匹配key value的代碼問題,建議將需要動態傳入的參數都寫在前面,避免下標越界。
還有一些使用場景,比如權限控制。總結一下,AOP適合將一些通用邏輯分離出來,然後通過AOP將此部分註入到業務代碼中。這樣我們可以更加註重業務的實現,代碼也顯得清晰起來。
5.其他問題 lambda
如果我們代碼中有使用lambda,例如點擊事件會變為:
tv.setOnClickListener(v -> Log.e("weilu", "點擊事件執行"));
這樣之前的點擊切入點就無效瞭,這裡涉及到D8這個脫糖工具和invokedynamic字節碼指令相關知識,這裡我也無法說的清楚詳細。簡單說使用lambda會生成lambda$
開頭的中間方法,所以隻能如下處理:
@Around("execution(* *..lambda$*(android.view.View))")
這種暫時處理起來比較麻煩,且可以看出容錯率也比較低,很容易切入其他無關方法,所以建議AOP不要使用lambda。
配置
一開始介紹瞭兩種配置,雖說AspectJX插件最近不太維護瞭,但是它的支持瞭AAR、JAR及Kotlin的切入,而默認僅是對自己的代碼進行切入。
在AspectJ常規配置中有這樣的代碼:”-inpath”, javaCompile.destinationDir.toString(),代表隻對源文件進行織入。在查看Aspectjx源碼時,發現在“-inputs”配置加入瞭.jar文件,使得class類可以被織入代碼。這麼理解來看,AspectJ也是支持對class文件的織入的,隻是需要對它進行相關的配置,而配置比較繁瑣,所以誕生瞭AspectJx等插件。
例如Kotlin在需要在常規的Gradle 配置上增加如下配置:
def buildType = variant.buildType.name String[] kotlinArgs = [ "-showWeaveInfo", "-1.8", "-inpath", project.buildDir.path + "/tmp/kotlin-classes/" + buildType, "-aspectpath", javaCompile.classpath.asPath, "-d", project.buildDir.path + "/tmp/kotlin-classes/" + buildType, "-classpath", javaCompile.classpath.asPath, "-bootclasspath", project.android.bootClasspath.join(File.pathSeparator)] MessageHandler handler = new MessageHandler(true) new Main().run(kotlinArgs, handler)
同時註意用kotlin寫對應的Aspect類,畢竟你需要註入的是kotlin代碼,用java的肯定不行,但是反過來卻可行。
建議有AAR、JAR及Kotlin需求的使用插件方式,即使後期無人維護,可自行修改源碼適配GAP,相對難度不大。
這部分內容較多同時也比較枯燥,斷斷續續整理瞭一周的時間。基本介紹瞭AspectJ在Android 中的配置,以及常用的語法與使用場景。對於應用AspectJ來說夠用瞭。
最後本篇涉及的代碼都已上傳至Github,有興趣的同學可以用做參考。
參考
AOP之AspectJ在Android中的應用
AOP 之 AspectJ 全面剖析 in Android
編譯插樁的三種方法:AspectJ、ASM、ReDex
Android 引入AspectJ的記錄
以上就是在Android項目中的使用AspectJ的詳細攻詻的詳細內容,更多關於AspectJ在android中使用的資料請關註WalkonNet其它相關文章!
推薦閱讀:
- Aspectj與Spring AOP的對比分析
- 使用Spring開啟註解AOP的支持放置的位置
- 淺談@Aspect@Order各個通知的執行順序
- Springboot如何使用Aspectj實現AOP面向切面編程
- Spring基於AspectJ的AOP開發案例解析