Android 註解系列之APT工具(三)

AndyJennifer發表於2018-10-29

該文章中涉及的程式碼,我已經提交到GitHub上了,大家按需下載---->原始碼

前言

在上篇文章Android 註解系列之Annotation(二)中,簡要的介紹了註解的基本使用與定義。同時也提出了以下幾個問題,當我們宣告瞭一個註解後,是不是需要手動找到所有的Class物件或Field、Method?怎麼通過註解生成新的類的定義呢?當面對這些問題的時候,我相信大家的第一反應肯定會想,"有不有相應的三方庫呢?Java是否提供了相應庫或者方法來解決呢?",當然Java肯定給我們提供了啦,就是我們既陌生又熟悉的APT工具啦。

為什麼這裡我會說既陌生又熟悉呢?我相信對於大多數安卓程式,我們都或多或少使用了一些主流庫,如Dagger2、ButterKnife、EventBus等,這些庫都使用了APT技術。既然大佬們都在使用,那我們怎麼不去了解呢?好了,書歸正傳,下面我們就來看看怎麼通過APT來處理之前我們提到的問題。

APT技術簡介

在具體瞭解APT技術之前,先簡單的對其進行介紹。APT(Annotation Processing Tool)是javac中提供的一種編譯時掃描和處理註解的工具,它會對原始碼檔案進行檢查,並找出其中的註解,然後根據使用者自定義的註解處理方法進行額外的處理。APT工具不僅能解析註解,還能根據註解生成其他的原始檔,最終將生成的新的原始檔與原來的原始檔共同編譯(注意:APT並不能對原始檔進行修改操作,只能生成新的檔案,例如在已有的類中新增方法)。具體流程圖如下圖所示:

apt使用流程圖.png

APT技術使用規則

APT技術的使用,需要我們遵守一定的規則。大家先看一下整個APT專案專案構建的一個規則圖,具體如下所示:

apt_rule.png

APT使用依賴

從圖中我們可以整個APT專案的構建需要三個部分:

  • 註解處理器庫(包含我們的註解處理器)
  • 註解宣告庫(用於儲存宣告的註解)
  • 實際使用APT的Android/Java專案

且三個部分的依賴關係為註解處理工具依賴註解宣告庫Android/Java專案同時依賴註解處理工具庫與註解宣告庫

為什麼把註解處理器獨立抽成一個庫呢?

對於Android專案預設是不包含 APT相關類的。所以要使用APT技術,那麼就必須建立一個Java Library。對於Java專案,獨立抽成一個庫,更容易維護與擴充套件。

為什麼把註解宣告也單獨抽成一個庫,而不放到註解處理工具庫中呢?

舉個例子,如果註解宣告與註解處理器為同一個庫,如果有開發者希望把我們的註解處理器用於他的專案中,那麼就必須包含註解宣告與整個註解處理器的程式碼,我們能非常確定是,他並不希望已經編譯好的專案中包含處理器相關的程式碼。他僅僅希望使用我們的註解。所以將註解處理器與註解分開單獨抽成一個庫時非常有意義的。接下來的文章中會具體會描述有哪些方法可以將我們的註解處理器不打包在我們的實際專案中。

註解處理器的宣告

在瞭解了ATP的使用規則後,現在我們再來看看怎麼宣告一個註解處理器,每一個註解處理器都需要承AbstractProcessor類,具體程式碼如下所示:

class MineProcessor extends AbstractProcessor {

    @Override
    public synchronized void init(ProcessingEnvironment processingEnv) {}
    
    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        return false;
    }

    @Override
    public SourceVersion getSupportedSourceVersion() { }

    @Override
    public Set<String> getSupportedAnnotationTypes() { }
}
複製程式碼
  • init(ProcessingEnvironment processingEnv):每個註解處理器被初始化的時候都會被呼叫,該方法會被傳入ProcessingEnvironment 引數。ProcessingEnvironment 能提供很多有用的工具類,Elements、Types和Filer。後面我們將會看到詳細的內容。
  • process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv):註解處理器實際處理方法,一般要求子類實現該抽象方法,你可以在在這裡寫你的掃描與處理註解的程式碼,以及生成Java檔案。其中引數RoundEnvironment ,可以讓你查詢出包含特定註解的被註解元素,後面我們會看到詳細的內容。
  • getSupportedAnnotationTypes(): 返回當前註解處理器處理註解的型別,返回值為一個字串的集合。其中字串為處理器需要處理的註解的合法全稱
  • getSupportedSourceVersion():用來指定你使用的Java版本,通常這裡返回SourceVersion.latestSupported()。如果你有足夠的理由指定某個Java版本的話,你可以返回SourceVersion.RELAEASE_XX。但是還是推薦使用前者。

在Java1.6版本中提供了SupportedAnnotationTypesSupportedSourceVersion兩個註解來替代getSupportedSourceVersiongetSupportedAnnotationTypes兩個方法,也就是這樣:

@SupportedSourceVersion(SourceVersion.RELEASE_6)
@SupportedAnnotationTypes({"合法註解的名稱"})
class MineProcessor extends AbstractProcessor {

    @Override
    public synchronized void init(ProcessingEnvironment processingEnv) {
    }

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        return false;
    }
    
}

複製程式碼

這裡需要注意的是以上提到的兩個註解是JAVA 1.6新增的,所以出於相容性的考慮,建議還是直接重寫getSupportedSourceVersion()getSupportedAnnotationTypes()方法。

註冊註解處理器

到了現在我們基本瞭解了處理器宣告,現在我們可能會有個疑問,怎麼樣將註解處理器註冊到Java編譯器中去呢?你必須提供一個.jar檔案,就像其他.jar檔案一樣,你需要打包你的註解處理器到此檔案中,並且在你的jar中,你需要打包一個特定的檔案javax.annotation.processing.ProcessorMETA-INF/services路徑下。就像下面這樣:

META-INF/services 相當於一個資訊包,目錄中的檔案和目錄獲得Java平臺的認可與解釋用來配置應用程式、擴充套件程式、類載入器和服務檔案,在jar打包時自動生成

放入特定資料夾.png

其中javax.annotation.processing.Processor檔案中的內容為每個註解處理器的合法的全名列表,每一個元素換行分割,也就是類似下面這樣:

com.jennifer.andy.processor.MineProcessor1
com.jennifer.andy.processor.MineProcessor2
com.jennifer.andy.processor.MineProcessor3
複製程式碼

最後我們只要將你生成的.jar放到你的buildPath中,那麼Java編譯器會自動的檢查和讀取javax.annotation.processing.Processor中的內容,並註冊該註解處理器。

當然對於現在我們的編譯器,如IDEA、AndroidStudio等中,我們只建立相應檔案與資料夾就行了,並不同用放在buildPath中去。當然原因是這些編譯器都幫我們處理了啦。如果你還是嫌麻煩,那我們可以使用Google為我們提供的AutoService 註解處理器,用於生成META-INF/services/javax.annotation.processing.Processor檔案的。也就是我們可以像下面這樣使用:

@SupportedSourceVersion(SourceVersion.RELEASE_6)
@SupportedAnnotationTypes({"合法註解的名稱"})
@AutoService(Processor.class)
class MineProcessor extends AbstractProcessor {

    @Override
    public synchronized void init(ProcessingEnvironment processingEnv) {
    }

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        return false;
    }
}
複製程式碼

我們只需要在類上宣告@AutoService(Processor.class),那麼就不用考慮其他的東西啦。是不是很方便呢?(當然使用AutoService在Gralde中你需要新增依賴compile 'com.google.auto.service:auto-service:1.0-rc2')。

註解處理器的掃描

在註解處理過程中,我們需要掃描所有的Java原始檔,原始碼的每一個部分都是一個特定型別的Element,也就是說Element代表原始檔中的元素,例如包、類、欄位、方法等。整體的關係如下圖所示:

element繼承關係.png

  • Parameterizable:表示混合型別的元素(不僅只有一種型別的Element)
  • TypeParameterElement:帶有泛型引數的類、介面、方法或者構造器。
  • VariableElement:表示欄位、常量、方法或建構函式。引數、區域性變數、資源變數或異常引數。
  • QualifiedNameable:具有限定名稱的元素
  • ExecutableElement:表示類或介面的方法、建構函式或初始化器(靜態或例項),包括註釋型別元素。
  • TypeElement :表示類和介面
  • PackageElement:表示包

那接下來我們通過下面的例子來具體的分析:

package com.jennifer.andy.aptdemo.domain;//PackageElement
class Person {//TypeElement 
    private String where;//VariableElement
    
    public void doSomething() { }//ExecutableElement
    
    public void run() {//ExecutableElement
        int runTime;//VariableElement
    }
}
複製程式碼

通過上述例子我們可以看出,APT對整個原始檔的掃描。有點類似於我們解析XML檔案(這種結構化文字一樣)。

既然在掃描的時候,原始檔是一種結構化的資料,那麼我們能不能獲取一個元素的父元素和子元素呢?。當然是可以的啦,舉例來說,假如我們有個public class Person的TypeElement元素,那麼我們可以遍歷它的所有的孩子元素。

TypeElement person= ... ;  
for (Element e : person.getEnclosedElements()){ // 遍歷它的孩子 
    Element parent = e.getEnclosingElement();  // 拿到孩子元素的最近的父元素
}
複製程式碼

其中getEnclosedElements()getEnclosingElement()Element中介面的宣告,想了解更多的內容,大家可以檢視一下原始碼。

元素種類判斷

現在我們已經瞭解了Element元素的分類,但是我們發現Element有時會代表多種元素。例如TypeElement代表類或介面,那有什麼方法具體區別呢?我們繼續看下面的例子:

public class SpiltElementProcessor extends AbstractProcessor {
    @Override
    public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {
	    //這裡通過獲取所有包含Who註解的元素set集合
        Set<? extends Element> elements = roundEnvironment.getElementsAnnotatedWith(Who.class);
        for (Element element : elements) {
            if (element.getKind() == ElementKind.CLASS) {//如果元素是類

            } else if (element.getKind() == ElementKind.INTERFACE) {//如果當前元素是介面

            }
        }
        return false;
    }
	...省略部分程式碼
}
複製程式碼

在上述例子中,我們通過roundEnvironment.getElementsAnnotatedWith(Who.class)獲取原始檔中所有包含@Who註解的元素,通過呼叫element.getKind()具體判斷當前元素種類,其中具體元素型別為ElementKind列舉型別ElementKind列舉宣告如下表所示:

列舉型別 種類
PACKAGE
ENUM 列舉
CLASS
ANNOTATION_TYPE 註解
INTERFACE 介面
ENUM_CONSTANT 列舉常量
FIELD 欄位
PARAMETER 引數
LOCAL_VARIABLE 本地變數
EXCEPTION_PARAMETER 異常引數
METHOD 方法
CONSTRUCTOR 建構函式
OTHER 其他
省略... 省略...

元素型別判斷

那接下來大家又會有一個問題了,既然我們在掃描的是獲取的元素且這些元素代表著原始檔中的結構化資料。那麼假如我們想獲得元素更多的資訊怎麼辦呢?例如對於某個類,現在我們已經知道了其為ElementKind.CLASS種類,但是我想獲取其父類的資訊,需要通過什麼方式呢?對於某個方法,我們也同樣知道了其為ElementKind.METHOD種類,那麼我想獲取該方法的返回值型別、引數型別、引數名稱,需要通過什麼方式呢?

當然Java已經為我們提供了相應的方法啦。使用mirror API就能解決這些問題啦,它能使我們在未經編譯的原始碼中檢視方法、域以及型別資訊。在實際使用中通過TypeMirror來獲取元素型別。看下面的例子:

public class TypeKindSpiltProcessor extends AbstractProcessor {

    @Override
    public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {
        Set<? extends Element> elements = roundEnvironment.getElementsAnnotatedWith(Who.class);
        for (Element element : elements) {
            if (element.getKind() == ElementKind.METHOD) {//如果當前元素是介面
                ExecutableElement methodElement = (ExecutableElement) element;
                TypeMirror returnType = methodElement.getReturnType();//獲取TypeMirror
                TypeKind kind = returnType.getKind();//獲取元素型別
                System.out.println("print return type----->" + kind.toString());
            }
        }
        return false;
    }

}
複製程式碼

觀察上述程式碼我們可以發現,當我們使用註解處理器時,我們會先找到相應的Element,如果你想獲得該Element的更多的資訊,那麼可以配合TypeMirror使用TypeKind來判斷當前元素的型別。當然對於不同種類的Element,其獲取的TypeMirror方法可能會不同。TypeKind列舉宣告如下表所示:

列舉型別 型別
BOOLEAN boolean 型別
BYTE byte 型別
SHORT short 型別
INT int 型別
LONG long 型別
CHAR char 型別
FLOAT float 型別
DOUBLE double 型別
VOID void型別,主要用於方法的返回值
NONE 無型別
NULL 空型別
ARRAY 陣列型別
省略... 省略...

元素可見性修飾符

在註解處理器中,我們不僅能獲得元素的種類和資訊,我們還能獲取該元素的可見性修飾符(例如public、private等)。我們可以直接呼叫Element.getModifiers(),具體程式碼如下所示:

public class GetModifiersProcessor extends AbstractProcessor {

    @Override
    public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {
        Set<? extends Element> elements = roundEnvironment.getElementsAnnotatedWith(Who.class);
        for (Element element : elements) {
            if (element.getKind() == ElementKind.CLASS) {//如果元素是類
                Set<Modifier> modifiers = element.getModifiers();//獲取可見性修飾符
                if (!modifiers.contains(Modifier.PUBLIC)) {//如果當前類不是public
                    throw new ProcessingException(classElement, "The class %s is not public.",
                            classElement.getQualifiedName().toString());
                }
            }
        return false;
    }
}
複製程式碼

在上述程式碼中Modifer為列舉型別,具體列舉如下所示:

public enum Modifier {

    /** The modifier {@code public} */          PUBLIC,
    /** The modifier {@code protected} */       PROTECTED,
    /** The modifier {@code private} */         PRIVATE,
    /** The modifier {@code abstract} */        ABSTRACT,
    /**
     * The modifier {@code default}
     * @since 1.8
     */
     DEFAULT,
    /** The modifier {@code static} */          STATIC,
    /** The modifier {@code final} */           FINAL,
    /** The modifier {@code transient} */       TRANSIENT,
    /** The modifier {@code volatile} */        VOLATILE,
    /** The modifier {@code synchronized} */    SYNCHRONIZED,
    /** The modifier {@code native} */          NATIVE,
    /** The modifier {@code strictfp} */        STRICTFP;
}

複製程式碼

錯誤處理

在註解處理器的自定義中,我們不僅能呼叫相關方法獲取原始檔中的元素資訊,還能通過處理器提供的Messager來報告錯誤、警告以及提示資訊。可以直接使用processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, msg);需要注意的是它並不是處理器開發中的日誌工具,而是用來寫一些資訊給使用此註解庫的第三方開發者的。也就是說如果我們像傳統的Java應用程式丟擲一個異常的話,那麼執行註解處理器的JVM就會崩潰,並且關於JVM中的錯誤資訊對於第三方開發者並不是很友好,所以推薦並且強烈建議使用Messager。就像下面這樣,當我們判斷某個類不是public修飾的時候,我們通過Messager來報告錯誤。

註解處理器是執行它自己的虛擬機器JVM中。是的,你沒有看錯,javac啟動一個完整Java虛擬機器來執行註解處理器。

public class GetModifiersProcessor extends AbstractProcessor {

    @Override
    public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {
        Set<? extends Element> elements = roundEnvironment.getElementsAnnotatedWith(Who.class);
        for (Element element : elements) {
            if (element.getKind() == ElementKind.CLASS) {//如果元素是類
                Set<Modifier> modifiers = element.getModifiers();//獲取可見性修飾符
                if (!modifiers.contains(Modifier.PUBLIC)) {//如果當前類不是public
	                roundEnvironment.getMessager().printMessage(Diagnostic.Kind.ERROR, "the class is not public");
                }
            }
        return false;
    }
}
複製程式碼

同時,在官方文件中,描述了訊息的不同級別,關於更多的訊息級別,大家可以通過從Diagnostic.Kind列舉中檢視。

檔案生成

到了現在我們已經基本瞭解整個APT的基礎知識。現在來講講APT技術如何生成新的類的定義(也就是建立新的原始檔)。對於建立新的檔案,我們並不用像基本檔案操作一樣,通過呼叫IO流來進行讀寫操作。而是通過JavaPoet來構造原始檔。(當然當你使用JavaPoet時,在gradle中你需要新增依賴compile 'com.google.auto.service:auto-service:1.0-rc2'),JavaPoet的使用也非常簡單,就像下面這樣:

當進行註釋處理或與後設資料檔案(例如,資料庫模式、協議格式)互動時,JavaPoet對於原始檔的生成可能非常有用。通過生成程式碼,消除了編寫樣板的必要性,同時也保持了後設資料的單一來源。

@AutoService(Processor.class)
@SupportedAnnotationTypes("com.jennifer.andy.apt.annotation.Who")
@SupportedSourceVersion(SourceVersion.RELEASE_7)
public class CreateFileByJavaPoetProcessor extends AbstractProcessor {

    @Override
    public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {
        createFileByJavaPoet(set, roundEnvironment);
        return false;
    }
    
    /**
     * 通過JavaPoet生成新的原始檔
     */
    private void createFileByJavaPoet(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        //建立main方法
        MethodSpec main = MethodSpec.methodBuilder("main")
                .addModifiers(Modifier.PUBLIC, Modifier.STATIC)//設定可見性修飾符public static
                .returns(void.class)//設定返回值為void
                .addParameter(String[].class, "args")//新增引數型別為String陣列,且引數名稱為args
                .addStatement("$T.out.println($S)", System.class, "Hello, JavaPoet!")//新增語句
                .build();
        //建立類
        TypeSpec helloWorld = TypeSpec.classBuilder("HelloWorld")
                .addModifiers(Modifier.PUBLIC, Modifier.FINAL)
                .addMethod(main)//將main方法新增到HelloWord類中
                .build();

        //建立檔案,第一個引數是包名,第二個引數是相關類
        JavaFile javaFile = JavaFile.builder("com.jennifer.andy.aptdemo.domain", helloWorld)
                .build();

        try {
            //建立檔案
            javaFile.writeTo(processingEnv.getFiler());
        } catch (IOException e) {
            log(e.getMessage());
        }

    }

    /**
     * 呼叫列印語句而已
     */
    private void log(String msg) {
        processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, msg);
    }

}
複製程式碼

當我們build上述程式碼後,我們可以在我們的build目錄下得到下列檔案:

生成檔案結果.png

關於JavaPoet的更多的詳細使用,大家可以參考官方文件-------->JavaPoet

分離處理器和專案

在上文中描述的APT使用規則中,我們是將註解宣告庫註解處理器庫分成了兩個庫,具體原因我也做了詳細的解釋,現在我們來思考如下問題。就算我們把兩個庫都抽成了兩個獨立的庫,但是如果有開發者想把我們自定義的註解處理器用於他的專案中,那麼他整個專案的編譯就必須也要把註解處理器與註解宣告庫包括進來。對於開發者來說,他們並不希望已經編譯好的專案中有包含註解處理器的相關程式碼。所以將註解宣告庫與註解處理器庫不打包進入專案是非常有必要的!!換句話說,註解處理器只在編譯處理期間需要用到,編譯處理完後就沒有實際作用了,而主專案新增了這個庫會引入很多不必要的檔案。

因為作者我本身是Android開發人員,所以以下都是針對Android專案展開討論。

使用android-apt

anroid-apt是Hugo Visser開發的一個Gradle外掛,該外掛的主要作用有如下兩點:

  • 允許只將編譯時註釋處理器配置為依賴項,而不在最終APK或庫中包括工件
  • 設定源路徑,以便Android Studio能正確地找到註釋處理器生成的程式碼

但是 Google爸爸看到別人這個功能功能不錯,所以為自己的Android Gradle 外掛也新增了名為annotationProcessor 的功能來完全代替 android-apt,既然官方支援了。那我們就去看看annotationProcessor的使用吧。

annotationProcessor使用

其實annotationProcessor的使用也非常簡單,分為兩種型別,具體使用如下程式碼所示:

 annotationProcessor project(':apt_compiler')//如果是本地庫
 annotationProcessor 'com.jakewharton:butterknife-compiler:9.0.0-rc1'//如果是遠端庫
複製程式碼

總結

整個APT的流程下來,自己也查閱了非常多的資料,也解決了許多問題。雖然寫部落格也花了非常多的時間。但是自己也發現了很多有趣的問題。我發現查閱的相關資料都會有一個通病。也就是沒有真正搞懂android apt與annotationProcessor的具體作用。所以這裡這裡也要告誡大家,對於網上的資料,自己一定要帶著懷疑與疑問的態度去瀏覽

同時個人覺得Gradle這一塊的知識點也非常重要。因為關於怎麼不把庫打包到實際專案中也是構建工具的特性與功能。希望大家有時間,一定要學習下相關Gradle知識。作者最近也在學習呢。和我一起加油吧~

該文章中涉及的程式碼,我已經提交到GitHub上了,大家按需下載---->原始碼

最後

該文章參考以下部落格與圖書,站在巨人的肩膀上。可以看得更遠。

ANNOTATION PROCESSING 101

自定義註解之編譯時註解(RetentionPolicy.CLASS)

你必須知道的APT、annotationProcessor、android-apt、Provided、自定義註解

相關文章