Android 中優雅地使用註解

薛定貓的諤發表於2018-02-28

概述

註解(Annotation),是原始碼中特殊的語法後設資料,類、方法、變數、引數都可以被註解。利用註解可以標記原始碼以便編譯器為原始碼生成文件和檢查程式碼,也可以讓編譯器和註解處理器在編譯時根據註解自動生成程式碼,甚至可以保留到執行時以便改變執行時的行為。 Java 內建了一些註解,如 @Override 註解用來表明該方法是重寫父類方法,編譯器會負責檢查該方法與父類方法的宣告是否一致。@Deprecated 註解用來表明該元素已經被廢棄不建議使用了。@SuppressWarnings 註解用來表示編譯器可以忽略特定警告。
註解型別的宣告和介面的宣告類似,不過需要使用 @interface 和元註解(用來定義註解的註解)描述,每個方法宣告定義了註解型別的一個元素,且方法宣告不能包含任何引數或 throws,方法的返回型別必須是原語型別、StringClass、列舉、註解和這些型別的陣列,方法可以有預設值,如:

public @interface RequestForEnhancement {
    int    id();
    String synopsis();
    String engineer() default "[unassigned]"; 
    String date();    default "[unimplemented]"; 
}
複製程式碼

定義完註解型別後,就可以用它去註解一些宣告瞭。註解是一種特殊的修飾符,可以像 publicstaticfinal 修飾符一樣使用,不過通常註解要寫在這些修飾符之前。使用時為 @ 符號加註解型別加元素值對列表並用括號括起來,如:

@RequestForEnhancement(
    id       = 2868724,
    synopsis = "Enable time-travel",
    engineer = "Mr. Peabody",
    date     = "4/1/3007"
)
public static void travelThroughTime(Date destination) { ... }
複製程式碼

註解型別也可以沒有方法/元素,被稱為標記註解型別,如:

public @interface Preliminary { }

@Preliminary public class TimeTravel { ... }
複製程式碼

如果註解型別只有一個元素,那麼元素應該命名為 value,使用時也就可以忽略元素名和等號了,如:

public @interface Copyright {
    String value();
}

@Copyright("2002 Yoyodyne Propulsion Systems")
public class OscillationOverthruster { ... }
複製程式碼

除了這些,很多註解還需要元註解去描述,如:

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Inherited
public @interface MethodInfo {
    String author() default "Peabody";
    String date();
    int version() default 1;
}
複製程式碼

@Documented 表明該註解型別可以被 javadoc 等工具文件化 @Retention 表明該註解型別可以保留多長時間,值為列舉值 RetentionPolicy:

  • RetentionPolicy.SOURCE(只保留在原始碼中,會被編譯器丟棄)
  • RetentionPolicy.CLASS(註解會被編譯器記錄在class檔案中,但不需要被VM保留到執行時,這也是預設的行為)
  • RetentionPolicy.RUNTIME(註解會被編譯器記錄在class檔案中並被VM保留到執行時,所以可以通過反射獲取)

@Target 表明該註解型別可以註解哪些程式元素,如果註解型別不使用 @Target 描述那麼表明可以註解所有程式元素,值是列舉陣列ElementType[]:

  • ElementType.TYPE(類、介面(包括註解型別)、列舉的宣告)
  • ElementType.FIELD(欄位(包括列舉常量)的宣告)
  • ElementType.METHOD(方法的宣告)
  • ElementType.PARAMETER(形參的宣告)
  • ElementType.CONSTRUCTOR(構造器的宣告)
  • ElementType.LOCAL_VARIABLE(本地變數的宣告)
  • ElementType.ANNOTATION_TYPE(註解型別的宣告)
  • ElementType.PACKAGE(包的宣告)
  • ElementType.TYPE_PARAMETER(泛型引數的宣告)
  • ElementType.TYPE_USE(泛型的使用)

@Inherited 表明該註解型別將被自動繼承。也就是說,如果註解型別被 @Inherited 註解,此時使用者查詢一個類宣告的註解,而類宣告沒被該註解型別註解,那麼將自動查詢該類父類的註解型別,以此類推直到找到該註解型別或達到頂層 Object 物件。

Android Support Library 中的註解

Android Support Library 提供了很多實用註解,如可以使用 @NonNull 註解進行空檢查,使用 @UiThread@WorkerThread 註解進行執行緒檢查,使用 @IdRes 表明這個整數代表資源引用,還可以通過 @IntDef@StringDef 註解自定義註解來代替列舉,如描述應用中使用的字型檔案:

public final class TypefaceManager {

    public static final int FONT_TYPE_ICONIC = 0;
    public static final int FONT_TYPE_IMPACT = 1;
    public static final int FONT_TYPE_HELVETICA = 2;
    public static final int FONT_TYPE_DIN = 3;

    @Retention(RetentionPolicy.SOURCE)
    @IntDef({FONT_TYPE_ICONIC, FONT_TYPE_IMPACT, FONT_TYPE_HELVETICA, FONT_TYPE_DIN})
    @interface FontType {
    }

    private Context mContext;
    private SparseArray<Typeface> mTypefaceSparseArray;

    public TypefaceManager(Context context) {
        this.mContext = context;
        this.mTypefaceSparseArray = new SparseArray<>();
    }

    public static void setTypeface(TextView textView, @FontType int fontType) {
        Typeface localTypeface = MyApplication.getInstance().getTypefaceManager().getTypeface(fontType);
        if (localTypeface != null && localTypeface != textView.getTypeface()) {
            textView.setTypeface(localTypeface);
        }
    }

    public static void setTypeface(Paint paint, @FontType int fontType) {
        Typeface localTypeface = MyApplication.getInstance().getTypefaceManager().getTypeface(fontType);
        if (localTypeface != null && localTypeface != paint.getTypeface()) {
            paint.setTypeface(localTypeface);
        }
    }

    public Typeface getTypeface(@FontType int fontType) {
        Typeface typeface = mTypefaceSparseArray.get(fontType);
        if (typeface == null) {
            try {
                String path = null;
                if (fontType == FONT_TYPE_ICONIC) {
                    path = "fonts/fontawesome-webfont.ttf";
                } else if (fontType == FONT_TYPE_IMPACT) {
                    path = "fonts/impact.ttf";
                } else if (fontType == FONT_TYPE_HELVETICA) {
                    path = "fonts/Helvetica.ttf";
                } else if (fontType == FONT_TYPE_DIN) {
                    path = "fonts/ptdin.ttf";
                }
                typeface = Typeface.createFromAsset(mContext.getAssets(), path);
                this.mTypefaceSparseArray.put(fontType, typeface);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        return typeface;
    }

}
複製程式碼

註解的使用與解析

對於 @Retention(RetentionPolicy.RUNTIME) 的註解,註解會被編譯器記錄在 class 檔案中並被 VM 保留到執行時,所以可以通過反射獲取,如:

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Inherited
public @interface MethodInfo {
    String author() default "Peabody";
    String date();
    int version() default 1;
}
複製程式碼

使用

public class App {
    @MethodInfo(
        author = “frank”,
        date = "2018/02/27",
        version = 2)
    public String getDescription() {
        return "no description";
    }
}
複製程式碼

可以寫個工具在執行時利用反射獲取註解:

public static void main(String[] args) {
    try {
        Class cls = Class.forName("com.frank.App");
        for (Method method : cls.getMethods()) {
            MethodInfo methodInfo = method.getAnnotation(
MethodInfo.class);
            if (methodInfo != null) {
                System.out.println("method author:" + methodInfo.author());
                System.out.println("method version:" + methodInfo.version());
                System.out.println("method date:" + methodInfo.date());
            }
        }
    } catch (ClassNotFoundException e) {
        e.printStackTrace();
    }
}
複製程式碼

對於 @Retention(RetentionPolicy.CLASS) 的註解,註解會被編譯器記錄在 class 檔案中,但不需要被 VM 保留到執行時,如:

@Retention(RetentionPolicy.CLASS)
@Target(ElementType.FIELD)
public @interface BindView {
    @IdRes int value();
}
複製程式碼

也就是說,這種編譯時註解適合用來在編譯時自動生成程式碼,這就需要 apt(Annotation Processing Tool)工具查詢並執行註解處理器(Annotation Processor)以生成原始碼和檔案,最終 javac 會編譯這些原始原始檔和自動生成的檔案。Android Gradle 外掛的 2.2 版本開始支援註解處理器,你只需要使用 annotationProcessor 依賴註解處理器或者使用 javaCompileOptions.annotationProcessorOptions {} DSL指定註解處理器即可。定義註解處理器最簡單的方式就是繼承 AbstractProcessor,在其 process 實現方法中實現註解元素的分析和原始碼檔案的生成。

自定義註解和註解處理器

以簡化一系列 findViewById 為例:

package com.frank.simplebutterknife;

import android.os.Bundle;
import android.support.v7.app.AppCompatActivity;
import android.widget.TextView;

public class MainActivity extends AppCompatActivity {

    TextView mTitleTextView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        mTitleTextView = (TextView) findViewById(R.id.titleTextView);
        mTitleTextView.setText("Hello World!");
    }
}
複製程式碼

我們希望利用自定義註解和註解處理器後可以這樣寫:

package com.frank.simplebutterknife;

import android.os.Bundle;
import android.support.v7.app.AppCompatActivity;
import android.widget.TextView;

import simplebutterknife.BindView;

public class MainActivity extends AppCompatActivity {

    @BindView(R.id.titleTextView)
    TextView mTitleTextView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        SimpleButterKnife.bind(this);
        mTitleTextView.setText("Hello World!");
    }
}

複製程式碼

也就是說 SimpleButterKnife.bind(this); 一行程式碼就完成了所有被 @BindView 註解的 View 的 findViewById 操作。而實現方式就是利用註解和註解編譯器在編譯時自動生成一個這樣的檔案:

package com.frank.simplebutterknife;

import android.view.View;
import android.widget.TextView;

public class MainActivity_ViewBinding {
  public MainActivity target;

  public MainActivity_ViewBinding(MainActivity target) {
    this(target, target.getWindow().getDecorView());
  }

  public MainActivity_ViewBinding(MainActivity target, View source) {
    this.target = target;

    target.mTitleTextView = (TextView) source.findViewById(2131165300);
  }
}
複製程式碼

SimpleButterKnife.bind(this); 的實現中載入這個類並執行構造器就可以了。 實現起來也很簡單,先新建一個 java-library 的module:simplebutterknife-annotations,用來宣告註解:

@Retention(RetentionPolicy.CLASS)
@Target(ElementType.FIELD)
public @interface BindView {
    @IdRes int value();
}
複製程式碼

RetentionPolicy.CLASS 表明這個註解只會在編譯時使用,ElementType.FIELD 表明這個註解只用於註解欄位,@IdRes 是 android support library 中的編譯時檢查註解,表明註解的值必須是資源 ID,所以該 module 的依賴為:

dependencies {
    compileOnly 'com.google.android:android:4.1.1.4'
    api 'com.android.support:support-annotations:27.0.2'
}
複製程式碼

宣告完註解後,再新建一個註解處理器 java-library 的module:simplebutterknife-compiler,用來對註解的元素進行分析和生成原始碼檔案:

@AutoService(Processor.class)
public class SimpleButterKnifeProcessor extends AbstractProcessor {

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

    @Override
    public Set<String> getSupportedAnnotationTypes() {
        Set<String> types = new LinkedHashSet<>();
        types.add(BindView.class.getCanonicalName());
        return types;
    }

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

    class BindingSet {
        TypeName targetTypeName;
        ClassName bindingClassName;
        List<ViewBinding> viewBindings;
    }

    class ViewBinding {
        TypeName type;
        int id;
        String name;
    }
}
複製程式碼

@AutoService(Processor.class) 註解是利用了 Google 的 AutoService 為註解處理器自動生成 metadata 檔案並將註解處理器jar檔案加入構建路徑,這樣也就不需要再手動建立並更新 META-INF/services/javax.annotation.processing.Processor 檔案了。 覆寫 getSupportedSourceVersion() 方法指定可以支援最新的 Java 版本,覆寫 getSupportedAnnotationTypes() 方法指定該註解處理器用於處理哪些註解(我們這裡只處理 @BindView 註解)。而檢索註解元素並生成程式碼的是 process 方法的實現:

Map<TypeElement, BindingSet> bindingMap = new LinkedHashMap<>();
複製程式碼

需要為每個包含註解的 Activity 都生成一個對應的 _ViewBinding 檔案,所以使用 Map 來儲存。BindingSet 儲存 Activity 資訊和它的 View 繫結資訊,View 繫結資訊(ViewBinding)包括繫結 View 的型別、View 的 ID 以及 View 的變數名。

for (Element element : roundEnvironment.getElementsAnnotatedWith(BindView.class))
複製程式碼

查詢所有被 @BindView 註解的程式元素(Element),為了簡化,這裡只認為被註解的元素是 View 欄位且它的外層元素(EnclosingElement)為 Activity 類:

// 註解元素的外側元素,即 View 的所在 Activity 類
TypeElement enclosingElement = (TypeElement) element.getEnclosingElement();
// 註解的 value 值,即 View 的 id
int id = element.getAnnotation(BindView.class).value();
// 註解元素的名字,即 View 變數名
Name simpleName = element.getSimpleName();
String name = simpleName.toString();
// 註解元素的型別,即 View 的型別
TypeMirror elementType = element.asType();
TypeName type = TypeName.get(elementType);
複製程式碼

然後把這些資訊存到 Activity 對應的 View 繫結中:

BindingSet bindingSet = bindingMap.get(enclosingElement);
if (bindingSet == null) {
    bindingSet = new BindingSet();
    TypeMirror typeMirror = enclosingElement.asType();
    TypeName targetType = TypeName.get(typeMirror);
    String packageName = MoreElements.getPackage(enclosingElement).getQualifiedName().toString();
    String className = enclosingElement.getQualifiedName().toString().substring(
            packageName.length() + 1).replace('.', '$');
    ClassName bindingClassName = ClassName.get(packageName, className + "_ViewBinding");
    bindingSet.targetTypeName = targetType;
    bindingSet.bindingClassName = bindingClassName;
    bindingMap.put(enclosingElement, bindingSet);
}
if (bindingSet.viewBindings == null) {
    bindingSet.viewBindings = new ArrayList<>();
}
ViewBinding viewBinding = new ViewBinding();
viewBinding.type = type;
viewBinding.id = id;
viewBinding.name = name;
bindingSet.viewBindings.add(viewBinding);
複製程式碼

確定完 Activity 資訊和它對應的 View 繫結資訊後,為每個 Activity 生成對應的 XXX_ViewBinding.java 檔案,檔案內容就是前面所說類似這樣的繫結類:

package com.frank.simplebutterknife;

import android.view.View;
import android.widget.TextView;

public class MainActivity_ViewBinding {
  public MainActivity target;

  public MainActivity_ViewBinding(MainActivity target) {
    this(target, target.getWindow().getDecorView());
  }

  public MainActivity_ViewBinding(MainActivity target, View source) {
    this.target = target;

    target.mTitleTextView = (TextView) source.findViewById(2131165300);
  }
}
複製程式碼

雖然通過字串拼接可以拼出這樣的檔案內容,但我們還得考慮 import,還得考慮大括號和換行,甚至還得考慮註釋和程式碼美觀,所以利用 JavaPoet 來生成 .java 檔案是個不錯的選擇:

for (Map.Entry<TypeElement, BindingSet> entry : bindingMap.entrySet()) {
            TypeElement typeElement = entry.getKey();
            BindingSet binding = entry.getValue();

            TypeName targetTypeName = binding.targetTypeName;
            ClassName bindingClassName = binding.bindingClassName;
            List<ViewBinding> viewBindings = binding.viewBindings;
            // binding 類
            TypeSpec.Builder viewBindingBuilder = TypeSpec.classBuilder(bindingClassName.simpleName())
                    .addModifiers(Modifier.PUBLIC);
            // public的target欄位用來儲存 Activity 引用
            viewBindingBuilder.addField(targetTypeName, "target", Modifier.PUBLIC);
            // 構造器
            MethodSpec.Builder activityViewBuilder = MethodSpec.constructorBuilder()
                    .addModifiers(Modifier.PUBLIC)
                    .addParameter(targetTypeName, "target");
            activityViewBuilder.addStatement("this(target, target.getWindow().getDecorView())");
            viewBindingBuilder.addMethod(activityViewBuilder.build());
            // 第二個構造器
            MethodSpec.Builder viewBuilder = MethodSpec.constructorBuilder()
                    .addModifiers(Modifier.PUBLIC)
                    .addParameter(targetTypeName, "target")
                    .addParameter(ClassName.get("android.view", "View"), "source");
            viewBuilder.addStatement("this.target = target");
            viewBuilder.addCode("\n");
            for (ViewBinding viewBinding : viewBindings) {
                CodeBlock.Builder builder = CodeBlock.builder()
                        .add("target.$L = ", viewBinding.name);
                builder.add("($T) ", viewBinding.type);
                builder.add("source.findViewById($L)", CodeBlock.of("$L", viewBinding.id));
                viewBuilder.addStatement("$L", builder.build());
            }
            viewBindingBuilder.addMethod(viewBuilder.build());
            // 輸出 Java 檔案
            JavaFile javaFile = JavaFile.builder(bindingClassName.packageName(), viewBindingBuilder.build())
                    .build();
            try {
                javaFile.writeTo(processingEnv.getFiler());
            } catch (IOException e) {
                processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, e.getMessage());
            }
        }
複製程式碼

好了,註解處理器已經寫完了,再調整一下註解處理器 module 的依賴:

dependencies {
    implementation project(':simplebutterknife-annotations')
    implementation 'com.google.auto:auto-common:0.10'
    api 'com.squareup:javapoet:1.9.0'
    compileOnly 'com.google.auto.service:auto-service:1.0-rc4'
}
複製程式碼

在 app module 中需要依賴註解 module 並註冊註解處理器 module:

dependencies {
    ...
    api project(':simplebutterknife-annotations')
    annotationProcessor project(':simplebutterknife-compiler')
}
複製程式碼

app module 中的工具類 SimpleButterKnifebind 方法只需要載入這個自動生成的類並執行它的構造器就行了:

public final class SimpleButterKnife {

    public static void bind(Activity target) {
        View sourceView = target.getWindow().getDecorView();
        Class<?> targetClass = target.getClass();
        String targetClassName = targetClass.getName();
        Constructor constructor;
        try {
            Class<?> bindingClass = targetClass.getClassLoader().loadClass(targetClassName + "_ViewBinding");
            constructor = bindingClass.getConstructor(targetClass, View.class);
        } catch (ClassNotFoundException e) {
            // TODO Not found. should try search its superclass
            throw new RuntimeException("Not found. should try search its superclass of " + targetClassName, e);
        } catch (NoSuchMethodException e) {
            throw new RuntimeException("Unable to find binding constructor for " + targetClassName, e);
        }
        try {
            constructor.newInstance(target, sourceView);
        } catch (IllegalAccessException e) {
            throw new RuntimeException("Unable to invoke " + constructor, e);
        } catch (InstantiationException e) {
            throw new RuntimeException("Unable to invoke " + constructor, e);
        } catch (InvocationTargetException e) {
            Throwable cause = e.getCause();
            if (cause instanceof RuntimeException) {
                throw (RuntimeException) cause;
            }
            if (cause instanceof Error) {
                throw (Error) cause;
            }
            throw new RuntimeException("Unable to create binding instance.", cause);
        }
    }
}
複製程式碼

重新構建下工程,就可以在 build\generated\source\apt\debug 目錄中檢視自動生成的檔案了:

package com.frank.simplebutterknife;

import android.view.View;
import android.widget.TextView;

public class MainActivity_ViewBinding {
  public MainActivity target;

  public MainActivity_ViewBinding(MainActivity target) {
    this(target, target.getWindow().getDecorView());
  }

  public MainActivity_ViewBinding(MainActivity target, View source) {
    this.target = target;

    target.mTitleTextView = (TextView) source.findViewById(2131165300);
  }
}

複製程式碼

此時,看一下 Butter Knife 的原始碼,其實就是在基礎上的補充完善。

總結

編譯時的註解和註解處理器可以生成一些模板程式碼,由於不涉及到反射所以也不會影響效能,註解的使用也會讓程式碼得到簡化,更加直觀優雅,所以很多專案都在使用,包括 Butter Knife、Dagger2、EventBus、Glide 等開源庫,所以有必要了解並使用註解,尤其是編譯時註解。

參考

相關文章