繁多的 AOP 方法該如何選擇?應用的步驟過於繁瑣,語法概念看得頭暈腦脹?java
本文將詳細展現選型種種考量維度,更是砍掉 2 個經典開源庫的枝節,取其主幹細細體會 AOP 的應用思想和關鍵流程。一邊實踐 AOP 一邊還能掌握開源庫,豈不快哉!android
在上文 最全面 AOP 方法探討 中,咱們分析對比了最熱門的幾種 AOP 方法。那麼,在實際狀況和業務需求中,咱們該怎麼考量選擇呢?git
若是你正在維護一個現有的項目,你要麼小範圍試用,要麼就須要選擇一個侵入性小的 AOP 方法(如:APT 代理類生效時機須要手動調用,靈活,但在插入點繁多狀況下侵入性太高)。程序員
第一步,考慮一下切入點的數量和類似性,你是否願意一個個在切點上面加註解,仍是用類似性統一切。github
第二步,考慮下這些應用切面的類有沒有被 final 修飾,同時類似的方法有沒有被 static 或 final 修飾時。 final 修飾的類就不能經過 cglib 生成代理,cglib 會繼承被代理類,須要重寫被代理方法,因此被代理類和方法不能是 final。apache
我怎麼選擇織入(Weave)的時機?編譯期間織入,仍是編譯後?載入時?或是運行時?經過比較各大 AOP 方法在織入時機方面的不一樣和優缺點,來得到對於如何選擇 Weave 時機進行斷定的準則。api
對於普通的狀況而言,在編譯時進行 Weave 是最爲直觀的作法。由於源程序中包含了應用的全部信息,這種方式一般支持最多種類的聯結點。利用編譯時 Weave,咱們可以使用 AOP 系統進行細粒度的 Weave 操做,例如讀取或寫入字段。源代碼編譯以後造成的模塊將喪失大量的信息,所以一般採用粗粒度的 AOP 方法。數組
同時,對於傳統的編譯爲本地代碼的語言如 C++ 來講,編譯完成後的模塊每每跟操做系統平臺相關,這就給創建統一的載入時、運行時 Weave 機制形成了困難。對於編譯爲本地代碼的語言而言,只有在編譯時進行 Weave 最爲可行。儘管編譯時 Weave 具備功能強大、適應面普遍等優勢,但他的缺點也很明顯。首先,它須要程序員提供全部的源代碼,所以對於模塊化的項目就力不從心了。bash
爲了解決這個問題,咱們能夠選擇支持編譯後 Weave 的 AOP 方法。app
新的問題又來了,若是程序的主邏輯部分和 Aspect 做爲不一樣的組件開發,那麼最爲合理的 Weave 時機就是在框架載入 Aspect 代碼之時。
運行時 Weave 多是全部 AOP 方法中最爲靈活的,程序在運行過程當中能夠爲單個的對象指定是否須要 Weave 特定的方面。
選擇合適的 Weave 時機對於 AOP 應用來講是很是關鍵的。針對具體的應用場合,咱們須要做出不一樣的抉擇。咱們也能夠結合多種 AOP 方法,從而得到更爲靈活的 Weave 策略。
除了動態 Hook 方法,其餘的 AOP 方法對性能影響幾乎能夠忽略不計。動態 AOP 本質使用了動態代理,不可避免要用到反射。而 APT 不可避免地要生成大量的代理類和方法。如何權衡,就看你對項目的要求。
若是隻是想特定地加強能力,可使用 APT,在編譯期間讀取 Java 代碼,解析註解,而後動態生成 Java 代碼。
下圖是Java編譯代碼的流程:
能夠看到,APT 工做在 Annotation Processing 階段,最終經過註解處理器生成的代碼會和源代碼一塊兒被編譯成 Java 字節碼。不過比較遺憾的是你不能修改已經存在的 Java 文件,好比在已經存在的類中添加新的方法或刪除舊方法,因此經過 APT 只能經過輔助類的方式來實現注入,這樣會略微增長項目的方法數和類數,不過只要控制好,不會對項目有太大的影響。
APT 的時機須要主動調用,而其餘 AOP 方法注入代碼的調用時機和切入點的調用時機一致。
AOP 的實踐都寫爛了,市面上有太多講怎麼實踐 AOP 的博文了。那這篇和其餘的博文有什麼不一樣呢?有什麼可讓你們受益的呢?
其實 AOP 實踐很簡單,關鍵是理解並應用,咱們先參考開源庫的實踐,在這基礎上去抽象關鍵步驟,一邊實戰一邊達成閱讀開源庫任務,美滋滋!
直接上圖說明。
在上面的過程當中,你能夠看到,爲何用 @Bind 、 @OnClick 等註解標註的屬性、方法必須是 public 或 protected? 由於ButterKnife 是經過 被代理類引用.this.editText 來注入View的。爲何要這樣呢? 答案就是:性能 。若是你把 View 和方法設置成 private,那麼框架必須經過反射來注入。
想深刻到源碼細節瞭解 ButterKnife 更多?
咱們去掉細節,抽出關鍵流程,看看 ButterKnife 是怎麼應用 APT 的。
能夠看到關鍵步驟就幾項:
咱們標出重點,也就是咱們須要實現的步驟。以下:
咦,你可能發現了,最後一個步驟是在合適的時機去調用代理類或門面對象。這就是 APT 的缺點之一,在任意包位置自動生成代碼可是運行時卻須要主動調用。
APT 手把手實現可參考 JavaPoet - 優雅地生成代碼——3.2 一個簡單示例
APT 中咱們用到了如下 3 個工具:
Java Annotation Tool 給了咱們一系列 API 支持。
你固然能夠直接經過字符串拼接的方式去生成 java 源碼,怎麼簡單怎麼來,一張圖 show JavaPoet 的厲害之處。
註解處理器已經有了,那麼怎麼執行它?這個時候就須要用到 android-apt 這個插件了,使用它有兩個目的:
項目引入了 butterknife 以後就無需引入 apt 了,若是繼續引入會報
Using incompatible plugins for the annotation processing
想要運行註解處理器,須要繁瑣的步驟:
Google 開發的 AutoService 能夠減小咱們的工做量,只須要在你定義的註解處理器上添加 @AutoService(Processor.class) ,就能自動完成上面的步驟,簡直不能再方便了。
雖然前面有說過 APT 並不能像 Aspectj 同樣實現代碼插入,可是可使用變種方式實現。用註解修飾一系列方法,由 APT 來代理執行。此部分可參考CakeRun
APT 生成的代理類按照必定次序依次執行修飾了註解的初始化方法,而且在其中增長了一些邏輯判斷,來決定是否要執行這個方法。從而繞過發生 Crash 的類。
J 神的框架一如既往小而美,想啃開源庫源碼,能夠先從 J 神的開源庫先讀起。
回到正題,hugo是 J 神開發的 Debug 日誌庫,包含了優秀的思想以及流行的技術,例如註解、AOP、AspectJ、Gradle 插件、android-maven-gradle-plugin 等。在進行 hugo 源碼解讀以前,你須要首先對這些知識點有必定的瞭解。
先上工做流程圖,咱們再講細節:
只須要一個 @DebugLog
註解,hugo就能幫咱們打印入參出參、統計方法耗時。自定義註解很好理解,咱們重點看看切面 Hugo 是怎麼處理的。
有沒有發現什麼?
沒錯,切點表達式幫助咱們描述具體要切入哪裏。
AspectJ 的切點表達式由關鍵字和操做參數組成,以切點表達式 execution(* helloWorld(..))
爲例,其中 execution
是關鍵字,爲了便於理解,一般也稱爲函數,而* helloWorld(..)
是操做參數,一般也稱爲函數的入參。切點表達式函數的類型不少,如方法切點函數,方法入參切點函數,目標類切點函數等,hugo 用到的有兩種類型:
函數名 | 類型 | 入參 | 說明 |
---|---|---|---|
execution() | 方法切點函數 | 方法匹配模式字符串 | 表示全部目標類中知足某個匹配模式的方法鏈接點,例如 execution(* helloWorld(..)) 表示全部目標類中的 helloWorld 方法,返回值和參數任意 |
within() | 目標類切點函數 | 類名匹配模式字符串 | 表示知足某個匹配模式的特定域中的類的全部鏈接點,例如 within(com.feelschaotic.demo.*) 表示 com.feelschaotic.demo 中的全部類的全部方法 |
想詳細入門 AspectJ 語法?
- 看AspectJ在Android中的強勢插入
- 深刻理解Android之AOP 語法講得很是詳細
咱們引入 hugo 只須要 3 步。
不是說 AspectJ 在 Android 中很不友好?!說好的須要使用 andorid-library gradle 插件在編譯時作一些 hook,使用 AspectJ 的編譯器(ajc,一個java編譯器的擴展)對全部受 aspect 影響的類進行織入,在 gradle 的編譯 task 中增長一些額外配置,使之能正確編譯運行等等等呢?
這些 hugo 已經幫咱們作好了(因此步驟 2 中,咱們引入 hugo 的同時要使用 hugo 的 Gradle 插件,就是爲了 hook 編譯)。
抽象一下 hugo 的工做流程,咱們獲得了 2 種Aspect工做流程:
前面選擇合適的 AOP 方法第 2 點咱們提到,以 Pointcut 切入點做爲區分,AspectJ 有兩種用法:
//方法一:一個個在切入點上面加註解
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 {
//...
}
}
複製代碼
//方法二:根據類似性統一切,不須要再使用註解標記了
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 {
//...
}
}
複製代碼
APT 決定是否使用切面的權利仍然在業務代碼中,而 AspectJ 將決定是否使用切面的權利還給了切面。在寫切面的時候就能夠決定哪些類的哪些方法會被代理,從邏輯上不須要侵入業務代碼。
可是AspectJ 的使用須要匹配一些明確的 Join Points,若是 Join Points 的函數命名、所在包位置等因素改變了,對應的匹配規則沒有跟着改變,就有可能致使匹配不到指定的內容而沒法在該地方插入本身想要的功能。
那 AspectJ 的執行原理是什麼?注入的代碼和目標代碼是怎麼鏈接的?請戳:會用就好了?你知道 AOP 框架的原理嗎?
爲何用 Javassist 來實踐?
由於實踐過程當中咱們能夠順帶掌握字節碼插樁的技術基礎,就算是後續學習熱修復、應用 ASM,這些基礎都是通用的。雖然 Javassist 性能比 ASM 低,但對新手很友好,操縱字節碼卻不須要直接接觸字節碼技術和了解虛擬機指令,由於 Javassist 實現了一個用於處理源代碼的小型編譯器,能夠接收用 Java 編寫的源代碼,而後將其編譯成 Java 字節碼再內聯到方法體中。
話很少說,咱們立刻上手,在上手以前,先了解幾個概念:
Javassist 修改對象是編譯後的 class 字節碼。那首先咱們得知道何時編譯完成,才能在 .class 文件被轉爲 .dex 文件以前去作修改。
大多 Android 項目使用 Gradle 構建,咱們須要先理解 Gradle 的工做流程。Gradle 是經過一個一個 Task 執行完成整個流程的,依次執行完 Task 後,項目就打包完成了。 其實 Gradle 就是一個裝載 Task 的腳本容器。
那 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。
思考一下,若是一個 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!
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 便可。
因此咱們須要怎麼作?流程總結以下:
既然說了這麼多,是時候實戰了,每次看到項目代碼裏充斥着防範性 try-catch,我就
咱們照着流程圖,一步步來實現這個自動 try-Catch 功能:
apply plugin: 'groovy'
repositories {
jcenter()
}
dependencies {
compile 'com.android.tools.build:gradle:2.3.3'
compile 'org.javassist:javassist:3.20.0-GA'
}
複製代碼
新建 groovy 目錄
新建 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 插件。
跑一下:
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注入的需求,暫不擴展
}
}
複製代碼
回到咱們剛剛定義的 PathPlugin,在 apply 方法中註冊 PathTransfrom:
def android = project.extensions.findByType(AppExtension)
android.registerTransform(new PathTransform(project))
複製代碼
clean 項目,再跑一次,確保沒有報錯。
接着就是重頭戲了,咱們新建一個 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;
}
複製代碼
接着咱們要在 TryCatchInject
的 traverseMethod
方法 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 遛一遛:
能夠看到應用沒有崩潰,logcat 打印出異常了。
後記
完成本篇過程曲折,最終成稿已經徹底偏離當初擬定的大綱,原本想詳細記錄下 AOP 的應用,把每種方法都一步步實踐一遍,但在寫做的過程當中,我不斷地質疑本身,這種步驟文全網都是,於本身於你們又有什麼意義? 想着把寫做方向改成 AOP 開源庫源碼分析,但又難以免陷入大段源碼分析的泥潭中。
本文的初衷在於 AOP 的實踐,既然是實踐,何不拋棄語法細節,抽象流程,圖示步驟,畢竟學習完能真正吸取的一是魔鬼的細節,二是精妙的思想。
寫做自己就是一種思考,謹以警示本身。