作者簡介:ASCE1885, 《Android 高級進階》作者。
本文由于潛在的商業目的,未經授權不開放全文轉載許可,謝謝!
本文分析的源碼版本已經 fork 到我的 Github。
在 Android 性能調優中,通常存在需要對方法的執行時間進行統計的需求,這樣就可以看出哪些方法耗時多,是系統的瓶頸。最容易想到的方案是在每個方法的開頭處獲取系統時間,在方法的結尾處再次獲取系統時間,前后兩個時間戳的差值就是這個方法執行所消耗的總時間。這個方案雖然簡單易懂,但實際操作起來要寫很多樣板代碼,同時對原有的代碼浸入性太高。那么有沒有更好的方案實現方法的性能監控呢?當然是有的,它就是本文的主角:hugo。
hugo 也是 Android 平臺著名的日志框架,跟 timber 一樣出自 JakeWharton 之手。在《Android 高級進階》一書的《面向切面編程及其在 Android 中的應用》一節中其實已經介紹過 hugo 相關內容,本文會再做拓展,對 hugo 源碼做更詳細的剖析。
基本用法
在介紹 hugo 的核心原理前,有必要先了解其基本用法。hugo 以 gradle 插件的形式供開發者集成和使用,分為兩步:
- 在項目全局添加對 hugo 插件的依賴
- 在需要使用 hugo 的 module 中應用 hugo 插件
buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath 'com.jakewharton.hugo:hugo-plugin:1.2.1' // 添加 Hugo 的 Gradle 插件依賴
}
}
apply plugin: 'com.jakewharton.hugo' // 應用 Hugo 插件
就這么簡單,之后這個插件會幫我們下載一些依賴庫,分別是:
- aspectjrt.jar:aspectJ 運行時的依賴庫,想要使用 aspectJ 的功能都需要引入這個庫
- hugo-annotations:hugo 的注解庫,定義了
DebugLog
這個注解,后面會介紹到 - hugo-runtime:hugo 的運行時庫,是實現 hugo 日志功能的核心庫
hugo 的使用很簡單,在需要進行日志記錄的類名或者方法名處使用 @DebugLog
注解標記即可,之后 hugo 就會在編譯時織入(weaving)打印日志的代碼,從而省去了開發者手動編寫日志代碼的繁瑣。例如下面這個方法使用 @DebugLog
注解:
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
printArgs("The", "Quick", "Brown", "Fox");
}
@DebugLog
private void printArgs(String... args) {
for (String arg : args) {
Log.i("Args", arg);
}
}
在程序運行的時候會打印出下面的日志信息,其中 ? printArgs(args=["The", "Quick", "Brown", "Fox"])
和 ? printArgs [0ms]
是 Hugo 這個函數庫為我們自動添加的日志信息。
com.asce1885.hugodemo V/MainActivity: ? printArgs(args=["The", "Quick", "Brown", "Fox"])
com.asce1885.hugodemo I/Args: The
com.asce1885.hugodemo I/Args: Quick
com.asce1885.hugodemo I/Args: Brown
com.asce1885.hugodemo I/Args: Fox
com.asce1885.hugodemo V/MainActivity: ? printArgs [0ms]
通過查看編譯后生成的 .class
文件(位于 build/intermediates/classes/類所在包名
中),可以看到 printArgs
方法經過 AspectJ 框架的代碼織入后,已經面目全非了:
@DebugLog
private void printArgs(String... args) {
JoinPoint var7 = Factory.makeJP(ajc$tjp_0, this, this, args);
Hugo var10000 = Hugo.aspectOf();
Object[] var8 = new Object[]{this, args, var7};
var10000.logAndExecute((new HugoActivity$AjcClosure1(var8)).linkClosureAndJoinPoint(69648));
}
核心知識點
hugo 這個框架麻雀雖小但五臟俱全,它使用了很多 Android 開發中流行的技術,例如注解,AOP,AspectJ,Gradle 插件等。在進行 hugo 源碼解讀之前,你需要首先對這些知識點有一定的了解。
注解
注解是 Java 語言的特性之一,它是在源代碼中插入的標簽,這些標簽在后面的編譯或者運行過程中起到某種作用,每個注解都必須通過注解接口 @interface 進行聲明,接口的方法對應著注解的元素。
元注解,注解的一種類型,顧名思義,就是用來定義和實現注解的注解,總共有如下五種,在 hugo 中會用到 @Target
和 @Retention
這兩個元注解,我們來做個簡單的介紹。
-
@Target:這個注解的取值是一個
ElementType
類型的數組,用來指定注解所適用的對象范圍,總共有十種不同的類型,根據定義的注解進行靈活的組合,如下所示(加粗的三個元素類型是 hugo 用到的,可重點關注):
元素類型 | 適用于 |
---|---|
ANNOTATION_TYPE | 注解類型聲明 |
CONSTRUCTOR | 構造函數 |
FIELD | 實例變量 |
LOCAL_VARIABLE | 局部變量 |
METHOD | 方法 |
PACKAGE | 包 |
PARAMETER | 方法參數或者構造函數的參數 |
TYPE | 類(包含enum)和接口(包含注解類型) |
TYPE_PARAMETER | 類型參數 |
TYPE_USE | 類型的用途 |
-
@Retention:用來指明注解的訪問范圍,也就是在什么級別保留注解,有如下三種選擇:
- 源碼級注解:在定義注解接口時,使用
@Retention(RetentionPolicy.SOURCE)
修飾的注解,該類型的注解信息只會保留在.java
源碼里,源碼經過編譯后,注解信息會被丟棄,不會保留在編譯好的.class
文件中 - 編譯時注解:在定義注解接口時,使用
@Retention(RetentionPolicy.CLASS)
修飾的注解,該類型的注解信息會保留在.java
源碼里和.class
文件里,在執行的時候,會被 Java 虛擬機丟棄,不會加載到虛擬機中,hugo 就是用的這一種 - 運行時注解:在定義注解接口時,使用
@Retention(RetentionPolicy.RUNTIME)
修飾的注解,Java 虛擬機在運行期也保留注解信息,可以通過反射機制讀取注解的信息(.java
源碼、.class
文件和執行的時候都有注解的信息)
- 源碼級注解:在定義注解接口時,使用
未指定類型時,默認是 CLASS 類型。
更多關于注解的相關知識點,可以參考《Android 高級進階》中的《注解在 Android 中的應用》一節。
AOP
AOP,全稱為 Aspect Oriented Programming,即面向切面編程。AOP 是軟件開發中的一個編程范式,通過預編譯方式或者運行期動態代理等實現程序功能的統一維護的一種技術,它是 OOP(面向對象編程)的延續,利用 AOP 開發者可以實現對業務邏輯中的不同部分進行隔離,從而進一步降低耦合,提高程序的可復用性,進而提高開發的效率。AOP 能夠實現將日志紀錄,性能統計,埋點統計,安全控制,異常處理等代碼從具體的業務邏輯代碼中抽取出來,放到統一的地方進行處理。AOP 涉及到的基本概念有:
- 橫切關注點(Cross-cutting concerns):在面向對象編程中,經常需要在不同的模塊代碼中添加一些類似的代碼,例如在函數入口處打印日志,在 View 的點擊處添加點擊事件的埋點統計,或者對一個函數進行性能監控,查看它的執行耗時等等,在 AOP 中把軟件系統分成兩個部分:核心關注點和橫切關注點,核心關注點就是業務邏輯處理的主要流程,而橫切關注點就是上面所說的經常發生在核心關注點的多個地方,且基本相似的日志紀錄,埋點統計等等。
- 連接點(Joint point):在核心關注點中可能會存在橫切關注點的地方,例如方法調用的入口,View 的點擊處理等地方,在 AOP 中習慣稱為連接點。
- 增強(Advice):特定連接點處所執行的動作,也就是 AOP 織入的代碼,目的是對原有代碼進行功能的增強,典型的有:
- before:在目標方法執行之前的動作
- around:在目標方法之前前后的動作
- after:在目標方法執行之后的動作
- 切入點(Pointcut):連接點的集合,這些連接點可以確定什么時機會觸發一個通知。切入點通常使用正則表達式或者通配符語法表示,可以指定執行某個方法,也可以指定多個方法,例如指定標記了某個注解的所有方法。
- 切面(Aspect):切入點和通知可以組合成一個切面。
- 織入(Weaving):將通知注入到連接點的過程。
AOP 中代碼的織入根據類型的不同,主要可以分為三類:
- 編譯時織入:在 Java 類文件編譯的時候進行織入,這需要通過特定的編譯器來實現,例如使用 AspectJ 的織入編譯器。
- 類加載時織入:通過自定義類加載器 ClassLoader 的方式在目標類被加載到虛擬機之前進行類的字節代碼的增強。
- 運行時織入:切面在運行的某個時刻被動態織入,基本原理是使用 Java 的動態代理技術。
hugo 使用到的代碼織入屬于編譯時織入,用到了 AspectJ 這樣一個面向切面的框架,它擴展了 Java 語言,定義了一套 AOP 語法,實現了一個專門的編譯器來在編譯期生成遵守 Java 字節碼規范的 .class
文件。
AspectJ
AspectJ 框架主要包含三部分內容:
- aspectjrt:運行時函數庫,AOP 所需要用到的
- aspectjtools:工具庫
- aspectjweaver:實現織入功能的函數庫
AspectJ 涉及的知識點比較多,可以獨立成書,這里我們只介紹 hugo 使用到的相關知識點,主要包括切點表達式,類型匹配通配符,邏輯運算符,增強類型等。
切點表達式
AspectJ 的切點表達式由關鍵字和操作參數組成,以切點表達式 execution(* helloWorld(..))
為例,其中 execution
是關鍵字,為了便于理解,通常也稱為函數,而 * helloWorld(..)
是操作參數,通常也稱為函數的入參。切點表達式函數的類型很多,例如方法切點函數,方法入參切點函數,目標類切點函數等,hugo 用到的有兩種類型:
- 方法切點函數之一
execution()
- 目標類切點函數之一
within()
具體涵義如下表所示:
函數名 | 入參 | 說明 |
---|---|---|
execution() | 方法匹配模式字符串 | 表示所有目標類中滿足某個匹配模式的方法連接點,例如 execution(* helloWorld(..)) 表示所有目標類中的 helloWorld 方法,返回值和參數任意 |
within() | 類名匹配模式字符串 | 表示滿足某個匹配模式的特定域中的類的所有連接點,例如 within(com.asce1885.debug.*) 表示 com.asce1885.debug 中的所有類的所有方法 |
接下來我們來介紹這兩個切入點函數入參的語法格式,先來看 execution()
的入參語法格式:
execution([注解] [修飾符] 返回值類型 方法名(參數列表) [異常列表])
其中,[] 號中的簽名組件是可選的。
對于 execution(* *(..))
這個切入點而言,
- 第一個
*
號對應方法的返回值,*
號表示方法返回值是任意的; - 第二個
*
號對應方法名,*
表示可以匹配該類中的所有方法; -
(..)
表示方法的參數是任意的
within()
函數入參語法格式如下:
within(類匹配模式)
可以看出,execution()
和 within()
兩者的主要區別是 within()
所指定的連接點最小范圍只能到類,而 execution()
所指定的連接點可以實現包,類,方法,方法入參范圍全覆蓋。
類型匹配通配符
AspectJ 切點表達式中的操作參數支持通配符,有三種類型的通配符可供選擇,具體的涵義如下表所示:
通配符 | 涵義 |
---|---|
* | 匹配任意字符,但只能匹配上下文中的一個元素 |
.. | 匹配任意字符,可以匹配上下文中多個元素,比如在目標類模式的匹配中,表示匹配任意數量的子包;在方法參數模式的匹配中,表示匹配任意數量的參數 |
+ | 匹配指定類型的子類型,只能作為后綴放在類名后面 |