在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 callMethod executionConstructor callConstructor execution等。

Pointcuts

切入點,是帶條件的Join Points,確定切入點位置。

Pointcuts語法 說明
execution(MethodPattern) 方法執行
call(MethodPattern) 方法被調用
execution(ConstructorPattern) 構造方法執行
call(ConstructorPattern) 構造方法被調用
get(FieldPattern) 讀取屬性
set(FieldPattern) 設置屬性
staticinitialization(TypePattern) static 塊初始化
handler(TypePattern) 異常處理

execution和call的區別如下圖:

call和execution區別

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其它相關文章!

推薦閱讀: