Android 編譯插樁- AspectJ 使用

一、AOP 理解

????在 Java 當中我們常常提及到的編程思想是 OOP(Object Oriented Programming)面向對象編程,即把功能或問題模塊化,每個模塊處理自己的事務。但在現實世界中,并不是所有問題都能完美地劃分到模塊中。比如,我們要完成一個事件埋點的功能,我們希望在原來整個系統當中,加入一些事件的埋點,監控并獲取用戶的操作行為和操作數據。按照面向對象的思想,我們會設計一個埋點管理器模塊,然后在每個需要埋點的地方都加上一段埋點管理器的方法調用的邏輯。看起來好像沒有什么問題,并且我們之前也都是這么做的,但當我們要對埋點的功能進行撤銷、遷移或者重構的時候,都會存在很大的代價,因為埋點的功能已經侵入到了各個模塊。這也是 OOP 很矛盾的地方。
????另一種編程思想是 AOP(Aspect Oriented Programming)面向切面編程。AOP 提倡的是針對同一類問題的統一處理。比如我們前面提及到的埋點功能,我們的埋點調用散落在系統的每個角落(雖然我們的核心邏輯可以抽象在一個對象當中)。如果我們將 AOP 與 OOP 兩者相結合,將功能的邏輯抽象成對象(OOP),然后在一個統一的地方完成邏輯的調用(AOP,將問題的處理也即是邏輯的調用統一)。
????Android 中 AOP 的實際使用場景是無侵入的在宿主系統中插入一些核心的代碼邏輯,比如日志埋點、性能監控、動態權限控制、代碼調試等等。日志埋點上的應用比較多,推薦看看網易的 HubbleData、51 信用卡的埋點實踐。實現 AOP 的的核心技術其實就是代碼織入技術(code injection),對應的編程手段和工具其實有很多種,比如 AspectJ、ASM,它們的輸入和輸出都是 Class 文件,是我們最常用的 Java 字節碼處理框架。

二、AspectJ 概念和語法

????AspectJ 實際上是對 AOP 編程思想的一個實踐。AspectJ 提供了一套全新的語法實現,完全兼容Java,同時還提供了純 Java 語言的實現,通過注解的方式,完成代碼編織的功能。因此我們在使用 AspectJ 的時候有以下兩種方式:

  • 使用AspectJ的語言進行開發
  • 通過AspectJ提供的注解在Java語言上開發
    ????因為最終的目的其實都是需要在字節碼文件中織入我們自己定義的切面代碼,不管使用哪種方式接入AspectJ,都需要使用AspectJ提供的代碼編譯工具ajc進行編譯。

????在了解 AspectJ 的具體使用之前,先了解一下其中的一些基本的術語概念,這有利于我們掌握 AspectJ 的使用以及 AOP 的編程思想。在下面的關于 AspectJ 的使用相關介紹都是以注解的方式使用作為說明的。

2.1 JoinPoints(連接點)

????JoinPoints(簡稱 JPoints)是 AspectJ 中最關鍵的一個概念。它是程序運行時的一些執行點,即程序中可能作為代碼注入目標的特定的點。一個程序中哪些執行點是 JPoints呢,我們接著往下看。

2.2 PointCuts(切入點)

????PointCuts(切入點),其實就是代碼注入的位置。與前面的JoinPoints不同的地方在于,PointCuts 是通過語法標準給 JoinPoints 添加了篩選條件限定。

2.2.1 直接對 JoinPoints 的選擇

????Pointcuts 中最常用的選擇條件和 JoinPoint 的類型密切相關,下面這個表可以清晰的看出哪些執行點可以作為 JoinPoints,以及對應的 Pointcut 句法:


Pointcut 句法
2.2.2 間接對 JoinPoints 的選擇

除了上面與 JoinPoint 對應的選擇外,Pointcuts 還有其他選擇方法:
間接對 JoinPoints 的選擇
2.2.3 組合對 JoinPoints 的選擇

????Pointcut 表達式還可以 !、&&、|| 來組合

組合對 JoinPoints 的選擇

????上表中所提及到的 MethodSignature、ConstructorSignature、TypeSignature、FieldSignature,它們的表達式都可以使用通配符進行匹配。我們先來看看常用的通配符:
通配符

接下來我們看看這些 Signature 的定義規則:
Signature

需要注意的是 “[]” 當中的內容表示可選項,當沒有設定的時候,表示全匹配。另外,需要注意不同項之前是否有空格。
可以通過 @Pointcut 注解聲明一個 PointCut,下面我們來看一些使用示例:


@Aspect
public class TestPointcut {
 
    //--1、通過方法定義切點----------
    @Pointcut("public * *(..)")//匹配所有目標類的public方法
    public void test(){}
 
    @Pointcut("* *(..) throws Exception")//匹配所有拋出Exception的方法
    public void test1(){}
 
    @Pointcut("* *To(..)")//匹配目標類所有以To為后綴的方法。第一個*代表返回類型,而*To代表任意以To為后綴的方法
    public void test2(){}
 
    //--2、通過類定義切點-----------
    @Pointcut("* com.eason.Test.*(..)")//匹配Test類(或接口)的所有方法。第一個*代表返回任意類型,第二個*代表所有方法
    public void test3(){}
 
    @Pointcut("* com.eason.Test+.*(..)")//匹配Test類及其所有子類(或接口及其所有實現類)所有的方法
    public void test4(){}
 
    //--3、通過類包定義切點。在類名模式串中,“.”表示包下的所有類,而“..”表示包、子孫包下的所有類---
    @Pointcut("* com.lerendan.*.*(..)")//匹配com.lerendan包下所有類的所有方法
    public void test5(){}
 
    @Pointcut("* com.lerendan..*.*(..)")
    //匹配com.eason包、子孫包下所有類的所有方法以及包下接口的實現類。“..”出現在類名中時后面必須跟“*”,表示包、子孫包下的所有類
    public void test6(){}
 
    @Pointcut("* com..*.*Dao.find*(..)")
    //匹配包名前綴為com的任何包下類名后綴為Dao的類中方法名以find為前綴的方法。如com.lerendan.UserDao#findByUserId()。
    public void test7(){}
 
    //--4、通過方法入參定義切點
    // 切點表達式中方法入參部分比較復雜,可以使用“”和“ ..”通配符,其中“”表示任意類型的參數,而“..”表示任意類型參數且參數個數不限。
    @Pointcut("* joke(String,int)")
    //匹配joke(String,int)方法,且方法的第一個入參是String,第二個入參是int。
    //如果方法中的入參類型是java.lang包下的類,可以直接使用類名,否則必須使用全限定類名,如joke(java.util.List,int)
    public void test8(){}
 
    @Pointcut("* joke(String,*)")//匹配目標類中的joke()方法,第一個入參為String,第二個入參可以是任意類型
    public void test9(){}
 
    @Pointcut("* joke(String,..)")//匹配目標類中的joke()方法,第一個入參為String,后面可以有任意個入參且入參類型不限
    public void test10(){}
 
    @Pointcut("* joke(Object+)")//匹配目標類中的joke()方法,方法擁有一個入參,且入參是Object類型或該類的子類。
    public void test11(){}
 
    //--5、通過構造函數定義切點---------
    @Pointcut("@com.logaop.annotation.Log *.new(..)")// 被注解Log修飾的所有構造函數,這個比較特殊
    public void test12(){}
 
}
2.3 Advice(通知)

????Advice 是在切入點上織入的代碼,在 AspectJ 中有以下幾種類型。


Advice

使用示例:

// 這里使用@Aspect注解,表示這個類是一個切片代碼類。
@Aspect
public class AspectJTest {
 
    private static final String TAG = "AspectJTest";
    
    //@After,表示使用After類型的advice,里面的value其實就是一個poincut,"value="可以省略
    @After(value = "staticinitialization(*..People)")
    public void afterStaticInitial() {
        Log.d(TAG, "the static block is initial");
    }
    
    @Pointcut(value = "handler(Exception)")
    public void handleException() {
    }
 
    @Pointcut(value = "within(*..MainActivity)")
    public void codeInMain() {
    }
 
    // 這里通過&&操作符,將兩個Pointcut進行了組合
    // 表達的意思其實就是:在MainActivity當中的catch代碼塊
    @Before(value = "codeInMain() && handleException()")
    public void catchException(JoinPoint joinPoint) {
        Log.d(TAG, "this is a try catch block");
    }
}

????通過上述代碼可以看到我們可以直接在 Advice 注解的參數里寫一個 PointCut 表達式,或者先通過 @Pointcut 注解定義 PointCut,然后在 Advice 注解的參數里填入 @Pointcut 注解修飾的方法名。

2.4 Aspect(切面)

????Aspect 就是 AOP 中的關鍵單位:切面,我們一般會把相關 Pointcut 和 Advice 放在一個 Aspect 類中。在基于 AspectJ 注解開發方式中只需要在類的頭部加上 @Aspect 注解即可。另外 @Aspect 不能修飾接口。

三、AspectJ 在 Android 中的使用方式

3.1 引入 AspectJ 的方式
3.1.1 直接引入
classpath 'org.aspectj:aspectjtools:1.9.6'

步驟2、在你開發 aspectj 的 library module 的 build.gradle 里面添加(如果我們的切面代碼并不是獨立為一個 module 的可以忽略這一步):


import org.aspectj.bridge.IMessage
import org.aspectj.bridge.MessageHandler
import org.aspectj.tools.ajc.Main
apply plugin: 'com.android.library'
android {
    // ...
}
dependencies {
    // ...
    implementation 'org.aspectj:aspectjrt:1.8.9'
}
 
android.libraryVariants.all { variant ->
    JavaCompile javaCompile = variant.javaCompile
    javaCompile.doLast {
        //下面的1.8是指我們兼容的jdk的版本
        String[] args = [
                "-showWeaveInfo",
                "-1.8",
                "-inpath", javaCompile.destinationDir.toString(),
                "-aspectpath", javaCompile.classpath.asPath,
                "-d", javaCompile.destinationDir.toString(),
                "-classpath", javaCompile.classpath.asPath,
                "-bootclasspath", android.bootClasspath.join(File.pathSeparator)
        ]
        // 注意這里為 kotlin 配置,當項目中有使用到kotlin時,需要添加以下配置
        def fullName = ""
        variant.name.tokenize('-').eachWithIndex { token, index ->
            fullName = fullName + (index == 0 ? token : token.capitalize())
        }
      String[] kotlinArgs = ["-showWeaveInfo",
                               "-1.8",
                               "-inpath", project.buildDir.path + "/tmp/kotlin-classes/" + fullName,
                               "-aspectpath", javaCompile.classpath.asPath,
                               "-d", project.buildDir.path + "/tmp/kotlin-classes/" + fullName,
                               "-classpath", javaCompile.classpath.asPath,
                               "-bootclasspath", project.android.bootClasspath.join(
                File.pathSeparator)]
        MessageHandler handler = new MessageHandler(true);
        new Main().run(args, handler)
        // kotlin 配置
        new Main().run(kotlinArgs, handler)
        def log = project.logger
        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:
                case IMessage.INFO:
                    log.info message.message, message.thrown
                    break;
                case IMessage.DEBUG:
                    log.debug message.message, message.thrown
                    break;
            }
        }
    }
}

步驟3、在 app 的 build.gradle 里面,添加:

apply plugin: 'com.android.application'
android {
    // ...
}
dependencies {
    // ...
    implementation 'org.aspectj:aspectjrt:1.8.9'
}
 


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 ->
    if (!variant.buildType.isDebuggable()) {
        log.debug("Skipping non-debuggable build type '${variant.buildType.name}'.")
        return;
    }
    JavaCompile javaCompile = variant.javaCompile
    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)]
        def fullName = ""
        variant.name.tokenize('-').eachWithIndex { token, index ->
            fullName = fullName + (index == 0 ? token : token.capitalize())
        }
        String[] kotlinArgs = ["-showWeaveInfo",
                               "-1.8",
                               "-inpath", project.buildDir.path + "/tmp/kotlin-classes/" + fullName,
                               "-aspectpath", javaCompile.classpath.asPath,
                               "-d", project.buildDir.path + "/tmp/kotlin-classes/" + fullName,
                               "-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);
        new Main().run(kotlinArgs, 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 需要該功能,則每一個需要的 module,都需要加上在你開發 aspectj 的 library module 的 build.gradle 里面添加的代碼。并且也需要依賴你編寫aspectj的那個module。

其實,第二步和第三步的配置是一樣的,并且在配置當中,我們使用了 gradle 的 log 日志打印對象 logger。因此我們在編譯的時候,可以獲得關于代碼織入的一些異常信息。我們可以利用這些異常信息幫助檢查我們的切面代碼片段是否語法正確。要注意的是:logger 的日志輸出是在 android studio 的 Gradle Console 控制臺顯示的,并不是我們常規的 logcat。

3.1.2 通過第三方插件引入

????通過上面的方式,我們就完成了在 android studio 中的 android 項目工程接入 AspectJ 的配置工作。這個配置有點繁瑣,因此網上其實已經有人寫了相應的 gradle 插件 gradle_plugin_android_aspectjx。直接利用這個 gradle 插件就可以了,具體的可以參考它的文檔。

3.2 使用方式

以 Pointcut 切入點作為區分,AspectJ 有兩種用法:侵入式和非侵入式

3.2.1 侵入式

侵入式一般會使用自定義注解,以此作為選擇切入點的規則。侵入式 AspectJ 的特點是:

  • 需要自定義注解
  • 切入點需要添加注解,會侵入切入點代碼
    不需要修改 Aspect 切面代碼,就可以隨意修改切入點
    它的實現代表就是 JakeWharton 大神的 hugo 。不下面我們來看看 hugo 的實現:

首先新增自定義注解:

@Target({TYPE, METHOD, CONSTRUCTOR}) @Retention(CLASS)
public @interface DebugLog {
}

????上面定義了 @DebugLog 注解,可以修飾類、接口、方法和構造函數。由于 AspectJ 的輸入是 class 文件,所以可在 Class 文件中保留,編譯期可用。接下來看看 hugo 的切面代碼:

@Aspect
public class Hugo {
  private static volatile boolean enabled = true;
  // @DebugLog 修飾的類、接口的 Join Point
  @Pointcut("within(@hugo.weaving.DebugLog *)")
  public void withinAnnotatedClass() {} 
 
  // synthetic 是內部類編譯后添加的修飾語,所以 !synthetic 表示非內部類的
  // 執行 @DebugLog 修飾的類、接口中的方法,不包括內部類中方法
  @Pointcut("execution(!synthetic * *(..)) && withinAnnotatedClass()")
  public void methodInsideAnnotatedType() {} 
 
  // 執行 @DebugLog 修飾的類中的構造函數,不包括內部類的構造函數
  @Pointcut("execution(!synthetic *.new(..)) && withinAnnotatedClass()")
  public void constructorInsideAnnotatedType() {} 
 
  // 執行 @DebugLog 修飾的方法,或者 @DebugLog 修飾的類、接口中的方法
  @Pointcut("execution(@hugo.weaving.DebugLog * *(..)) || methodInsideAnnotatedType()")
  public void method() {} 
 
  // 執行 @DebugLog 修飾的構造函數,或者 @DebugLog 修飾的類中的構造函數
  @Pointcut("execution(@hugo.weaving.DebugLog *.new(..)) || constructorInsideAnnotatedType()")
  public void constructor() {} 
  ...
 
  @Around("method() || constructor()")
  public Object logAndExecute(ProceedingJoinPoint joinPoint) throws Throwable {
    enterMethod(joinPoint); // 打印切入點方法名、參數列表
    long startNanos = System.nanoTime();
    Object result = joinPoint.proceed(); // 調用原來的方法
    long stopNanos = System.nanoTime();
    long lengthMillis = TimeUnit.NANOSECONDS.toMillis(stopNanos - startNanos);
    exitMethod(joinPoint, result, lengthMillis); // 打印切入點方法名、返回值、方法執行時間
    return result;
  }

????從上面代碼可以看出 hugo 是以 @DebugLog 作為選擇切入點的條件,只需要用 @DebugLog 注解類或者方法就可以打印方法調用的信息。

3.2.2 非侵入式

非侵入式,就是不需要使用額外的注解來修飾切入點,不用修改切入點的代碼。

四、AspectJ 的優缺點

????AspectJ 一個顯著的缺點就是性能較低,它在實現時會包裝自己的一些類,邏輯比較復雜,不僅生成的字節碼比較大,而且對原函數的性能也會有所影響。
????AspectJ 是通過對目標工程的 .class 文件進行代碼注入的方式將通知(Advise)插入到目標代碼中。

  • 第一步:根據pointCut切點規則匹配的joinPoint;
  • 第二步:將Advise插入到目標JoinPoint中。
    這樣在程序運行時被重構的連接點將會回調 Advise方法,就實現了AspectJ代碼與目標代碼之間的連接。舉個例子:

@Before("execution(* **(..))")
public void before(JoinPoint joinPoint) {
    Trace.beginSection(joinPoint.getSignature().toString());
}
 
@After("execution(* **(..))")
public void after() {
    Trace.endSection();
}
代碼對比

????可以看到經過 AspectJ 的字節碼處理,它并不會直接把 Trace 函數直接插入到代碼中,而是經過一系列自己的封裝。如果想針對所有的函數都做插樁,AspectJ 會帶來不少的性能影響。不過大部分情況,我們可能只會插樁某一小部分函數,這樣 AspectJ 帶來的性能影響就可以忽略不計了。

從使用上來看,作為字節碼處理元老,AspectJ 的框架也的確有自己的一些優勢。

  • 成熟穩定。從字節碼的格式和各種指令規則來看,字節碼處理不是那么簡單,如果處理出錯,就會導致程序編譯或者運行過程出問題。而 AspectJ 作為從 2001 年發展至今的框架,它已經很成熟,一般不用考慮插入的字節碼正確性的問題。

  • 使用簡單。AspectJ 功能強大而且使用非常簡單,使用者完全不需要理解任何 Java 字節碼相關的知識,就可以使用自如。它可以在方法(包括構造方法)被調用的位置、在方法體(包括構造方法)的內部、在讀寫變量的位置、在靜態代碼塊內部、在異常處理的位置等前后,插入自定義的代碼,或者直接將原位置的代碼替換為自定義的代碼。

五、AspectJ 實戰

5.1 統計 Application 中所有方法的耗時


@Aspect
public class ApplicationAspect {
    @Around("call (* com.json.chao.application.BaseApplication.**(..))")
    public void getTime(ProceedingJoinPoint joinPoint) {
        Signature signature = joinPoint.getSignature();
        String name = signature.toShortString();
        long time = System.currentTimeMillis();
        try {
            joinPoint.proceed();
        } catch (Throwable throwable) {
            throwable.printStackTrace();
        }
        Log.i("ApplicationAop", name + " cost" + (System.currentTimeMillis() - time));
    }
}

參考文獻:
https://www.eclipse.org/aspectj/doc/released/aspectj5rt-api/index.html
極客時間《Android 開發高手課》27丨編譯插樁的三種方法:AspectJ、ASM、ReDex
Android Aop之Aspectj
Android AOP學習之:AspectJ實踐
https://github.com/JakeWharton/hugo
AOP之@AspectJ技術原理詳解
51 信用卡 Android 自動埋點實踐
網易HubbleData之Android無埋點實踐

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容