1. 程式人生 > >一文讀懂 AOP | 你想要的最全面 AOP 方法探討

一文讀懂 AOP | 你想要的最全面 AOP 方法探討

AOP系列思維導圖

前前言

相信大家在入門 AOP 時,常常被繁多的術語、方法和框架繞暈。AOP 好像有點耳熟?Javaseopt 是個什麼?Javassist 又是啥?Dexposed、APT 也是 AOP?本篇將輔助你快速理清概念,掌握 AOP 思想,找到最適合自己業務場景的 AOP 方法。

前言

上文 也談程式碼 —— 重構兩年前的程式碼 中,我們提到最佳的系統架構由模組化的關注面領域組成,每個關注面均用純 Java 物件實現。不同的領域之間用最不具有侵害性的「方面」或「類方面」工具整合起來。

反思自己的專案,有很多模組沒有做到恰當地切分關注面,往往在業務邏輯中耦合了業務埋點、許可權申請、登陸狀態的判斷、對不可預知異常 try-catch 和一些持久化操作。

雖說保證程式碼最簡單化和可執行化很有必要,但我們還是可以嘗試小範圍的重構。就如「程式碼整潔之道」中所說:通過方面式的手段切分關注面的威力不可低估,假如你能用 POJO 編寫應用程式的領域邏輯,在程式碼層面與架構關注面分離開,就有可能真正地用測試來驅動架構。

這裡的切分關注面的思想就是 AOP。


一、AOP即面向切向程式設計

AOP 是 Aspect Oriented Programming 的縮寫,譯為面向切向程式設計。用我們最常用的 OOP 來對比理解:

縱向關係 OOP,橫向角度 AOP

舉個小例子:

設計一個日誌列印模組。按 OOP 思想,我們會設計一個列印日誌 LogUtils 類,然後在需要列印的地方引用即可。

public class ClassA {
    private void initView() {
        LogUtils.d(TAG, "onInitView");
    }
}

public class ClassB {
    private void onDataComplete(Bean bean) {
        LogUtils.d(TAG, bean.attribute);
    }
}

public class ClassC {
    private void onError() {
        LogUtils.e(TAG, "onError"
); } } 複製程式碼

看起來沒有任何問題是吧?

但是這個類是橫跨並嵌入眾多模組裡的,在各個模組裡分散得很厲害,到處都能見到。從物件組織角度來講,我們一般採用的分類方法都是使用類似生物學分類的方法,以「繼承」關係為主線,我們稱之為縱向,也就是 OOP。設計時只使用 OOP思想可能會帶來兩個問題:

  1. 物件設計的時候一般都是縱向思維,如果這個時候考慮這些不同類物件的共性,不僅會增加設計的難度和複雜性,還會造成類的介面過多而難以維護(共性越多,意味著介面契約越多)。

  2. 需要對現有的物件 動態增加 某種行為或責任時非常困難。

而AOP就可以很好地解決以上的問題,怎麼做到的?除了這種縱向分類之外,我們從橫向的角度去觀察這些物件,無需再去到處呼叫 LogUtils 了,宣告哪些地方需要列印日誌,這個地方就是一個切面,AOP 會在適當的時機為你把列印語句插進切面。

// 只需要宣告哪些方法需要列印 log,列印什麼內容
public class ClassA {
    @Log(msg = "onInitView")
    private void initView() {
    }
}

public class ClassB {
    @Log(msg = "bean.attribute")
    private void onDataComplete(Bean bean) {
    }
}

public class ClassC {
    @Log(msg = "onError")
    private void onError() {
    }
}
複製程式碼

如果說 OOP 是把問題劃分到單個模組的話,那麼 AOP 就是把涉及到眾多模組的某一類問題進行統一管理。AOP的目標是把這些功能集中起來,放到一個統一的地方來控制和管理。利用 AOP 思想,這樣對業務邏輯的各個部分進行了隔離,從而降低業務邏輯各部分之間的耦合,提高程式的可重用性,提高開發效率。

OOP 與 AOP 的區別

  1. 面向目標不同:簡單來說 OOP 是面向名詞領域,AOP 面向動詞領域。

  2. 思想結構不同:OOP 是縱向結構,AOP 是橫向結構。

  3. 注重方面不同:OOP 注重業務邏輯單元的劃分,AOP 偏重業務處理過程中的某個步驟或階段。

OOP 與 AOP 的聯絡

兩者之間是一個相互補充和完善的關係。


二、應用場景

那AOP既然這麼有用,除了上面提到的列印日誌場景,還有沒有其他用處呢?

當然有!

只要系統的業務模組都需要引用通用模組,就可以使用AOP。以下是一些常用的業務場景:

1. 引數校驗和判空

系統之間在進行介面呼叫時,往往是有入參傳遞的,入參是介面業務邏輯實現的先決條件,有時入參的缺失或錯誤會導致業務邏輯的異常,大量的異常捕獲無疑增加了介面實現的複雜度,也讓程式碼顯得雍腫冗長,因此提前對入參進行驗證是有必要的,可以提前處理入引數據的異常,並封裝好異常轉化成結果物件返回給呼叫方,也讓業務邏輯解耦變得獨立。

2. Android API23+的許可權控制

避免到處都是申請許可權和處理許可權的程式碼

3. 無痕埋點

4. 安全控制

比如全域性的登入狀態流程控制。

5. 日誌記錄

6. 事件防抖

防止View被連續點選觸發多次事件

7. 效能統計

檢測方法耗時其實已經有一些現成的工具,比如 trace view。痛點是這些工具使用起來都比較麻煩,效率低下,而且無法針對某一個塊程式碼或者某個指定的sdk進行檢視方法耗時。可以採用 AOP 思想對每個方法做一個切點,在執行之後列印方法耗時。

8. 事務處理

宣告方法,為特定方法加上事務,指定情況下(比如丟擲異常)回滾事務

9. 異常處理

替代防禦性的 try-Catch。

10. 快取

快取某方法的返回值,下次執行該方法時,直接從快取裡獲取。

11. 軟體破解

使用 Hook 修改軟體的驗證類的判斷邏輯。

12. 熱修復

AOP 可以讓我們在執行一個方法的前插入另一個方法,運用這個思路,我們可以把有 bug 的方法替換成我們下發的新方法。


三、AOP 方法

本篇為入門篇,重在理解 AOP 思想和應用,輔助你快速進行 AOP 方法選型,所以 AOP 方法這塊暫不會深入原理和術語。

Android AOP 常用的方法有 JNI HOOK 和 靜態織入。

動態織入 Hook 方式

在執行期,目標類載入後,為介面動態生成代理類,將切面植入到代理類中。相對於靜態AOP更加靈活。但切入的關注點需要實現介面。對系統有一點效能影響。

  1. Dexposed

  2. Xposed

  3. epic 在 native 層修改 java method 對應的 native 指標

動態位元組碼生成

  1. Cglib + Dexmaker

Cglib 是一個強大的,高效能的 Code 生成類庫, 原理是在執行期間目標位元組碼載入後,通過位元組碼技術為一個類建立子類,並在子類中採用方法攔截的技術攔截所有父類方法的呼叫,順勢織入橫切邏輯。由於是通過子類來代理父類,因此不能代理被 final 欄位修飾的方法。

但是 Cglib 有一個很致命的缺點:底層是採用著名的 ASM 位元組碼生成框架,使用位元組碼技術生成代理類,也就是通過操作位元組碼來生成的新的 .class 檔案,而我們在 Android 中載入的是優化後的 .dex 檔案,也就是說我們需要可以動態生成 .dex 檔案代理類,因此 Cglib 不能在 Android 中直接使用。有大神根據 Dexmaker 框架(dex程式碼生成工具)來仿照 Cglib 庫動態生成 .dex 檔案,實現了類似於 Cglib 的 AOP 的功能。詳細的用法可參考:將cglib動態代理思想帶入Android開發

靜態織入方式

在編譯期,切面直接以位元組碼的形式編譯到目標位元組碼檔案中。對系統無效能影響。但靈活性不夠。

  1. APT

  2. AspectJ

  3. ASM

  4. Javassist

  5. DexMaker

  6. ASMDEX

這麼多方法?有什麼區別?

方法作用期比對

一圖勝千言

AOP 方法作用時期比對.png

AOP 是思想,上面的方法其實都是工具,只不過是插入時機和方式不同。

同:都可以織入邏輯,都體現了 AOP 思想 異:作用的時機不一樣,且適用的註解的型別不一樣。

方法優缺點、難點比對

方法 作用時機 操作物件 優點 缺點 為了上手,我需要掌握什麼?
APT 編譯期:還未編譯為 class 時 .java 檔案 1. 可以織入所有類;2. 編譯期代理,減少執行時消耗 1. 需要使用 apt 編譯器編譯;2. 需要手動拼接代理的程式碼(可以使用 Javapoet 彌補);3. 生成大量代理類 設計模式和解耦思想的靈活應用
AspectJ 編譯期、載入時 .java 檔案 功能強大,除了 hook 之外,還可以為目標類新增變數,介面。也有抽象,繼承等各種更高階的玩法。 不夠輕量級 複雜的語法,但掌握幾個簡單的,就能實現絕大多數場景
Javassist 編譯期:class 還未編譯為 dex 時或執行時 class 位元組碼 1. 減少了生成子類的開銷;2. 直接操作修改編譯後的位元組碼,直接繞過了java編譯器,所以可以做很多突破限制的事情,例如,跨 dex 引用,解決熱修復中 CLASS_ISPREVERIFIED 問題。 執行時加入切面邏輯,產生效能開銷。 1. 自定義 Gradle 外掛;2. 掌握groovy 語言
ASM 編譯期或執行期位元組碼注入 class 位元組碼 小巧輕便、效能好,效率比Javassist高 學習成本高 需要熟悉位元組碼語法,ASM 通過樹這種資料結構來表示複雜的位元組碼結構,並利用 Push 模型來對樹進行遍歷,在遍歷過程中對位元組碼進行修改。
ASMDEX 編譯期和載入時:轉化為 .dex 後 Dex 位元組碼,建立 class 檔案 可以織入所有類 學習成本高 需要對 class 檔案比較熟悉,編寫過程複雜。
DexMaker 同ASMDEX Dex 位元組碼,建立 dex 檔案 同ASMDEX 同ASMDEX 同ASMDEX
Cglib 執行期生成子類攔截方法 位元組碼 沒有介面也可以織入 1. 不能代理被final欄位修飾的方法;2. 需要和 dexmaker 結合使用 --
xposed 執行期hook -- 能hook自己應用程序的方法,能hook其他應用的方法,能hook系統的方法 依賴三方包的支援,相容性差,手機需要root --
dexposed 執行期hook -- 只能hook自己應用程序的方法,但無需root 1. 依賴三方包的支援,相容性差;2. 只能支援 Dalvik 虛擬機器 --
epic 執行期hook -- 支援 Dalvik 和 Art 虛擬機器 只適合在開發除錯中使用,碎片化嚴重有相容性問題 --

四、常用的 AOP 方法介紹

業務中常用的 AOP 方式為靜態織入,接下來詳細介紹靜態織入中最常用的三種方式:APT、AspectJ、Javassist。

1. APT

APT (Annotation Processing Tool )即註解處理器,是一種處理註解的工具,確切的說它是 javac 的一個工具,它用來在編譯時掃描和處理註解。註解處理器以 Java 程式碼( 或者編譯過的位元組碼)作為輸入,生成 .java 檔案作為輸出。簡單來說就是在編譯期,通過註解生成 .java 檔案。使用的 Annotation 型別是 SOURCE。

代表框架:DataBinding、Dagger2、ButterKnife、EventBus3、DBFlow、AndroidAnnotation

為什麼這些框架註解實現 AOP 要使用 APT?

目前 Android 註解解析框架主要有兩種實現方法,一種是執行期通過反射去解析當前類,注入相應要執行的方法。另一種是在編譯期生成類的代理類,在執行期直接呼叫代理類的代理方法,APT 指的是後者。

如果不使用APT基於註解動態生成 java 程式碼,那麼就需要在執行時使用反射或者動態代理,比如大名鼎鼎的 butterknife 之前就是在執行時反射處理註解,為我們例項化控制元件並新增事件,然而這種方法很大的一個缺點就是用了反射,導致 app 效能下降。所以後面 butterknife 改為 apt 的方式,可以留意到,butterknife 會在編譯期間生成一個 XXX_ViewBinding.java。雖然 APT 增加了程式碼量,但是不再需要用反射,也就無損效能。

APT 的缺點改進

效能問題解決了,又帶來新的問題了。我們在處理註解或元資料檔案的時候,往往有自動生成原始碼的需要。難道我們要手動拼接原始碼嗎?不不不,這不符合程式碼的優雅,JavaPoet 這個神器就是來解決這個問題的。

JavaPoet

JavaPoet 是 square 推出的開源 java 程式碼生成框架,提供 Java Api 生成 .java 原始檔。這個框架功能非常有用,我們可以很方便的使用它根據註解、資料庫模式、協議格式等來對應生成程式碼。通過這種自動化生成程式碼的方式,可以讓我們用更加簡潔優雅的方式要替代繁瑣冗雜的重複工作。本質上就是用建造者模式來替代手工拼寫原始檔。

JavaPoet詳細用法可參考:javapoet——讓你從重複無聊的程式碼中解放出來

2. AspectJ

目前最好、最方便、最火的 AOP 實現方式當屬 AspectJ,它是一種幾乎和 Java 完全一樣的語言,而且完全相容 Java。

但是在 Android 上整合 AspectJ 是比較複雜的。

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

有很多庫幫助我們完成這些工作,可以方便快捷接入 AspectJ。

AspectJ 框架選型

大小 相容性 缺點 備註
Hugo -- -- 不支援AAR或JAR切入 --
gradle-android-aspectj-plugin -- -- 無法相容databinding,不支援AAR或JAR切入 該庫已經棄用
AspectJx(推薦) 44kb 會和有transform功能的外掛衝突,如:retroLambda 在前兩者基礎上擴充套件支援AAR, JAR及Kotlin的應用 僅支援annotation的方式,不支援 *.aj 檔案的編譯

3. Javassist

代表框架:熱修復框架HotFix 、Savior(InstantRun)

Javassist 是一個編輯位元組碼的框架,作用是修改編譯後的 class 位元組碼,ASM也有這個功能,不過 Javassist 的 Java 風格 API 要比 ASM 更容易上手。

既然是修改編譯後的 class 位元組碼,首先我們得知道什麼時候編譯完成,並且我們要在 .class檔案被轉為 .dex 檔案之前去做修改。在 Gradle Transfrom 這個 api 出來之前,想要監聽專案被打包成 .dex 的時機,就必須自定義一個 Gradle Task,插入到 predex 或者 dex 之前,在這個自定義的 Task 中使用 Javassist 或者 ASM 對 class 位元組碼進行操作。而 Transform 更為方便,我們不再需要插入到某個Task前面。Tranfrom 有自己的執行時機,一經註冊便會自動新增到 Task 執行序列中,且正好是 class 被打包成dex之前。


五、總結

AOP 重在理解這種思想:

  1. 先考慮要在什麼期間插入程式碼,選用合適的 AOP 方法;
  2. 找準切入點也就是程式碼可注入的點,比如一個方法的呼叫處或者方法內部;
  3. 接著考慮怎麼過濾方法,找到注入點的描述,比如注入到所有onClick方法:call(* view.on|Click(..));
  4. 接著要考慮以怎樣的方式處理程式碼,是在程式碼執行前?執行後?還是包裹程式碼?還是替換目的碼?

任何的技術都需要有業務依託和落地,後續將會推出AOP實踐篇,一步步實現 AOP 應用落地,敬請期待。


六、還想了解更多?

博文推薦

書籍推薦