1. 程式人生 > >使用編譯時註解簡單實現類似 ButterKnife 的效果

使用編譯時註解簡單實現類似 ButterKnife 的效果

讀完本文你將瞭解:

什麼是編譯時註解

上篇文章 什麼是註解以及執行時註解的使用 中我們介紹了註解的幾種使用場景,這裡回顧一下:

  1. 編譯前提示資訊:註解可以被編譯器用來發現錯誤,或者清除不必要的警告;
  2. 編譯時生成程式碼:一些處理器可以在編譯時根據註解資訊生成程式碼,比如 Java 程式碼,xml 程式碼等;
  3. 執行時處理:我們可以在執行時根據註解,通過反射獲取具體資訊,然後做一些操作。

編譯時註解就是隻在編譯時存在的註解,可以被註解處理器識別,用於生成一些程式碼。

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"

編譯時註解如何使用與編寫

編譯時註解的使用一般分為三步:

  1. 用註解修飾變數
  2. 編譯時使用註解處理器生成程式碼
  3. 執行時呼叫生成的程式碼

那編寫編譯時註解專案的步驟就是這樣:

  1. 先建立註解
  2. 建立註解處理器,在其中拿到註解修飾的變數資訊,生成需要的程式碼
  3. 建立執行時,呼叫生成程式碼的排程器

舉個例子

這裡我們寫一個類似 ButterKnife 使用註解實現 findViewById 的 demo。

思路

這個 demo 的目的減少編寫 findViewById 的程式碼,使用一個註解就達到 View 物件的繫結效果。

羊毛出在豬身上,使用方便的背後一定有默默無聞的付出者,我們要做的就是根據註解實現對應 View 的繫結。

所以大概思路就是這樣子:

  1. 先寫一個註解,這個註解修飾一個成員變數,同時指定這個變數對應的 id
  2. 然後寫個註解處理器,讀取當前類的所有被註解修飾的成員物件和 id,生成對應的 findViewById 程式碼
  3. 最後寫個執行時繫結的類,初始化當前類的成員

注意:
註解處理器所在的 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();
}

編譯時註解的 RetentionRetentionPolicy.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() 中做兩件事:

  1. 收集資訊
  2. 生成程式碼

首先收集資訊,我們需要拿到的資訊有如下幾點:

  1. 註解修飾變數所在的類名,便於和字尾拼接生成代理類
  2. 類的完整包名
  3. 類中被註解修飾的欄位,以及對應的佈局 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 mTextViewTextView 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 都使用了編譯時註解,技術基礎有了後,具體如何創造,就看你的想象力了!

Thanks