暴力突破 Android 編譯插樁(七)- AspectJ 使用

專欄暴力突破 Android 編譯插樁系列

一、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 句法:

JoinPoints PointCut 句法 說明 JoinPoints示例
Method execution execution(MethodSignature)

函數調用

比如調用Log.e()的位置

Method call

call(MethodSignature) 函數執行 比如Log.e()的執行內部
Constructor execution execution(ConstructorSignature) 構造函數調用  
Constructor call call(ConstructorSignature) 構造函數執行  
Class initialization staticinitialization(TypeSignature) 類初始化  
Field read access get(FieldSignature) 獲取某個變量  
Field write access set(FieldSignature) 設置某個變量  
Exception handler execution handler(TypeSignature) 異常處理  
Object initialization initialization(ConstructorSignature) 對象初始化  
Object pre-initialization preinitialization(ConstructorSignature) 對象預初始化  
Advice execution adviceexecution() advice執行  

2.2.2 間接對 JoinPoints 的選擇

除了上面與 JoinPoint 對應的選擇外,Pointcuts 還有其他選擇方法:

Pointcuts 說明 示例
within(TypeSignature) 表示在某個類中所有的JoinPoint within(com.example.Test):表示在 com.example.Test 類當中的全部JoinPoint
withincode(MethodSignature) 在某些方法中的 JoinPoint withincode( ..Test(..)):表示在任意包下面的Test函數的所有JoinPoint
withincode(ConstructorSignature) 在某些構造函數中的 JoinPoint  

2.2.3 組合對 JoinPoints 的選擇

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

組合 說明
!Pointcut 選取不符合 Pointcut 的 Join Point
Pointcut0 && Pointcut1 選取符合 Pointcut0 和 Pointcut1 的 Join Point
Pointcut0 || Pointcut1 選取符合 Pointcut0 或 Pointcut1 的 Join Point

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

通配符 意義

示例

* 表示除 ”.” 以外的任意字符串 java.*.Date:可以表示 java.sql.Date 和 java.util.Date
..

匹配任何數量字符的重複,如在類型模式中匹配任何數量子包;

而在方法參數模式中匹配任何數量參數

java..*:表示java任意子包

void getName(..):表示方法參數爲任意類型任意個數

+ 表示子類 java..*Model+:表示 java 任意包中以 Model 結尾的子類

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

Signature 規則
MethodSignature [!] [@Annotation] [public,protected,private] [static] [final] 返回值類型 [類名.]方法名(參數類型列表) [throws 異常類型]
ConstructorSignature [!] [@Annotation] [public,protected,private] [final] [類名.]new(參數類型列表) [throws 異常類型]
FieldSignature [!] [@Annotation] [public,protected,private] [static] [final] 屬性類型 [類名.]屬性名
TypeSignature TypeSignature其實就是用來指定一個類的。因此我們只需要給出一個類的全路徑的表達式即可

需要注意的是 “[]” 當中的內容表示可選項,當沒有設定的時候,表示全匹配。另外,需要注意不同項之前是否有空格。

可以通過 @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.lerendan.Test.*(..)")//匹配Test類(或接口)的所有方法。第一個*代表返回任意類型,第二個*代表所有方法
    public void test3(){}

    @Pointcut("* com.lerendan.Test+.*(..)")//匹配Test類及其所有子類(或接口及其所有實現類)所有的方法
    public void test4(){}

    //--3、通過類包定義切點。在類名模式串中,“.”表示包下的所有類,而“..”表示包、子孫包下的所有類---
    @Pointcut("* com.lerendan.*.*(..)")//匹配com.lerendan包下所有類的所有方法
    public void test5(){}

    @Pointcut("* com.lerendan..*.*(..)")
    //匹配com.lerendan包、子孫包下所有類的所有方法以及包下接口的實現類。“..”出現在類名中時後面必須跟“*”,表示包、子孫包下的所有類
    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 修飾的方法的參數 說明
@Before JoinPoint 在執行 JoinPoint 之前
@After JoinPoint 在執行 JoinPoint 之後,包括正常的 return 和 throw 異常
@AfterReturning JoinPoint JoinPoint 爲方法調用且正常 return 時,不指定返回類型時匹配所有類型
@AfterThrowing JoinPoint JoinPoint 爲方法調用且拋出異常時,不指定異常類型時匹配所有類型
@Around ProceedingJoinPoint 替代 JoinPoint 的代碼,如果要執行原來代碼的話,要使用 ProceedingJoinPoint.proceed()

使用示例:

// 這裏使用@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 直接引入

步驟1、在工程根目錄的 build.gradle 裏面,buildscript-dependencies 下面添加:

classpath 'org.aspectj:aspectjtools:1.8.9'

步驟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)
        ]
        MessageHandler handler = new MessageHandler(true);
        new Main().run(args, 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 裏面,添加:

import org.aspectj.bridge.IMessage
import org.aspectj.bridge.MessageHandler
import org.aspectj.tools.ajc.Main
apply plugin: 'com.android.application'
android {
    // ...
}
dependencies {
    // ...
    implementation 'org.aspectj:aspectjrt:1.8.9'
}

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)]
        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 需要該功能,則每一個需要的 module,都需要加上在你開發 aspectj 的 library module 的 build.gradle 裏面添加的代碼。並且也需要依賴你編寫aspectj的那個module。

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

通過我們前面 《Gradle 專欄》 的學習,這裏其實我們也可以定義一個 gradle plugin,將上述配置放到 plugin 中達到一個自動配置的效果。

3.1.2 通過第三方插件引入

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

3.2 使用方式

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

3.2.1 侵入式

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

  • 需要自定義註解
  • 切入點需要添加註解,會侵入切入點代碼
  • 不需要修改 Aspect 切面代碼,就可以隨意修改切入點

它的實現代表就是 JakeWharton 大神的 hugo 。不熟悉如何自定義註解的同學可以看本博客《編譯插樁專欄》裏的 APT 部分。下面我們來看看 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 處理後:

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

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

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

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

 

五、AspectJ 實戰


如果你看到這裏,說明你對 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));
    }
}

可以看到 Around 和 Before、After 的最大區別就是 ProceedingPoint 不同於 JoinPoint,其提供了 proceed 方法執行目標方法。

5.2 對 App 中所有的方法進行 Systrace 函數插樁

@Aspect
public class SystraceTraceAspect {
    private static final String TAG = "SystraceTraceAspectj";

    @Before("execution(* **(..))")
    public void before(JoinPoint joinPoint) {
        TraceCompat.beginSection(joinPoint.getSignature().toString());
    }

    @After("execution(* **(..))")
    public void after() {
        TraceCompat.endSection();
    }

}

使用 Systrace 對函數進行插樁,從而能夠查看應用中方法的耗時與 CPU 情況。學習了 AspectJ 之後,我們就可以利用它實現對 App 中所有的方法進行 Systrace 函數插樁了。瞭解了 AspectJX 的基本使用之後,我們使用 AspectJ 去打造一個簡易版的 APM(性能監控框架)。

 

 

參考文獻

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無埋點實踐

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章