一文應用 AOP | 最全選型考量 + 邊剖析經典開源庫邊實踐,美滋滋

FeelsChaotic發表於2018-12-17

AOP系列思維導圖

前言

繁多的 AOP 方法該如何選擇?應用的步驟過於繁瑣,語法概念看得頭暈腦脹?

本文將詳細展示選型種種考量維度,更是砍掉 2 個經典開源庫的枝節,取其主幹細細體會 AOP 的應用思想和關鍵流程。一邊實踐 AOP 一邊還能掌握開源庫,豈不快哉!

一、6 個要點幫你選擇合適的 AOP 方法

在上文 最全面 AOP 方法探討 中,我們分析對比了最熱門的幾種 AOP 方法。那麼,在實際情況和業務需求中,我們該怎麼考量選擇呢?

1. 明確你應用 AOP 在什麼專案

如果你正在維護一個現有的專案,你要麼小範圍試用,要麼就需要選擇一個侵入性小的 AOP 方法(如:APT 代理類生效時機需要手動呼叫,靈活,但在插入點繁多情況下侵入性過高)。

2. 明確切入點的相似性

第一步,考慮一下切入點的數量和相似性,你是否願意一個個在切點上面加註解,還是用相似性統一切。

第二步,考慮下這些應用切面的類有沒有被 final 修飾,同時相似的方法有沒有被 static 或 final 修飾時。 final 修飾的類就不能通過 cglib 生成代理,cglib 會繼承被代理類,需要重寫被代理方法,所以被代理類和方法不能是 final。

3. 明確織入的粒度和織入時機

我怎麼選擇織入(Weave)的時機?編譯期間織入,還是編譯後?載入時?或是執行時?通過比較各大 AOP 方法在織入時機方面的不同和優缺點,來獲得對於如何選擇 Weave 時機進行判定的準則。

對於普通的情況而言,在編譯時進行 Weave 是最為直觀的做法。因為源程式中包含了應用的所有資訊,這種方式通常支援最多種類的聯結點。利用編譯時 Weave,我們能夠使用 AOP 系統進行細粒度的 Weave 操作,例如讀取或寫入欄位。原始碼編譯之後形成的模組將喪失大量的資訊,因此通常採用粗粒度的 AOP 方法。

同時,對於傳統的編譯為原生程式碼的語言如 C++ 來說,編譯完成後的模組往往跟作業系統平臺相關,這就給建立統一的載入時、執行時 Weave 機制造成了困難。對於編譯為原生程式碼的語言而言,只有在編譯時進行 Weave 最為可行。儘管編譯時 Weave 具有功能強大、適應面廣泛等優點,但他的缺點也很明顯。首先,它需要程式設計師提供所有的原始碼,因此對於模組化的專案就力不從心了。

為了解決這個問題,我們可以選擇支援編譯後 Weave 的 AOP 方法。

新的問題又來了,如果程式的主邏輯部分和 Aspect 作為不同的元件開發,那麼最為合理的 Weave 時機就是在框架載入 Aspect 程式碼之時。

執行時 Weave 可能是所有 AOP 方法中最為靈活的,程式在執行過程中可以為單個的物件指定是否需要 Weave 特定的方面。

選擇合適的 Weave 時機對於 AOP 應用來說是非常關鍵的。針對具體的應用場合,我們需要作出不同的抉擇。我們也可以結合多種 AOP 方法,從而獲得更為靈活的 Weave 策略。

4. 明確對效能的要求,明確對方法數的要求

除了動態 Hook 方法,其他的 AOP 方法對效能影響幾乎可以忽略不計。動態 AOP 本質使用了動態代理,不可避免要用到反射。而 APT 不可避免地要生成大量的代理類和方法。如何權衡,就看你對專案的要求。

5. 明確是否需要修改原有類

如果只是想特定地增強能力,可以使用 APT,在編譯期間讀取 Java 程式碼,解析註解,然後動態生成 Java 程式碼。

下圖是Java編譯程式碼的流程:

一文應用 AOP | 最全選型考量 + 邊剖析經典開源庫邊實踐,美滋滋

可以看到,APT 工作在 Annotation Processing 階段,最終通過註解處理器生成的程式碼會和原始碼一起被編譯成 Java 位元組碼。不過比較遺憾的是你不能修改已經存在的 Java 檔案,比如在已經存在的類中新增新的方法或刪除舊方法,所以通過 APT 只能通過輔助類的方式來實現注入,這樣會略微增加專案的方法數和類數,不過只要控制好,不會對專案有太大的影響。

6. 明確呼叫的時機

APT 的時機需要主動呼叫,而其他 AOP 方法注入程式碼的呼叫時機和切入點的呼叫時機一致。

二、從開源庫剖析 AOP

AOP 的實踐都寫爛了,市面上有太多講怎麼實踐 AOP 的博文了。那這篇和其他的博文有什麼不同呢?有什麼可以讓大家受益的呢?

其實 AOP 實踐很簡單,關鍵是理解並應用,我們先參考開源庫的實踐,在這基礎上去抽象關鍵步驟,一邊實戰一邊達成閱讀開源庫任務,美滋滋!

APT

1. 經典 APT 框架 ButterKnife 工作流程

直接上圖說明。

APT之ButterKnife工作流程

在上面的過程中,你可以看到,為什麼用 @Bind 、 @OnClick 等註解標註的屬性、方法必須是 public 或 protected? 因為ButterKnife 是通過 被代理類引用.this.editText 來注入View的。為什麼要這樣呢? 答案就是:效能 。如果你把 View 和方法設定成 private,那麼框架必須通過反射來注入。

想深入到原始碼細節瞭解 ButterKnife 更多?

2. 仿造 ButterKnife,上手 APT

我們去掉細節,抽出關鍵流程,看看 ButterKnife 是怎麼應用 APT 的。

APT工作流程

可以看到關鍵步驟就幾項:

  1. 定義註解
  2. 編寫註解處理器
  3. 掃描註解
  4. 編寫代理類內容
  5. 生成代理類
  6. 呼叫代理類

我們標出重點,也就是我們需要實現的步驟。如下:

APT工作流程重點

咦,你可能發現了,最後一個步驟是在合適的時機去呼叫代理類或門面物件。這就是 APT 的缺點之一,在任意包位置自動生成程式碼但是執行時卻需要主動呼叫。

APT 手把手實現可參考 JavaPoet - 優雅地生成程式碼——3.2 一個簡單示例

3. 工具詳解

APT 中我們用到了以下 3 個工具:

(1)Java Annotation Tool

Java Annotation Tool 給了我們一系列 API 支援。

  1. 通過 Java Annotation Tool 的 Filer 可以幫助我們以檔案的形式輸出JAVA原始碼。
  2. 通過 Java Annotation Tool 的 Elements 可以幫助我們處理掃描過程中掃描到的所有的元素節點,比如包(PackageElement)、類(TypeElement)、方法(ExecuteableElement)等。
  3. 通過 Java Annotation Tool 的 TypeMirror 可以幫助我們判斷某個元素是否是我們想要的型別。
(2)JavaPoet

你當然可以直接通過字串拼接的方式去生成 java 原始碼,怎麼簡單怎麼來,一張圖 show JavaPoet 的厲害之處。

生成同樣的類,使用JavaPoet前,字串拼接
生成同樣的類,使用JavaPoet後,以物件導向的方式來生成原始碼

(3)APT 外掛

註解處理器已經有了,那麼怎麼執行它?這個時候就需要用到 android-apt 這個外掛了,使用它有兩個目的:

  1. 允許配置只在編譯時作為註解處理器的依賴,而不新增到最後的APK或library
  2. 設定源路徑,使註解處理器生成的程式碼能被Android Studio正確的引用

專案引入了 butterknife 之後就無需引入 apt 了,如果繼續引入會報 Using incompatible plugins for the annotation processing

(4)AutoService

想要執行註解處理器,需要繁瑣的步驟:

  1. 在 processors 庫的 main 目錄下新建 resources 資原始檔夾;
  2. 在 resources資料夾下建立 META-INF/services 目錄資料夾;
  3. 在 META-INF/services 目錄資料夾下建立 javax.annotation.processing.Processor 檔案;
  4. 在 javax.annotation.processing.Processor 檔案寫入註解處理器的全稱,包括包路徑;

Google 開發的 AutoService 可以減少我們的工作量,只需要在你定義的註解處理器上新增 @AutoService(Processor.class) ,就能自動完成上面的步驟,簡直不能再方便了。

4. 代理執行

雖然前面有說過 APT 並不能像 Aspectj 一樣實現程式碼插入,但是可以使用變種方式實現。用註解修飾一系列方法,由 APT 來代理執行。此部分可參考CakeRun

APT 生成的代理類按照一定次序依次執行修飾了註解的初始化方法,並且在其中增加了一些邏輯判斷,來決定是否要執行這個方法。從而繞過發生 Crash 的類。

AspectJ

1. 經典 Aspectj 框架 hugo 工作流程

J 神的框架一如既往小而美,想啃開源庫原始碼,可以先從 J 神的開源庫先讀起。

回到正題,hugo是 J 神開發的 Debug 日誌庫,包含了優秀的思想以及流行的技術,例如註解、AOP、AspectJ、Gradle 外掛、android-maven-gradle-plugin 等。在進行 hugo 原始碼解讀之前,你需要首先對這些知識點有一定的瞭解。

先上工作流程圖,我們再講細節:

Aspect之hugo工作流程

2. 解惑之一個列印日誌邏輯怎麼織入的?

只需要一個 @DebugLog註解,hugo就能幫我們列印入參出參、統計方法耗時。自定義註解很好理解,我們重點看看切面 Hugo 是怎麼處理的。

一文應用 AOP | 最全選型考量 + 邊剖析經典開源庫邊實踐,美滋滋

有沒有發現什麼?

一文應用 AOP | 最全選型考量 + 邊剖析經典開源庫邊實踐,美滋滋

一文應用 AOP | 最全選型考量 + 邊剖析經典開源庫邊實踐,美滋滋

沒錯,切點表示式幫助我們描述具體要切入哪裡。

AspectJ 的切點表示式由關鍵字和操作引數組成,以切點表示式 execution(* helloWorld(..))為例,其中 execution 是關鍵字,為了便於理解,通常也稱為函式,而* helloWorld(..)是操作引數,通常也稱為函式的入參。切點表示式函式的型別很多,如方法切點函式,方法入參切點函式,目標類切點函式等,hugo 用到的有兩種型別:

函式名 型別 入參 說明
execution() 方法切點函式 方法匹配模式字串 表示所有目標類中滿足某個匹配模式的方法連線點,例如 execution(* helloWorld(..)) 表示所有目標類中的 helloWorld 方法,返回值和引數任意
within() 目標類切點函式 類名匹配模式字串 表示滿足某個匹配模式的特定域中的類的所有連線點,例如 within(com.feelschaotic.demo.*) 表示 com.feelschaotic.demo 中的所有類的所有方法

想詳細入門 AspectJ 語法?

3. 解惑之 AspectJ in Android 為何如此絲滑?

我們引入 hugo 只需要 3 步。

一文應用 AOP | 最全選型考量 + 邊剖析經典開源庫邊實踐,美滋滋

不是說 AspectJ 在 Android 中很不友好?!說好的需要使用 andorid-library gradle 外掛在編譯時做一些 hook,使用 AspectJ 的編譯器(ajc,一個java編譯器的擴充套件)對所有受 aspect 影響的類進行織入,在 gradle 的編譯 task 中增加一些額外配置,使之能正確編譯執行等等等呢?

這些 hugo 已經幫我們做好了(所以步驟 2 中,我們引入 hugo 的同時要使用 hugo 的 Gradle 外掛,就是為了 hook 編譯)。

一文應用 AOP | 最全選型考量 + 邊剖析經典開源庫邊實踐,美滋滋

一文應用 AOP | 最全選型考量 + 邊剖析經典開源庫邊實踐,美滋滋

一文應用 AOP | 最全選型考量 + 邊剖析經典開源庫邊實踐,美滋滋

4. 抽絲剝繭 Aspect 的重點流程

抽象一下 hugo 的工作流程,我們得到了 2 種Aspect工作流程:

Aspect侵入式工作流程

Aspect非侵入式工作流程

前面選擇合適的 AOP 方法第 2 點我們提到,以 Pointcut 切入點作為區分,AspectJ 有兩種用法:

  1. 用自定義註解修飾切入點,精確控制切入點,屬於侵入式
//方法一:一個個在切入點上面加註解
protected void onCreate(Bundle savedInstanceState) {
	//...
	followTextView.setOnClickListener(view -> {
		onClickFollow();
	});
	unFollowTextView.setOnClickListener(view -> {
		onClickUnFollow();
	});
}

@SingleClick(clickIntervalTime = 1000)
private void onClickUnFollow() {
}

@SingleClick(clickIntervalTime = 1000)
private void onClickFollow() {
}

@Aspect
public class AspectTest {
	@Around("execution(@com.feelschaotic.aspectj.annotations.SingleClick * *(..))")
	public void onClickLitener(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
		//...
	}
}
複製程式碼
  1. 不需要在切入點程式碼中做任何修改,統一按相似性來切(比如類名,包名),屬於非侵入式
//方法二:根據相似性統一切,不需要再使用註解標記了
protected void onCreate(Bundle savedInstanceState) {
	//...
	followTextView.setOnClickListener(view -> {
		//...
	});
	unFollowTextView.setOnClickListener(view -> {
		//...
	});
}

@Aspect
public class AspectTest {
	@Around("execution(* android.view.View.OnClickListener.onClick(..))")
	public void onClickLitener(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
		//...
	}
}
複製程式碼

5. AspectJ 和 APT 最大的不同

APT 決定是否使用切面的權利仍然在業務程式碼中,而 AspectJ 將決定是否使用切面的權利還給了切面。在寫切面的時候就可以決定哪些類的哪些方法會被代理,從邏輯上不需要侵入業務程式碼。

但是AspectJ 的使用需要匹配一些明確的 Join Points,如果 Join Points 的函式命名、所在包位置等因素改變了,對應的匹配規則沒有跟著改變,就有可能導致匹配不到指定的內容而無法在該地方插入自己想要的功能。

那 AspectJ 的執行原理是什麼?注入的程式碼和目的碼是怎麼連線的?下一篇我將會詳細講講,先挖個坑,跑路。

三、應用篇

Javassist

為什麼用 Javassist 來實踐?

因為實踐過程中我們可以順帶掌握位元組碼插樁的技術基礎,就算是後續學習熱修復、應用 ASM,這些基礎都是通用的。雖然 Javassist 效能比 ASM 低,但對新手很友好,操縱位元組碼卻不需要直接接觸位元組碼技術和了解虛擬機器指令,因為 Javassist 實現了一個用於處理原始碼的小型編譯器,可以接收用 Java 編寫的原始碼,然後將其編譯成 Java 位元組碼再內聯到方法體中。

話不多說,我們馬上上手,在上手之前,先了解幾個概念:

1. 入門概念

(1)Gradle

Javassist 修改物件是編譯後的 class 位元組碼。那首先我們得知道什麼時候編譯完成,才能在 .class 檔案被轉為 .dex 檔案之前去做修改。

大多 Android 專案使用 Gradle 構建,我們需要先理解 Gradle 的工作流程。Gradle 是通過一個一個 Task 執行完成整個流程的,依次執行完 Task 後,專案就打包完成了。 其實 Gradle 就是一個裝載 Task 的指令碼容器。

Task執行流程

(2) Plugin

那 Gralde 裡面那麼多 Task 是怎麼來的呢,誰定義的呢?是Plugin!

我們回憶下,在 app module 的 build.gradle 檔案中的第一行,往往會有 apply plugin : 'com.android.application',lib 的 build.gradle 則會有 apply plugin : 'com.android.library',就是 Plugin 為專案構建提供了 Task,不同的 plugin 裡註冊的 Task 不一樣,使用不同 plugin,module 的功能也就不一樣。

可以簡單地理解為, Gradle 只是一個框架,真正起作用的是 plugin,是plugin 往 Gradle 指令碼中新增 Task

(3)Task

思考一下,如果一個 Task 的職責是將 .java 編譯成 .class,這個 Task 是不是要先拿到 java 檔案的目錄?處理完成後還要告訴下一個 Task class 的目錄?

沒錯,從 Task 執行流程圖可以看出,Task 有一個重要的概念:inputs 和 outputs。 Task 通過 inputs 拿到一些需要的引數,處理完畢之後就輸出 outputs,而下一個 Task 的 inputs 則是上一個 Task 的outputs。

這些 Task 其中肯定也有將所有 class 打包成 dex 的 Task,那我們要怎麼找到這個 Task ?在之前插入我們自己的 Task 做程式碼注入呢?用 Transfrom!

(4)Transform

Transfrom 是 Gradle 1.5以上新出的一個 API,其實它也是 Task。

  • gradle plugin 1.5 以下,preDex 這個 Task 會將依賴的 module 編譯後的 class 打包成 jar,然後 dex 這個 Task 則會將所有 class 打包成dex;

    想要監聽專案被打包成 .dex 的時機,就必須自定義一個 Gradle Task,插入到 predex 或者 dex 之前,在這個自定義的 Task 中使用 Javassist ca class 。

  • gradle plugin 1.5 以上,preDex 和 Dex 這兩個 Task 已經被 TransfromClassesWithDexForDebug 取代

    Transform 更為方便,我們不再需要插入到某個 Task 前面。Tranfrom 有自己的執行時機一經註冊便會自動新增到 Task 執行序列中,且正好是 class 被打包成dex之前,所以我們自定義一個 Transform 即可。

(5)Groovy
  1. Gradle 使用 Groovy 語言實現,想要自定義 Gradle 外掛就需要使用 Groovy 語言。
  2. Groovy 語言 = Java語言的擴充套件 + 眾多指令碼語言的語法,執行在 JVM 虛擬機器上,可以與 Java 無縫對接。Java 開發者學習 Groovy 的成本並不高。

2. 小結

所以我們需要怎麼做?流程總結如下:

Javassist應用流程

3. 實戰 —— 自動TryCatch

程式碼裡到處都是防範性catch

既然說了這麼多,是時候實戰了,每次看到專案程式碼裡充斥著防範性 try-catch,我就

一文應用 AOP | 最全選型考量 + 邊剖析經典開源庫邊實踐,美滋滋

我們照著流程圖,一步步來實現這個自動 try-Catch 功能:

(1)自定義 Plugin
  1. 新建一個 module,選擇 library module,module 名字必須為 buildSrc
  2. 刪除 module 下所有檔案,build.gradle 配置替換如下:
apply plugin: 'groovy'

repositories {
    jcenter()
}

dependencies {
    compile 'com.android.tools.build:gradle:2.3.3'
    compile 'org.javassist:javassist:3.20.0-GA'
}
複製程式碼
  1. 新建 groovy 目錄

    一文應用 AOP | 最全選型考量 + 邊剖析經典開源庫邊實踐,美滋滋

  2. 新建 Plugin 類

需要注意: groovy 目錄下新建類,需要選擇 file且以.groovy作為檔案格式。

import org.gradle.api.Plugin
import org.gradle.api.Project
import com.android.build.gradle.AppExtension

class PathPlugin implements Plugin<Project> {
    @Override
    void apply(Project project) {
        project.logger.debug "================自定義外掛成功!=========="
    }
}
複製程式碼

為了馬上看到效果,我們提前走流程圖中的步驟 4,在 app module下的 buiil.gradle 中新增 apply 外掛。

一文應用 AOP | 最全選型考量 + 邊剖析經典開源庫邊實踐,美滋滋

跑一下:

一文應用 AOP | 最全選型考量 + 邊剖析經典開源庫邊實踐,美滋滋

(2)自定義 Transfrom
import com.android.build.api.transform.*
import com.android.build.gradle.internal.pipeline.TransformManager
import org.apache.commons.codec.digest.DigestUtils
import org.apache.commons.io.FileUtils
import org.gradle.api.Project

class PathTransform extends Transform {

    Project project
    TransformOutputProvider outputProvider

    // 建構函式中我們將Project物件儲存一下備用
    public PathTransform(Project project) {
        this.project = project
    }

    // 設定我們自定義的Transform對應的Task名稱,TransfromClassesWithPreDexForXXXX
    @Override
    String getName() {
        return "PathTransform"
    }

    //通過指定輸入的型別指定我們要處理的檔案型別
    @Override
    Set<QualifiedContent.ContentType> getInputTypes() {
        //指定處理所有class和jar的位元組碼
        return TransformManager.CONTENT_CLASS
    }

    // 指定Transform的作用範圍
    @Override
    Set<QualifiedContent.Scope> getScopes() {
        return TransformManager.SCOPE_FULL_PROJECT
    }

    @Override
    boolean isIncremental() {
        return false
    }

    @Override
    void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        super.transform(transformInvocation)
    }

    @Override
    void transform(Context context, Collection<TransformInput> inputs,
                   Collection<TransformInput> referencedInputs,
                   TransformOutputProvider outputProvider, boolean isIncremental)
            throws IOException, TransformException, InterruptedException {
        this.outputProvider = outputProvider
        traversalInputs(inputs)
    }

    /**
     * Transform的inputs有兩種型別:
     *  一種是目錄, DirectoryInput
     *  一種是jar包,JarInput
     *  要分開遍歷
     */
    private ArrayList<TransformInput> traversalInputs(Collection<TransformInput> inputs) {
        inputs.each {
            TransformInput input ->
                traversalDirInputs(input)
                traversalJarInputs(input)
        }
    }

    /**
     * 對型別為資料夾的input進行遍歷
     */
    private ArrayList<DirectoryInput> traversalDirInputs(TransformInput input) {
        input.directoryInputs.each {
            /**
             * 資料夾裡面包含的是
             *  我們手寫的類
             *  R.class、
             *  BuildConfig.class
             *  R$XXX.class
             *  等
             *  根據自己的需要對應處理
             */
            println("it == ${it}")

            //TODO:這裡可以注入程式碼!!

            // 獲取output目錄
            def dest = outputProvider.getContentLocation(it.name
                    , it.contentTypes, it.scopes, Format.DIRECTORY)

            // 將input的目錄複製到output指定目錄
            FileUtils.copyDirectory(it.file, dest)
        }
    }

    /**
     * 對型別為jar檔案的input進行遍歷
     */
    private ArrayList<JarInput> traversalJarInputs(TransformInput input) {
		//沒有對jar注入的需求,暫不擴充套件
    }
}

複製程式碼
(3)向自定義的 Plugin 註冊 Transfrom

回到我們剛剛定義的 PathPlugin,在 apply 方法中註冊 PathTransfrom:

def android = project.extensions.findByType(AppExtension)
android.registerTransform(new PathTransform(project))
複製程式碼

clean 專案,再跑一次,確保沒有報錯。

(4)程式碼注入

接著就是重頭戲了,我們新建一個 TryCatchInject 類,先把掃描到的方法和類名列印出來

這個類不同於前面定義的類,無需繼承指定父類,無需實現指定方法,所以我以短方法+有表達力的命名代替了註釋,如果有疑問請一定要反饋給我,我好反思是否寫得不夠清晰。

import javassist.ClassPool
import javassist.CtClass
import javassist.CtConstructor
import javassist.CtMethod
import javassist.bytecode.AnnotationsAttribute
import javassist.bytecode.MethodInfo
import java.lang.annotation.Annotation

class TryCatchInject {
    private static String path
    private static ClassPool pool = ClassPool.getDefault()
    private static final String CLASS_SUFFIX = ".class"
	
	//注入的入口
    static void injectDir(String path, String packageName) {
        this.path = path
        pool.appendClassPath(path)
        traverseFile(packageName)
    }

    private static traverseFile(String packageName) {
        File dir = new File(path)
        if (!dir.isDirectory()) {
            return
        }
        beginTraverseFile(dir, packageName)
    }

    private static beginTraverseFile(File dir, packageName) {
        dir.eachFileRecurse { File file ->

            String filePath = file.absolutePath
            if (isClassFile(filePath)) {
                int index = filePath.indexOf(packageName.replace(".", File.separator))
                boolean isClassFilePath = index != -1
                if (isClassFilePath) {
                    transformPathAndInjectCode(filePath, index)
                }
            }
        }
    }

    private static boolean isClassFile(String filePath) {
        return filePath.endsWith(".class") && !filePath.contains('R') && !filePath.contains('R.class') && !filePath.contains("BuildConfig.class")
    }

    private static void transformPathAndInjectCode(String filePath, int index) {
        String className = getClassNameFromFilePath(filePath, index)
        injectCode(className)
    }

    private static String getClassNameFromFilePath(String filePath, int index) {
        int end = filePath.length() - CLASS_SUFFIX.length()
        String className = filePath.substring(index, end).replace('\\', '.').replace('/', '.')
        className
    }

    private static void injectCode(String className) {
        CtClass c = pool.getCtClass(className)
        println("CtClass:" + c)
        defrostClassIfFrozen(c)
        traverseMethod(c)

        c.writeFile(path)
        c.detach()
    }

    private static void traverseMethod(CtClass c) {
        CtMethod[] methods = c.getDeclaredMethods()
        for (ctMethod in methods) {
            println("ctMethod:" + ctMethod)
            //TODO: 這裡可以對方法進行操作
        }
    }

    private static void defrostClassIfFrozen(CtClass c) {
        if (c.isFrozen()) {
            c.defrost()
        }
    }
}

複製程式碼

在 PathTransfrom 裡的 TODO 標記處呼叫注入類

//請注意把 com\\feelschaotic\\javassist 替換為自己想掃描的路徑
 TryCatchInject.injectDir(it.file.absolutePath, "com\\feelschaotic\\javassist")
複製程式碼

我們再次 clean 後跑一下

列印了掃描到的類和方法

我們可以直接按方法的包名切,也可以按方法的標記切(比如:特殊的入參、方法簽名、方法名、方法上的註解……),考慮到我們只需要對特定的方法捕獲異常,我打算用自定義註解來標記方法。

在 app module 中定義一個註解

//僅支援在方法上使用
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AutoTryCatch {
	//支援業務方catch指定異常
    Class[] value() default Exception.class;
}

複製程式碼

接著我們要在 TryCatchInjecttraverseMethod方法 TODO 中,使用 Javassist 獲取方法上的註解再獲取註解的 value

   private static void traverseMethod(CtClass c) {
        CtMethod[] methods = c.getDeclaredMethods()
        for (ctMethod in methods) {
            println("ctMethod:" + ctMethod)
            traverseAnnotation(ctMethod)
        }
    }

	private static void traverseAnnotation(CtMethod ctMethod) {
        Annotation[] annotations = ctMethod.getAnnotations()

        for (annotation in annotations) {
            def canonicalName = annotation.annotationType().canonicalName
            if (isSpecifiedAnnotation(canonicalName)) {
                onIsSpecifiedAnnotation(ctMethod, canonicalName)
            }
        }
    }

    private static boolean isSpecifiedAnnotation(String canonicalName) {
        PROCESSED_ANNOTATION_NAME.equals(canonicalName)
    }

    private static void onIsSpecifiedAnnotation(CtMethod ctMethod, String canonicalName) {
        MethodInfo methodInfo = ctMethod.getMethodInfo()
        AnnotationsAttribute attribute = methodInfo.getAttribute(AnnotationsAttribute.visibleTag)

        javassist.bytecode.annotation.Annotation javassistAnnotation = attribute.getAnnotation(canonicalName)
        def names = javassistAnnotation.getMemberNames()
        if (names == null || names.isEmpty()) {
            catchAllExceptions(ctMethod)
            return
        }
        catchSpecifiedExceptions(ctMethod, names, javassistAnnotation)
    }

    private static catchAllExceptions(CtMethod ctMethod) {
        CtClass etype = pool.get("java.lang.Exception")
        ctMethod.addCatch('{com.feelschaotic.javassist.Logger.print($e);return;}', etype)
    }

    private static void catchSpecifiedExceptions(CtMethod ctMethod, Set names, javassist.bytecode.annotation.Annotation javassistAnnotation) {
        names.each { def name ->

            ArrayMemberValue arrayMemberValues = (ArrayMemberValue) javassistAnnotation.getMemberValue(name)
            if (arrayMemberValues == null) {
                return
            }
            addMultiCatch(ctMethod, (ClassMemberValue[]) arrayMemberValues.getValue())
        }
    }

    private static void addMultiCatch(CtMethod ctMethod, ClassMemberValue[] classMemberValues) {
        classMemberValues.each { ClassMemberValue classMemberValue ->
            CtClass etype = pool.get(classMemberValue.value)
            ctMethod.addCatch('{ com.feelschaotic.javassist.Logger.print($e);return;}', etype)
        }
    }

複製程式碼

完成!寫個 demo 遛一遛:

一文應用 AOP | 最全選型考量 + 邊剖析經典開源庫邊實踐,美滋滋

可以看到應用沒有崩潰,logcat 列印出異常了。

一文應用 AOP | 最全選型考量 + 邊剖析經典開源庫邊實踐,美滋滋

完整demo請戳

後記

完成本篇過程曲折,最終成稿已經完全偏離當初擬定的大綱,本來想詳細記錄下 AOP 的應用,把每種方法都一步步實踐一遍,但在寫作的過程中,我不斷地質疑自己,這種步驟文全網都是,於自己於大家又有什麼意義? 想著把寫作方向改為 AOP 開源庫原始碼分析,但又難以避免陷入大段原始碼分析的泥潭中。

本文的初衷在於 AOP 的實踐,既然是實踐,何不拋棄語法細節,抽象流程,圖示步驟,畢竟學習完能真正吸收的一是魔鬼的細節,二是精妙的思想。

寫作本身就是一種思考,謹以警示自己。

相關文章