寫給Android工程師的AOP知識! (建議收藏)

語言: CN / TW / HK

 安卓進階漲薪訓練營 ,讓一部分人先進大廠

大家好,我是皇叔,最近開了一個安卓進階漲薪訓練營,可以幫助大家突破技術&職場瓶頸,從而度過難關,進入心儀的公司。

詳情見文章: 沒錯!皇叔開了個訓練營

作者:樹獺非懶 連結:https://juejin.cn/post/7067128525929021471

如果你接觸過Java後臺開發,一定聽過AOP的概念,它到底是什麼東西,對我們Android開發有什麼用呢?本部落格站在Android工程師的角度探索一下AOP這個熟悉又陌生的概念:

AOP是個啥

AOP是 Aspect Oriented Program 的首字母縮寫,翻譯過來就是面向切面程式設計。這個概念中面向容易理解,程式設計可以理解,最關鍵的是切面是指什麼?

在理解切面之前,先回顧一下我們熟悉的OOP(Object-Oriented Programming),面向物件程式設計。我們知道,面向物件的特點是繼承、多型和封裝。而封裝就要求將功能分散到不同的類中去,這在軟體設計中往往稱為職責分配,這樣做的好處是降低了程式碼的複雜程度,使類具備了可重用性。但是在分散程式碼的同時,也增加了程式碼的重複性。什麼意思呢?

舉個栗子,

假設我們要對兩個類的每個方法新增日誌。按面向物件的設計方法,我們就必須在這兩個類的方法中都加上日誌的程式碼。也許新增的日誌程式碼是完全相同的,但也正是因為面向物件的設計,讓類與類之間無法聯絡,而不能將這些重複的程式碼統一起來。

也許你想到了方法:我們可以將這段程式碼寫在一個獨立的類的某個方法裡(比如工具類),然後在兩個類中呼叫。但是,這樣一來,這兩個類和新增獨立的類就有耦合了,也就是說獨立類程式碼的改變會直接影響這兩個類。那麼,有沒有什麼辦法,可以不需要干涉到兩個類原本的關係而實現功能呢?

我們可以用抽象思維去思考這個問題,對類的每個方法新增日誌這個操作是一個相對更泛的操作。

它不同於對A類的B方法新增日誌這種偏向於具體的點的操作。所以它更像是對一個面來操作,這類操作就叫做切面。

一般而言,我們管切入到指定類指定方法的程式碼片段稱為切面,而切入到哪些類、哪些方法則叫切入點。有了AOP,我們就可以把幾個類共有的程式碼,抽取到一個切片中,等到需要時再切入物件中去,從而改變其原有的行為。

由於OOP的程式設計思路,並不能幫我們實現這類切面操作,而我們確實有這類需求,所以就有了AOP的概念, AOP像OOP一樣,只是一種程式設計正規化,它本身並沒有規定說一定要用什麼方式去實現。從上面一大串的解釋,也可以看出AOP其實是對OOP的一個補充。

簡單的概括上面的內容就是,面向切面程式設計可以對多個沒有關聯的類的某一類功能進行管理。

AOP的實現方式

靜態AOP

在編譯器,切面直接以位元組碼的形式編譯到目標位元組碼檔案中。

1.AspectJ

AspectJ屬於靜態AOP,它是在編譯時進行增強,會在編譯時期將AOP邏輯織入到程式碼中。

由於是在編譯器織入,所以它的優點是不影響執行時效能,缺點是不夠靈活。

2.AbstractProcessor

自定義一個AbstractProcessor,在編譯期去解析編譯的類,並且根據需求生成一個實現了特定介面的子類(代理類)

動態AOP

1.JDK動態代理

通過實現InvocationHandler介面,可以實現對一個類的動態代理,通過動態代理可以生成代理類,從而在代理類方法中,在執行被代理類方法前後,新增自己的實現內容,從而實現AOP。

2.動態位元組碼生成

在執行期,目標類載入後,動態構建位元組碼檔案生成目標類的子類,將切面邏輯加入到子類中,沒有介面也可以織入,但擴充套件類的例項方法為final時,則無法進行織入。比如Cglib

CGLIB 是一個功能強大,高效能的程式碼生成包。它為沒有實現介面的類提供代理,為JDK的動態代理提供了很好的補充。通常可以使用Java的動態代理建立代理,但當要代理的類沒有實現介面或者為了更好的效能,CGLIB是一個好的選擇。

3.自定義類載入器

在執行期,目標載入前,將切面邏輯加到目標位元組碼裡。如: Javassist

Javassist 是可以動態編輯Java位元組碼的類庫。它可以在Java程式執行時定義一個新的類,並載入到JVM中;還可以在JVM載入時修改一個類檔案。

4.ASM

ASM可以在編譯期直接修改編譯出的位元組碼檔案,也可以像Javassit一樣,在執行期,類檔案載入前,去修改位元組碼。

AspectJ的應用

AspectJ的介紹

AspectJ提供了 兩套 強大的機制:

第一套是切面語法。就是網上隨便一搜的AspectJ使用方法,它把決定是否使用切面的權利還給了切面。就是說在寫切面的時候就可以決定哪些類的哪些方法會被代理,從而從邏輯上不需要侵入業務程式碼。由於這套語法實在是太有名,導致很多人都誤以為AspectJ就是這一套切面語法,其實不然。

第二套是織入工具。上面介紹的切面語法能夠讓切面從邏輯上與業務程式碼解耦,但是從操作上來講,當JVM執行業務程式碼的時候,他無從得知旁邊還有個類想橫插一刀。解決思路就是在編譯期(或者類載入期)我們優先考慮一下切面程式碼,並將切面程式碼通過某種形式插入到業務程式碼中,這樣業務程式碼不就知道自己被“切”了麼?這種思路的一個實現就是aspectjweaver,就是這裡的織入工具。

AspectJ提供了兩套對切面的描述方法:

一種就是我們常見的 基於java註解 切面描述的方法,這種方法相容java語法,寫起來十分方便,不需要IDE的額外語法檢測支援。另外一種是 基於aspect檔案 的切面描述方法,這種語法本身並不是java語法,因此寫的時候需要IDE的外掛支援才能進行語法檢查。

AspectJ的使用方法

本文主要介紹基於java註解的這種常用使用方式。先了解下AspectJ提供的註解:

  • @Aspect
    AspectJ
    JPonit
    
  • @Pointcut :表示具體的切入點,可以確定具體織入程式碼的地方。可以通過通配、正則表示式等指定點
  • @Before :表示在呼叫點之前,呼叫該方法
  • @After :表示在呼叫點之後,再呼叫該方法
  • @Around :使用該方法代替該點的執行

Join Point 表示連線點,即 AOP 可織入程式碼的點:

Pointcuts 是具體的切入點,可以確定具體織入程式碼的地方。可以通過通配、正則表示式等指定點,常用的有:

區分execution和call:

  • execution: 用於匹配方法執行的連線點,意思是說,直接在方法內部的前或者後新增。
  • call: 呼叫匹配到的方法時呼叫

AspectJ在Android中的應用

AspectJ我們最常看到的是Spring中的應用,那麼在Android中有沒有它的用武之地呢?

那必然是有的,下面會用兩個栗子來看看有什麼應用場景會需要用到它

先準備好環境,需要使用 AspectJX 外掛: AspectJX 外掛地址

projectbuild.gradle 新增依賴

classpath 'com.hujiang.aspectjx:gradle-android-plugin-aspectjx:2.0.10'

module的 build.gradle 應用外掛

plugins {
id 'android-aspectjx'
}

AspectJX 是一個基於 AspectJ 並在此基礎上擴展出來可應用於Android開發平臺的 AOP 框架,可作用於java原始碼, class 檔案及jar包,同時支援kotlin的應用。

為什麼選用AspectJX而不是基礎的AspectJ或其他?

目前其他的AspectJ相關外掛和框架都不支援AAR或者JAR切入的,對於Kotlin更加無能為力(以下栗子均為kotlin實現)

栗子一:實現一種方式可防止View連續快速點選

相信客戶端的同學應該都會被測試這樣的手法摧殘過吧:快速點選很多次某按鈕導致xxx...

對於這個栗子來說,防止view快速點選這個操作就是一個切面,所以可以用AspectJ進行切面程式設計。

定義一個註解 @FastClickView ,引數 interval 表示多長時間內只有一次點選生效:

@Target(AnnotationTarget.FUNCTION)
@Retention(value = AnnotationRetention.RUNTIME)
annotation class FastClickView(val interval: Long = 3000L)

定義 FastClickViewAspect 類,把該類作為切面類:對該類新增 @Aspect 註解

確定切入點:只要使用了FastClickView註解的方法均生效。

使用execution匹配方法執行

// @com.example.aopdemo.FastClickView * *(..) 表示任何支援FastClickView註解的方法
@Pointcut("execution(@com.example.aopdemo.FastClickView * *(..))")
複製程式碼
完整程式碼如下:
@Aspect
class FastClickViewAspect {
@Pointcut("execution(@com.example.aopdemo.FastClickView * *(..))")
fun executeFastClickViewLimit() { }

@Around("executeFastClickViewLimit()")
@Throws(Throwable::class)
fun aroundExecuteFastClickViewLimit(joinPoint: ProceedingJoinPoint) {
Log.d(TAG, "aroundClickCountLimit: ")
val signature: MethodSignature = joinPoint.signature as MethodSignature
// 取出JoinPoint的方法
val method = signature.method
if (method.isAnnotationPresent(FastClickView::class.java)) {
val annotation: FastClickView? = method.getAnnotation(FastClickView::class.java)
annotation?.let {
val interval = annotation.interval
val view = joinPoint.args[0] as View
if (!FastClickCheckUtil.isFastClick(view, interval)) {
joinPoint.proceed()
}
}
}
}
}


object FastClickCheckUtil {
private const val TAG = "FastClickCheckUtil"
/**
* 判斷是否屬於快速點選
*
* @param view 點選的View
* @param interval 快速點選的閾值
* @return true:快速點選
*/

fun isFastClick(view: View, interval: Long): Boolean {
val key: Int = view.id
Log.d(TAG, "isFastClick: $view $interval")
val currentClickTime: Long = System.currentTimeMillis()
// 如果兩次點選間隔超過閾值,則不是快速點選
if (view.getTag(key) == null || currentClickTime - (view.getTag(key) as Long) > interval) {
// 儲存最近點選時間
view.setTag(key, currentClickTime)
return false
}else{
return true
}

}
}

// 測試:2s內防止快速點選
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_aop)
btnFastClick.setOnClickListener(object: View.OnClickListener{
@FastClickView(2000)
override fun onClick(view: View?) {
Log.d(TAG, "onClick: click me...")
}
})
}

栗子二:統計方法的耗時

在進行應用啟動優化時,我們需要對方法進行方法耗時的統計,常規做法是手動在方法前後裡新增程式碼,計算耗時時間。

這種方式侵入性強,且程式碼重複。此時使用AOP方式就很優雅:

定義註解 @TimeConsume :只要在方法上新增該註解就可以統計出方法耗時並通過日誌打印出來

@Target(AnnotationTarget.FUNCTION)
@Retention(value = AnnotationRetention.RUNTIME)
annotation class TimeConsume
複製程式碼
定義切面類TimeConsumeAspect:
切入點:任何支援TimeConsume註解的方法
切入時機:註解的方法前@Before和方法後@After
@Aspect
class TimeConsumeAspect {

companion object {
private const val TAG = "TimeConsumeAspect"
}

var startTime: Long = 0

@Pointcut("execution(@com.example.aopdemo.TimeConsume * *(..))")
fun methodTimeConsumePoint() {}

@Before("methodTimeConsumePoint()")
fun doBefore(joinPoint: JoinPoint) {
val signature: MethodSignature = joinPoint.signature as MethodSignature
val method = signature.method
Log.d(TAG, "doBefore: $method")
startTime = System.currentTimeMillis()
}

@After("methodTimeConsumePoint()")
fun doAfter() {
val endTime = System.currentTimeMillis()
val consumeTime = endTime - startTime
Log.d(TAG, "開始於${startTime},結束於$endTime, 耗時 $consumeTime ms")
}
}

測試程式碼:

//test: 
@TimeConsume
override fun onStart() {
try {
Thread.sleep(3000)
}catch (e: Exception){
e.printStackTrace()
}
super.onStart()
}

@TimeConsume
override fun onResume() {
super.onResume()
}

檢視列印結果:

D/TimeConsumeAspect: doBefore: protected void com.example.aopdemo.MainActivity.onStart()
D/TimeConsumeAspect: 開始於1645418155237,結束於1645418158240, 耗時 3003 ms
D/TimeConsumeAspect: doBefore: protected void com.example.aopdemo.MainActivity.onResume()
D/TimeConsumeAspect: 開始於1645418158247,結束於1645418158263, 耗時 16 ms

demo地址:https://github.com/Kingwentao/AopDemo.git

  • AOP在Android的其他應用

  • AOP在Android的應用場景還有很多,比如:

APT的應用:Dagger2、ButterKnife、ARouter Javassist:熱更新

參考部落格

以下部落格對本文提供了很大的幫助:

  • 原生AspectJ用法分析

  • AspectJ in Android (二),AspectJ 語法

  • Spring 之AOP AspectJ切入點語法詳解

為了防止失聯,歡迎關注我防備的小號

                                       微信改了推送機制,真愛請星標本公號 :point_down: