使用編譯時註解簡單實現類似 ButterKnife 的效果
讀完本文你將瞭解:
什麼是編譯時註解
上篇文章 什麼是註解以及執行時註解的使用 中我們介紹了註解的幾種使用場景,這裡回顧一下:
- 編譯前提示資訊:註解可以被編譯器用來發現錯誤,或者清除不必要的警告;
- 編譯時生成程式碼:一些處理器可以在編譯時根據註解資訊生成程式碼,比如 Java 程式碼,xml 程式碼等;
- 執行時處理:我們可以在執行時根據註解,通過反射獲取具體資訊,然後做一些操作。
編譯時註解就是隻在編譯時存在的註解,可以被註解處理器識別,用於生成一些程式碼。
APT
處理編譯時註解需要使用 APT。
APT 即 Annotation Processing Tool,註解處理工具,它可以在編譯時檢測原始碼檔案,找到符合條件的註解修飾的內容,然後進行相應的處理。
我們在使用 ButterKnife 和 Dagger2 時,gradle 依賴中的 apt 就是指定在編譯時呼叫它們的註解處理器:
compile "com.jakewharton:butterknife:$rootProject.butterknifeVersion"
apt "com.jakewharton:butterknife-compiler:$rootProject.butterknifeVersion"
compile "com.google.dagger:dagger:$rootProject.daggerVersion"
apt "com.google.dagger:dagger-compiler:$rootProject .daggerVersion"
編譯時註解如何使用與編寫
編譯時註解的使用一般分為三步:
- 用註解修飾變數
- 編譯時使用註解處理器生成程式碼
- 執行時呼叫生成的程式碼
那編寫編譯時註解專案的步驟就是這樣:
- 先建立註解
- 建立註解處理器,在其中拿到註解修飾的變數資訊,生成需要的程式碼
- 建立執行時,呼叫生成程式碼的排程器
舉個例子
這裡我們寫一個類似 ButterKnife
使用註解實現 findViewById
的 demo。
思路
這個 demo 的目的減少編寫 findViewById
的程式碼,使用一個註解就達到 View 物件的繫結效果。
羊毛出在豬身上,使用方便的背後一定有默默無聞的付出者,我們要做的就是根據註解實現對應 View 的繫結。
所以大概思路就是這樣子:
- 先寫一個註解,這個註解修飾一個成員變數,同時指定這個變數對應的 id
- 然後寫個註解處理器,讀取當前類的所有被註解修飾的成員物件和 id,生成對應的 findViewById 程式碼
- 最後寫個執行時繫結的類,初始化當前類的成員
注意:
註解處理器所在的 module 必須是 Java Library,因為要用到特有的 javax;
註解處理器需要依賴 註解 module,所以註解所在的 module 也要是 Java Library;
執行時繫結的類要操作 Activity 或者 View,所以需要為 Android Library。
因此需要建立三個 module:
接下來將分別介紹每個 module 的內容。
1.建立註解
New 一個 Module,選擇為 Java library,我們起名為 ioc-annotation。
在其中建立一個註解,這裡叫 BindView:
@Retention(RetentionPolicy.CLASS)
@Target(ElementType.FIELD)
public @interface BindView {
int value();
}
編譯時註解的 Retention
為 RetentionPolicy.CLASS
,即只在編譯時保留。
修飾目標為 ElementType.FIELD
,即成員變數。
這個註解有一個 value 屬性,型別為 int,用於指明將來 findViewById
的 id。
現在我們可以使用這個註解來修飾 Activity 中的成員,指定它對應的 id:
@BindView(R.id.tv_content)
public TextView mTextView;
@BindView(R.id.tv_bottom_content)
public TextView mBottomTextView;
看起來和 ButterKnife
很相似吧,不過現在它只是有個樣子,還得寫點額外程式碼它才能起作用。
2.建立執行時繫結的類
類似 ButterKnife
,我們需要在 Activity 中呼叫一個繫結的方法,便於執行時初始化當前類中使用註解修飾的欄位。就像這樣:
@Override
protected void onCreate(@Nullable final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_annotation);
ViewBinder.bind(this);
}
New 一個 Module,選擇為 Android library,我們起名為 ioc。
建立 ViewBinder
,它的作用是呼叫生成類,完成 bind()
方法傳入物件的欄位初始化。
**
* Description:
* <br> 從生成類中為當前 Activity/View 中的 View findViewById
* <p>
* <br> Created by shixinzhang on 17/6/22.
* <p>
* <br> Email: shixinzhang2016@gmail.com
* <p>
* <br> https://about.me/shixinzhang
*/
public class ViewBinder {
private static final String SUFFIX = "$$ViewInjector";
//Activity 中呼叫的方法
public static void bind(Activity activity) {
bind(activity, activity);
}
/**
* 1.尋找對應的代理類
* 2.呼叫介面提供的繫結方法
*
* @param host
* @param root
*/
@SuppressWarnings("unchecked")
private static void bind(final Object host, final Object root) {
if (host == null || root == null) {
return;
}
Class<?> aClass = host.getClass();
String proxyClassFullName = aClass.getName() + SUFFIX; //拼接生成類的名稱
try {
Class<?> proxyClass = Class.forName(proxyClassFullName);
ViewInjector viewInjector = (ViewInjector) proxyClass.newInstance();
if (viewInjector != null) {
viewInjector.inject(host, root);
}
} catch (ClassNotFoundException e) {
e.printStackTrace();
} catch (InstantiationException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
}
}
}
ViewBinder.bind(this)
的作用就是根據當前類名和約定好的類名,找到生成類,然後反射呼叫它的方法。為了呼叫指定的方法,讓這個生成類實現一個介面。
所以我們還需要建立一個介面 ViewInjector
,這個介面的作用是便於反射呼叫。
public interface ViewInjector<T> {
void inject(T t, Object source);
}
其實也可以反射遍歷呼叫物件的方法,但是效率不如直接實現一個介面來的好。
3.建立註解處理器
註解處理器的作用是讀取註解、生成程式碼,先看下將來想要生成的程式碼:
我們要生成的類,名稱是使用註解修飾的欄位所在類名 拼接上 $$ViewInjector
,實現 ViewInjector
介面,在 inject()
方法中實現類中欄位的 findViewById
過程。
這樣直接傳入對應的 activity,其中的 mTextView
或者 mBottomTextView
等使用 BindView
修飾的變數就可以初始化了。
OK,知道要生成啥樣的類以後,就可以編寫註解處理程式碼了。
New 一個 Module,選擇為 Java library,我們起名為 ioc-processor。
①首先修改 build.gradle
檔案,新增下面兩行:
dependencies {
compile 'com.google.auto.service:auto-service:1.0-rc3'
compile project(path: ':ioc-annotation')
}
第一個依賴會幫我們生成 META-INF
元資訊,指明註解處理器的完整路徑。
如果不想使用這個依賴,要自己建立的話,也可以:
- 在 main 資料夾下建立一個 resources.META-INF.services 資料夾
- 在其中建立
javax.annotation.processing.Processor
檔案
- 檔案的內容是註解處理器的完整包名加類名
top.shixinzhang.BindViewProcessor
②我這裡直接使用註解了,建立註解處理器,繼承 AbstractProcessor
:
@AutoService(Processor.class) //幫我們生成 META-INF 資訊
@SupportedAnnotationTypes("top.shixinzhang.BindView") 要處理的註解型別
@SupportedSourceVersion(SourceVersion.RELEASE_7) //支援的原始碼版本
public class BindViewProcessor extends AbstractProcessor {
//...
}
三個註解的作用如註釋所示。
如果不使用後面兩個註解,就需要重寫 getSupportedAnnotationTypes()
和 getSupportedSourceVersion
方法:
// 有註解就不用重寫這兩個方法了
// @Override
// public Set<String> getSupportedAnnotationTypes() {
// Set<String> annotationTypes = new LinkedHashSet<>();
// annotationTypes.add(BindView.class.getCanonicalName());
// return annotationTypes;
// }
//
//
// /**
// * 支援的原始碼版本
// * @return
// */
// @Override
// public SourceVersion getSupportedSourceVersion() {
// return SourceVersion.latestSupported();
// }
③然後重寫 init()
方法:
@AutoService(Processor.class)
@SupportedAnnotationTypes("top.shixinzhang.BindView")
@SupportedSourceVersion(SourceVersion.RELEASE_7) //支援的原始碼版本
public class BindViewProcessor extends AbstractProcessor {
private Elements mElementUtils; //基於元素進行操作的工具方法
private Filer mFileCreator; //程式碼建立者
private Messager mMessager; //日誌,提示者,提示錯誤、警告
private Map<String, ProxyInfo> mProxyMap = new HashMap<>();
@Override
public synchronized void init(final ProcessingEnvironment processingEnv) {
super.init(processingEnv);
mElementUtils = processingEnv.getElementUtils();
mFileCreator = processingEnv.getFiler();
mMessager = processingEnv.getMessager();
}
//...
}
在這個方法中做初始化操作,引數 processingEnv
是註解處理環境,通過它可以獲取很多功能類:
public interface ProcessingEnvironment {
//返回註解處理工具的一些配置選項
Map<String,String> getOptions();
//返回資訊傳遞者,用來報告錯誤、警告燈資訊
Messager getMessager();
//返回用於建立 Java 檔案、class 檔案或者其他輔助檔案的檔案建立者
Filer getFiler();
//返回用於基於元素進行操作的工具類
Elements getElementUtils();
//返回用於基於型別進行操作的工具類
Types getTypeUtils();
//返回生成檔案的版本
SourceVersion getSourceVersion();
//返回當前區域,用於提示本地化的訊息
Locale getLocale();
}
這麼多功能,我們這裡只使用 getElementUtils()
, getFiler()
和 getMessager()
,用於後續建立檔案、獲取元素資訊,以及在編譯時提示資訊。
④重寫 process()
方法
做好準備工作後,接下來在 process()
中做兩件事:
- 收集資訊
- 生成程式碼
首先收集資訊,我們需要拿到的資訊有如下幾點:
- 註解修飾變數所在的類名,便於和字尾拼接生成代理類
- 類的完整包名
- 類中被註解修飾的欄位,以及對應的佈局 id
那我們編譯時可以拿到什麼呢?
@Override
public boolean process(final Set<? extends TypeElement> annotations, final RoundEnvironment roundEnv) {
//...
}
第一個引數暫且不表,第二個引數 RoundEnvironment
的作用是提供一個註解處理器,在編譯時可以查詢類的資訊。其中有一個關鍵的方法 getElementsAnnotatedWith()
:
Set<? extends Element> getElementsAnnotatedWith(Class<? extends Annotation> a);
這個方法可以拿到指定註解修飾的元素集合,返回的是 Element
及其子類的物件集合。
Element
是一個介面,代表著一個包、類、方法或者元素,它的子介面有很多,比如:
VariableElement
:成員變數TypeElement
:類或者介面PackageElement
:包資訊ExecutableElement
:方法
OK,瞭解了目的和條件,就可以編寫程式碼了。
@Override
public boolean process(final Set<? extends TypeElement> annotations, final RoundEnvironment roundEnv) {
mMessager.printMessage(Diagnostic.Kind.NOTE, "process...");
//避免生成重複的代理類
mProxyMap.clear();
//拿到被 @BindView 註解修飾的元素,應該是 VariableElement
Set<? extends Element> elements = roundEnv.getElementsAnnotatedWith(BindView.class);
//1.收集資訊
for (Element element : elements) {
if (!checkAnnotationValid(element, BindView.class)) { //去除不合格的元素
continue;
}
//類中的成員變數
VariableElement variableElement = (VariableElement) element;
//類或者介面
TypeElement typeElement = (TypeElement) variableElement.getEnclosingElement();
//完整的名稱
String qualifiedName = typeElement.getQualifiedName().toString();
ProxyInfo proxyInfo = mProxyMap.get(qualifiedName);
if (proxyInfo == null) {
//將該類中被註解修飾的變數加入到 ProxyInfo 中
proxyInfo = new ProxyInfo(mElementUtils, typeElement);
mProxyMap.put(qualifiedName, proxyInfo);
}
BindView annotation = variableElement.getAnnotation(BindView.class);
if (annotation != null) {
int id = annotation.value();
proxyInfo.mInjectElements.put(id, variableElement);
}
}
//...
}
我們先輸出了一個提示資訊 “process…”,一會兒 build 專案的時候可以看到這個提示。
上面的程式碼主要做了這幾件事:
- 先呼叫
roundEnv.getElementsAnnotatedWith(BindView.class)
拿到被 @BindView 註解修飾的元素集合,在前面的例子中,我們拿到的就是TextView mTextView
和TextView mBottomTextView
。 - 然後遍歷這些元素,由於我們註解修飾的是變數,可以直接轉換成
VariableElement
型別。 - 呼叫
variableElement.getEnclosingElement()
方法拿到變數所在類的物件資訊,呼叫它的getQualifiedName().toString()
方法獲得類的完整名稱。
我們使用一個 map 儲存類的資訊:
private Map<String, ProxyInfo> mProxyMap = new HashMap<>();
你可以先建立一個空的 ProxyInfo
類,建構函式為:
public class ProxyInfo {
private static final String SUFFIX = "ViewInjector";
public Map<Integer, VariableElement> mInjectElements = new HashMap<>(); //被註解修飾的變數和 id 對映表
public ProxyInfo(final Elements elementUtils, final TypeElement typeElement) {
//...
}
}
}
它的具體內容後面介紹。
將該類中被註解修飾的變數加入到 mProxyMap
後,接下來就可以遍歷這些資訊,生成對應的程式碼了。
生成程式碼:
//...
//2.生成代理類
for (String key : mProxyMap.keySet()) {
ProxyInfo proxyInfo = mProxyMap.get(key);
try {
//建立檔案物件
JavaFileObject sourceFile = mFileCreator.createSourceFile(
proxyInfo.getProxyClassFullName(), proxyInfo.getTypeElement());
Writer writer = sourceFile.openWriter();
writer.write(proxyInfo.generateJavaCode()); //寫入檔案
writer.flush();
writer.close();
} catch (IOException e) {
e.printStackTrace();
error(proxyInfo.getTypeElement(), "Unable to write injector for type %s: %s", proxyInfo.getTypeElement(), e.getMessage());
}
}
return true;
}
遍歷 mProxyMap
,裡面的 ProxyInfo
列表建立檔案。
建立檔案物件只需要呼叫 mFileCreator.createSourceFile
拿到 JavaFileObject
物件,然後拿到 Writer,寫入資訊即可。
生成程式碼最終呼叫的是 proxyInfo.generateJavaCode()
,這時我們可以瞭解前面介紹的 ProxyInfo
了。
最終的程式碼生成類。
ProxyInfo
的作用就是拿程式碼拼出這個類:
所以它需要儲存類的資訊、包名、完整類名以及其中的變數列表,在建構函式中初始化:
public class ProxyInfo {
private static final String SUFFIX = "ViewInjector";
public Map<Integer, VariableElement> mInjectElements = new HashMap<>(); //變數列表
private TypeElement mTypeElement; //類資訊
private String mPackageName; //包名
private String mProxyClassName; //代理類名
public ProxyInfo(final Elements elementUtils, final TypeElement typeElement) {
mTypeElement = typeElement;
PackageElement packageElement = elementUtils.getPackageOf(typeElement);
mPackageName = packageElement.getQualifiedName().toString();
String className = getClassName(typeElement, mPackageName);
mProxyClassName = className + "$$" + SUFFIX;
System.out.println("****** " + mProxyClassName + " \n" + mPackageName);
}
private String getClassName(final TypeElement typeElement, final String packageName) {
int packageLength = packageName.length() + 1; //
return typeElement.getQualifiedName().toString().substring(packageLength).replace('.', '$');
}
//...
}
然後就可以根據這些動態資訊生成不同的類了。
{
public String generateJavaCode() {
StringBuilder stringBuilder = new StringBuilder();
//stringBuilder 中不要再使用 + 拼接字串
stringBuilder.append("// Generate code. Do not modify it !\n")
.append("package ").append(mPackageName).append(";\n\n")
.append("import top.shixinzhang.ioc.*;\n\n")
.append("public class ").append(mProxyClassName).append(" implements ").append(SUFFIX).append("<").append(mTypeElement.getQualifiedName()).append(">").append("{\n");
generateMethod(stringBuilder);
stringBuilder.append("\n}\n");
return stringBuilder.toString();
}
private void generateMethod(final StringBuilder stringBuilder) {
if (stringBuilder == null) {
return;
}
stringBuilder.append("@Override\n")
.append("public void inject(").append(mTypeElement.getQualifiedName()).append(" host, Object object )").append("{\n");
for (Integer id : mInjectElements.keySet()) {
VariableElement variableElement = mInjectElements.get(id);
String name = variableElement.getSimpleName().toString();
String type = variableElement.asType().toString();
stringBuilder.append("if(object instanceof android.app.Activity)").append("{\n")
.append("host.").append(name).append(" = ")
.append("(").append(type).append(")((android.app.Activity)object).findViewById(").append(id).append(");")
.append("\n}\n")
.append("else").append("{\n")
.append("host.").append(name).append(" = ")
.append("(").append(type).append(")((android.view.View)object).findViewById(").append(id).append(");")
.append("\n}\n");
}
stringBuilder.append("\n}\n");
}
public String getProxyClassFullName() {
return mPackageName + "." + mProxyClassName;
}
public TypeElement getTypeElement() {
return mTypeElement;
}
}
拼的很簡單粗暴,參考目的碼即可。
完成編寫,使用一下
完成這三個 module 後,就可以直接使用了!
在 app module 的 gradle 檔案中新增三個 module 的依賴:
compile project(':ioc')
compile project(':ioc-annotation')
apt project(':ioc-processor')
apt 指定註解處理器。
然後在類中使用註解修飾變數,同時呼叫 ViewBinder.bind(this)
綁定當前 Activity。
public class AnnotationTestActivity extends BaseActivity {
@BindView(R.id.tv_content)
public TextView mTextView;
@BindView(R.id.tv_bottom_content)
public TextView mBottomTextView;
@Override
protected void onCreate(@Nullable final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_annotation);
ViewBinder.bind(this);
}
//...
}
點選 Build
-> Rebuild Project
,可以在 Messages
-> Gradle Console
控制檯中看到輸出資訊:
然後在 app -> build -> generated -> source -> apt -> flavor -> 使用註解的包名下,看到生成類:
有這個類表示生成程式碼成功了。
然後執行一下,執行時就會完成對應變數的初始化。
遇到的坑
1.無法引入javax包下的類庫
javax 包屬於java,Android核心庫中沒有。所以不能直接在app Module和Android Library中使用,必須要建立一個Java Library。然後由Java Library匯出jar包使用。
2.不生成檔案
檢查你有沒有使用註解。。。
2.幾個 module 沒有劃分
註解沒有單獨在一個 module 中
3.感謝這個開源專案負責人認真的解答,讓我也發現了問題所在!
程式碼地址
總結
這篇文章介紹瞭如何編寫編譯時註解,光看一邊很難理解,希望各位可以親手敲一遍,加深理解。
編譯時註解的作用就是生成程式碼,對比在執行時反射進行類似的操作,效能影響可以忽略不計,它其實和直接執行手寫程式碼沒有任何區別,方便在幫我們省去編寫一些重複的程式碼。
EventBus,ButterKnife,Dagger2 都使用了編譯時註解,技術基礎有了後,具體如何創造,就看你的想象力了!