Android註解處理初探:使用註解處理器消除樣板程式碼

zhuhean發表於2017-11-15

我們平常在使用Java進行開發時,經常會需要寫很多重複冗餘的樣板程式碼(Boilerplate Code),Android開發中最常見的一種,就是findViewById了,如果一個介面有很多View,寫起來那叫一個要死要死。
於是神一樣的Jake Wharton開源了一個ButterKnife,讓我們從此跟findViewById撒喲啦啦。而ButterKnife的核心原理就是使用了註解處理器在編譯時期幫我們生成了這些樣板程式碼。
今天我們來初探一下,如何通過打造一個註解處理器來消除樣板程式碼。

1. 從一個例子開始

我們先來看這樣一個類:

public class User {
    String firstName;
    String lastName;
    String nickName;
    int age;

    public String getFirstName() {
        return firstName;
    }

    public String getLastName() {
        return lastName;
    }

    public String getNickName() {
        return nickName;
    }

    public int getAge() {
        return age;
    }
}複製程式碼

一個超簡單的類,就四個屬性跟它對應的Getter

這個類目前沒有建構函式,如果我們給它新增建構函式的話,可能要根據不同屬性過載好多個,很麻煩,這樣我們不如給他寫個Builder類:

public final class UserBuilder {
  private String firstName;
  private String lastName;
  private String nickName;
  private int age;

  public UserBuilder firstName(String firstName) {
    this.firstName = firstName;
    return this;
  }

  public UserBuilder lastName(String lastName) {
    this.lastName = lastName;
    return this;
  }

  public UserBuilder nickName(String nickName) {
    this.nickName = nickName;
    return this;
  }

  public UserBuilder age(int age) {
    this.age = age;
    return this;
  }

  public User build() {
    User user = new User();
    user.firstName = this.firstName;
    user.lastName = this.lastName;
    user.nickName = this.nickName;
    user.age = this.age;
    return user;
  }
}複製程式碼

可以看到,UserBuilder類中包含User類中的全部屬性,然後是屬性對應的Setter,最後還有一個build方法,用來建立User例項。

這時候假設你有另外一個類,你也想給它寫一個Builder類,你會發現,Builder類的寫法是固定的,屬性Setterbuild方法,這很顯然就是樣板程式碼了,既然這樣,那我們能不能通過某種方式自動給一個類生成他對應的Builder類呢?

當然可以啦,通過註解處理和程式碼生成就可以輕鬆實現。我們接下來分別詳細看看這兩部分。

2. 註解處理(Annotation Processing)

註解處理是javac的一部分,可以在編譯時期掃描註解並進行處理。
註解處理從Java 5就出現了,但直到Java 6才有了可用的API。

2.1 註解(Annotation)

既然是寫註解處理器,我們肯定需要先定義一個註解,然後再處理吧。

因此我們先宣告一個叫Builder的註解:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.SOURCE)
public @interface Builder {

}複製程式碼

宣告一個註解跟宣告一個介面很像,只不過註解的interface關鍵字前有個@符號。這個註解又使用了兩個Java提供的元註解。

第一個元註解@Target,用來指明當前註解型別的使用物件。
這裡用的是ElementType.TYPE,表示該註解可以用於類,介面,或是列舉。
除此之外Java還提供了:

  • ElementType.FIELD 指明註解可應用於屬性
  • ElementType.METHOD 指明註解可應用於方法
  • ElementType.PARAMETER 指明註解可應用於引數
  • ElementType.CONSTRUCTOR 指明註解可應用於建構函式
  • 還有很多,寄幾去看

第二個元註解@Retention,用來指明當前註解型別的保留機制,Java提供了三種註解保留機制:

  • RetentionPolicy.SOURCE 註解在編譯完後被拋棄,不會出現在.class檔案中。
  • RetentionPolicy.CLASS 註解保留在編譯後的.class檔案中,但不會被載入到JVM中,這是Java預設的保留機制。
  • RetentionPolicy.RUNTIME 註解保留在編譯後的.class檔案中,會被載入到JVM中,執行的時候可以通過反射獲取到。

這裡我們使用的是RetentionPolicy.SOURCE,因為原始碼中的註解處理完後,我們就不再需要了。

2.2 註解處理器(Annotation Processor)

實現一個自己的註解處理器,需要建立一個類並繼承AbstrctProcessor。需要注意的是,該類必須包含一個無參建構函式

public class BuilderProcessor extends AbstractProcessor {

}複製程式碼

我們這裡的註解處理器用來處理前面寫好的@Builder註解,因此就叫BuilderProcessor。繼承AbstrctProcessor類後,有下面這四個方法需要重寫:

public class BuilderProcessor extends AbstractProcessor {
    private Messager messager;
    private Elements elementUtils;
    private Filer filer;

    @Override
    public synchronized void init(ProcessingEnvironment processingEnv) {
        super.init(processingEnv);
        messager = processingEnv.getMessager();
        elementUtils = processingEnv.getElementUtils();
        filer = processingEnv.getFiler();
    }

    @Override
    public SourceVersion getSupportedSourceVersion() {
        return SourceVersion.latestSupported();
    }

    @Override
    public Set<String> getSupportedAnnotationTypes() {
        return Collections.singleton(Builder.class.getCanonicalName());
    }

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        for (Element element : roundEnv.getElementsAnnotatedWith(Builder.class)) {
            // ...
        }
        return true;
    }
}複製程式碼
  1. init(ProcessingEnvironment processingEnv)方法
    該方法用來初始化處理器,同時該方法傳入一個ProcessingEnvironment物件,我們可以從該物件獲取到一些工具類的例項,我們這裡獲取到了messager,elementUtils和filer。
    messager物件可以用來在註解處理過程中報錯,提出警告。
    elementUtils物件可以用來操作當前處理的元素。
    filer物件用來寫.java檔案.

  2. getSupportedSourceVersion()方法
    返回當前註解處理器支援的Java原始碼版本,這有點類似Android中的targetSdkVersion,所以一般返回最新的版本就可以,這裡返回了SourceVersion.latestSupported()

  3. getSupportedAnnotationTypes()方法
    返回當前註解處理器支援處理的全部註解。
    該方法的返回型別是一個String型別的Set集合,Set集合中每個元素應該是一個註解的完整全名(包名跟類名)。
    由於我們這個處理器只處理@Builder註解,因此返回了Collections.singleton(Builder.class.getCanonicalName())。
    singletonCollections類中的一個靜態方法,會返回一個SingletonSet物件。
    Builder.class.getCanonicalName()是獲取@Builder註解帶包名的完整全名。

  4. process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv)方法
    最重要的就是process方法了,因為這個方法實現了真正的註解處理生成程式碼的邏輯。
    該方法在處理的過程中可能會被呼叫好幾次。
    該方法包含兩引數,annotations和roundEnv,annotations是需要被處理的註解集合,roundEnv是Java提供的一個實現了RoundEnvironment介面的類的物件,該物件最常用的方法就是getElementsAnnotatedWith(Class<? extends Annotation> a)

2.3 註解處理器配置檔案

註解處理器需要註冊後才能使用,註冊的方法是在註解處理器模組的main資料夾下,建立一個叫resources的新資料夾,然後在該資料夾下建立一個META-INF資料夾,接著在META-INF資料夾下建立一個services資料夾,然後在services資料夾下建立一個叫javax.annotation.processing.Processor的檔案,最後往這個檔案裡新增你註解處理器類的全名。麻煩爆了啊有沒有!!!

好訊息是,我們可以使用Google開源的一個AutoService的庫,它會幫助我們自動生成這個配置檔案,使用起來也很簡單,先新增依賴:

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation project(':annotation')
    implementation 'com.google.auto.service:auto-service:1.0-rc3'
}複製程式碼

然後給我們的BuilderProcessor類加上AutoService註解就可以了:

@AutoService(Processor.class)
public class BuilderProcessor extends AbstractProcessor {
    //...
}複製程式碼

3. 程式碼生成(Code Generation)

程式碼生成即生成包含可執行Java原始碼的.java檔案。

我們可以直接拼接字串來生成程式碼,但這樣很麻煩,而且生成的原始碼出錯機率很大,因此這裡使用一個叫JavaPoet的庫來生成原始碼。

3.1 JavaPoet

JavaPoet是Square開源的一個用來生成.java原始碼的庫,它提供了非常流暢的API,寫起來也非常優雅,我們來簡單看一下它怎麼使用。

現在假設我們想生成一個超簡單的HelloWorld類:

package com.example.helloworld;

public final class HelloWorld {
  public static void main(String[] args) {
    System.out.println("Hello, JavaPoet!");
  }
}複製程式碼

使用JavaPoet對應的生成程式碼如下:

MethodSpec main = MethodSpec.methodBuilder("main")
    .addModifiers(Modifier.PUBLIC, Modifier.STATIC)
    .returns(void.class)
    .addParameter(String[].class, "args")
    .addStatement("$T.out.println($S)", System.class, "Hello, JavaPoet!")
    .build();

TypeSpec helloWorld = TypeSpec.classBuilder("HelloWorld")
    .addModifiers(Modifier.PUBLIC, Modifier.FINAL)
    .addMethod(main)
    .build();

JavaFile javaFile = JavaFile.builder("com.example.helloworld", helloWorld)
    .build();

javaFile.writeTo(System.out);複製程式碼

MethodSpec對應Java中的方法或是建構函式,TypeSpec對應類,介面或是列舉。除此之外JavaPoet還提供了FieldSpec來對應屬性,AnnotationSpec對應註解,ParameterSpec對應引數。

JavaFile對應一個包含頂層類的Java原始檔。

3.2 process方法的具體實現

初步瞭解了JavaPoet後,我們可以嘗試著來具體實現我們的process方法.

首先我們獲取所有含@Builder註解的元素:

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        for (Element element : roundEnv.getElementsAnnotatedWith(Builder.class)) {
            //...
        }
        return true;
    }複製程式碼

roundEnv.getElementsAnnotatedWith(Builder.class)的返回型別是Set<? extends Element>,即返回所有含@Builder註解的元素的集合。Element可以用來表示一個類,一個方法,或是一個變數,等等。

所以接下來我們判斷這個元素是不是一個類,不是的話我們就報錯:

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        for (Element element : roundEnv.getElementsAnnotatedWith(Builder.class)) {
            if (element.getKind() != ElementKind.CLASS) {
                onError("Builder annotation can only be applied to class", element);
                return false;
            }
            //...
        }
        return true;
    }複製程式碼

然後我們開始獲取一下接下來要用的引數,包括包名,當前元素的類名:

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        for (Element element : roundEnv.getElementsAnnotatedWith(Builder.class)) {
            if (element.getKind() != ElementKind.CLASS) {
                onError("Builder annotation can only be applied to class", element);
                return false;
            }

            String packageName = elementUtils.getPackageOf(element).getQualifiedName().toString();
            String elementName = element.getSimpleName().toString();
            ClassName builderClassName = ClassName.get(packageName, String.format("%sBuilder", elementName));

            //...
        }
        return true;
    }複製程式碼

這裡包名就是element表示的類所在的包名,elementName即element元素的類名,然後根據該類名,我們可以建立Builder的類名,比如說當前的類名是User,它的Builder的類名就是UserBuilder。

接下來我們使用JavaPoet逐步寫出生成程式碼。

首先一個Builder包含屬性和setter,因此我們先從element物件獲取它的全部屬性元素:

    private Set<Element> getFields(Element element) {
        Set<Element> fields = new LinkedHashSet<>();
        for (Element enclosedElement : element.getEnclosedElements()) {
            if (enclosedElement.getKind() == ElementKind.FIELD) {
                fields.add(enclosedElement);
            }
        }
        return fields;
    }複製程式碼

getFields方法從element中獲取全部型別為ElementKind.FIELD的元素並返回。
然後我們為這些元素生成JavaPoet對應的FieldSpec和MethodSpec:

private TypeSpec createTypeSpec(Element element, ClassName builderClassName, String elementName) {
        Set<Element> fieldElements = getFields(element);
        List<FieldSpec> fieldSpecs = new ArrayList<>(fieldElements.size());
        List<MethodSpec> setterSpecs = new ArrayList<>(fieldElements.size());
        for (Element field : fieldElements) {
            TypeName fieldType = TypeName.get(field.asType());
            String fieldName = field.getSimpleName().toString();
            FieldSpec fieldSpec = FieldSpec.builder(fieldType, fieldName, Modifier.PRIVATE).build();
            fieldSpecs.add(fieldSpec);
            MethodSpec setterSpec = MethodSpec
                    .methodBuilder(fieldName)
                    .addModifiers(Modifier.PUBLIC)
                    .returns(builderClassName)
                    .addParameter(fieldType, fieldName)
                    .addStatement("this.$N = $N", fieldName, fieldName)
                    .addStatement("return this")
                    .build();
            setterSpecs.add(setterSpec);
        }
    //...
}複製程式碼

屬性對應的的FieldSpec和Setter對應MethodSpec生成後,Builer類還差一個build方法,接下來我們就來生成這個代表build方法的MethodSpec物件:

    private TypeSpec createTypeSpec(Element element, ClassName builderClassName, String elementName) {
        //...
        TypeName elementType = TypeName.get(element.asType());
        String instanceName = Helper.toCamelCase(elementName);
        MethodSpec.Builder buildMethodBuilder = MethodSpec
                .methodBuilder("build")
                .addModifiers(Modifier.PUBLIC)
                .returns(elementType)
                .addStatement("$1T $2N = new $1T()", elementType, instanceName);
        for (FieldSpec fieldSpec : fieldSpecs) {
            buildMethodBuilder.addStatement("$1N.$2N = $2N", instanceName, fieldSpec);
        }
        buildMethodBuilder.addStatement("return $N", instanceName);
        MethodSpec buildMethod = buildMethodBuilder.build();
        //...
    }複製程式碼

然後建立TypeSpec物件並把前面這些屬性啊setter啊build方法都新增進來:

    private TypeSpec createTypeSpec(Element element, ClassName builderClassName, String elementName) {
        //...
        return TypeSpec
                .classBuilder(builderClassName)
                .addModifiers(Modifier.PUBLIC, Modifier.FINAL)
                .addFields(fieldSpecs)
                .addMethods(setterSpecs)
                .addMethod(buildMethod)
                .build();
    }複製程式碼

最後建立JavaFile物件並寫入到filer中:

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
            //...
            TypeSpec typeSpec = createTypeSpec(element, builderClassName, elementName);

            JavaFile javaFile = JavaFile.builder(packageName, typeSpec).build();
            try {
                javaFile.writeTo(filer);
            } catch (IOException e) {
                onError("Failed to write java file: " + e.getMessage(), element);
            }
        }
        return true;
    }複製程式碼

4.使用

註解和註解處理器都寫好了,接下來就非常簡單。
我們先給app模組的build.gradle新增上依賴:

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation project(':annotation')
    annotationProcessor project(':processor')
}複製程式碼

接著給原來的User類新增上我們的自定義註解@Builder

@Builder
public class User {
    // ...
}複製程式碼

然後我們在Android Studio的Build選單欄選擇Make Project,完了我們可以在app/build/generated/source/apt/debug資料夾下看到我們生成的UserBuilder.java原始碼。

接下來我們就可以在專案中直接使用生成的UserBuilder類了。

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        User me = new UserBuilder()
                .firstName("HeAn")
                .lastName("Zhu")
                .nickName("violet")
                .age(22)
                .build();
    }

}複製程式碼

5.原始碼

本文相應演示專案基於Android Studio 3.0開發,專案地址:github.com/zhuhean/Bui…

6.參考

相關文章