手把手教你實現Android編譯期註解

vivo網際網路技術發表於2021-07-27

一、編譯期註解在開發中的重要性

從早期令人驚豔的ButterKnife,到後來的以ARouter為首的各種路由框架,再到現在谷歌大力推行的Jetpack元件,越來越多的第三方框架都在使用編譯期註解這門技術,可以說不管你是想要深入研究這些第三方框架的原理 還是要成為一個Android高階開發工程師,編譯期註解都是你不得不好好掌握的一門基礎技術。

本文從基礎的執行期註解用法開始,逐步演進到編譯期註解的用法,讓你真正明白編譯期註解到底應該在什麼場景下使用,怎麼用,用了有哪些好處。

二、手寫執行期註解

類似下面這種寫法,當View一多得不停的findViewById 寫很多行,手寫起來很麻煩,我們首先嚐試用執行期註解來解決這個問題,看看能不能自動處理這些findViewById的操作。

首先是工程結構,肯定要定義一個lib module。

其次定義我們的註解類:

有了這個註解的類,我們就可以在我們的MainAcitivity先用起來,雖然此時這個註解還並未起到什麼作用。

到這裡要稍微想一下,此時我們要做的是 通過註解來將R.id.xx 賦值給對應的field,也就是你定義的那些view物件(例如紅框中的tv),對於我們的lib工程來說,因為是MainActivity 要依賴lib,自然你lib不可以依賴Main所屬的app工程了,這裡有2個原因:

  • A依賴B ,B依賴A的迴圈依賴是肯定會報錯的;

  • 既然你要做一個lib 那你肯定不能依賴使用者的宿主 否則怎麼能叫lib呢?

所以這個問題就變成了,lib工程 只能拿到Acitivty,拿不到宿主的MainActivity , 既然拿不到宿主的MainActivity,那我怎麼知道這個activity有多少個field?這裡就要用到反射了。

public class BindingView {
 
    public static void init(Activity activity) {
        Field[] fields = activity.getClass().getDeclaredFields();
        for (Field field : fields) {
            //獲取 被註解
            BindView annotation = field.getAnnotation(BindView.class);
            if (annotation != null) {
                int viewId = annotation.value();
                field.setAccessible(true);
                try {
                    field.set(activity, activity.findViewById(viewId));
                } catch (IllegalAccessException e) {
                    e.printStackTrace();
                }
            }
 
        }
 
    }
}

最後我們在宿主的MainActivity中呼叫一下這個方法 即可:

到這裡其實有人就要問了,這個執行時註解看起來也不難啊,為啥好像用的人不是很多?問題就出在剛才反射的那堆方法裡,反射大家都知道 會對Android執行時帶來一些效能損耗,而這裡的程式碼是一段迴圈, 也就是說這裡的程式碼會隨著你使用lib的Activity的介面複雜程度的提高 而變得越來越慢,這是一個會隨著你介面複雜度提高而逐步劣化的過程, 單次反射對於今天的手機來說幾乎已經不存在什麼效能消耗了,但是這種for迴圈中使用反射還是儘量少用。

三、手寫編譯期註解

為了解決這個問題,就要使用編譯期註解。現在我們來嘗試用編譯期註解來解決上述的問題。前面我們說過,執行期註解可以用反射來拿到宿主的field 從而完成需求,為了解決反射的效能問題,我們其實想要的程式碼是這樣的:

我們可以在app 的module 中新建一個MainActivityViewBinding的類:

然後在我們的BindingView(注意我們的BindingView是在lib module下的)中來呼叫這個方法不就解決這個反射的問題了嗎?

但是這裡會有個問題 就是你既然是一個lib 你不能依賴宿主 ,所以在lib Module 中你其實拿不到 MainActivityViewBinding 這個類的,還是得利用反射。

可以看一下上面註釋掉的程式碼,為啥不直接字串寫死?因為你是lib庫你當然得是動態的,不然怎麼給別人用?其實就是獲取宿主的class名稱然後加上一個固定的字尾ViewBinding 即可。這個時候 我們就拿到這個Binding的class了,對吧,剩下就是呼叫構造方法即可。

public class BindingView {
 
    public static void init(Activity activity) {
        try {
            Class bindingClass = Class.forName(activity.getClass().getCanonicalName() + "ViewBinding");
            Constructor constructor = bindingClass.getDeclaredConstructor(activity.getClass());
            constructor.newInstance(activity);
        } catch (ClassNotFoundException | NoSuchMethodException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        } catch (InstantiationException e) {
            e.printStackTrace();
        } catch (InvocationTargetException e) {
            e.printStackTrace();
        }
    }
}

看下此時的程式碼結構:

有人這裡要問,這裡你不還是用了反射麼,對! 這裡雖然用了反射,但是我這裡的反射只會呼叫一次,不管你的activity有都少field,在我這裡反射方法都只會執行一次。所以效能肯定是比之前的方案要快很多倍的。接著看,雖然此刻程式碼可以正常執行,但是還有一個問題, 雖然我可以在lib中呼叫到我們app宿主的類的構造方法,但是,宿主的這個類依舊是我們手寫的啊?那你這個lib庫 還是沒有起到任何可以讓我們少寫程式碼的作用。

這個時候就需要我們的apt 出場了,也就是編譯期註解的核心部分了。我們建立一個Java Library,注意是Java lib不是android lib,然後在app module中引入他。

注意 引入的方式 不是imp了,是annotation processor ;

然後我們來修改一下lib_processor,首先建立一個 註解處理類:

再建立檔案resources/META-INF/services/javax.annotation.processing.Processor ,這裡要注意 資料夾建立不要寫錯了。

然後再這個Processor指定 一下我們的註解處理器即可:

到這裡還沒完,我們得告訴這個註解處理器 只處理我們的BindView註解即可,否則這個註解處理器預設處理全部註解 速度就太慢了,但是此時 我們的BindView這個註解類還在lib倉裡面,顯然我們要調整一下我們的工程結構:

我們再新建一個Javalib,只放BindView即可,然後讓我們的lib_processor和app 都依賴這個lib_interface即可。再稍微修改一下程式碼,此時我們是編譯期處理,所Policy不用是runtime了。

@Retention(RetentionPolicy.SOURCE)
@Target(ElementType.FIELD)
public @interface BindView {
    int value();
}
public class BindingProcessor extends AbstractProcessor {
 
    Messager messager;
 
    @Override
    public synchronized void init(ProcessingEnvironment processingEnvironment) {
        messager = processingEnvironment.getMessager();
        messager.printMessage(Diagnostic.Kind.NOTE, " BindingProcessor init");
        super.init(processingEnvironment);
    }
 
    @Override
    public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {
        return false;
    }
 
    //要支援哪些註解
    @Override
    public Set<String> getSupportedAnnotationTypes() {
        return Collections.singleton(BindView.class.getCanonicalName());
    }
}

到此我們的大部分工作就處理完畢了。再看一下程式碼結構(這裡的程式碼結構一定要理解清楚為什麼這樣設計,否則你是學不會編譯期註解的)。

我們現在已經能夠做到 通過 lib 這個sdk 呼叫到MainActivityViewBinding這個裡面的方法,但是他 還在app倉是我們手寫的,不太智慧,還沒辦法用。我們需要在註解處理器裡面 ,動態的生成這個類,只要能完成這個步驟,那我們的SDK也就基本完成了。

這裡要提一下,很多人註解始終學不會就是卡在這裡,因為太多的文章或者教程上來就是Javapoet 那一套程式碼,壓根學不會,或者只能複製貼上別人的東西,稍微變動一下就不會了,其實這裡最佳的學習方式是先用StringBuffer 字串拼接的方式 拼出我們想要的程式碼就可以了,通過這個字串拼接的過程 來理解對應的api以及生成java程式碼的思路,然後最後再用JavaPoet來優化程式碼即可。

我們可以先思考一下, 如果用字串拼接的方式來做這個生成類的操作要完成哪些步驟。

  • 首先要獲取哪些類使用了我們的BindView註解;

  • 獲取這些類中使用了BindView註解的field以及他們對應的值;

  • 拿到這些類的類名稱以便我們生成諸如MainActivityViewBinding這樣的類名;

  • 拿到這些類的包名,因為我們生成的類要和註解所屬的類屬於同一個package 才不會出現field 訪問許可權的問題;

  • 上述條件都具備以後 就用字串拼接的方式 拼接出我們想要的java程式碼 即可。

這裡就直接上程式碼了,重要部分 直接看註釋即可,有了上面的步驟分析再看程式碼註釋應該不難理解。

public class BindingProcessor extends AbstractProcessor {
 
    Messager messager;
    Filer filer;
    Elements elementUtils;
 
    @Override
    public synchronized void init(ProcessingEnvironment processingEnvironment) {
        //主要是輸出一些重要的日誌使用
        messager = processingEnvironment.getMessager();
        //你就理解成最終我們寫java檔案 要用到的重要 輸出引數即可
        filer = processingEnvironment.getFiler();
        //一些方便的utils方法
        elementUtils = processingEnvironment.getElementUtils();
        //這裡要注意的是Diagnostic.Kind.ERROR 是可以讓編譯失敗的 一些重要的引數校驗可以用這個來提示使用者你哪裡寫的不對
        messager.printMessage(Diagnostic.Kind.NOTE, " BindingProcessor init");
        super.init(processingEnvironment);
    }
 
    private void generateCodeByStringBuffer(String className, List<Element> elements) throws IOException {
 
        //你要生成的類 要和 註解的類 同屬一個package 所以還要取 package的名稱
        String packageName = elementUtils.getPackageOf(elements.get(0)).getQualifiedName().toString();
        StringBuffer sb = new StringBuffer();
        // 每個java類 的開頭都是package sth...
        sb.append("package ");
        sb.append(packageName);
        sb.append(";\n");
 
        // public class XXXActivityViewBinding {
        final String classDefine = "public class " + className + "ViewBinding { \n";
        sb.append(classDefine);
 
        //定義建構函式的開頭
        String constructorName = "public " + className + "ViewBinding(" + className + " activity){ \n";
        sb.append(constructorName);
 
        //遍歷所有element 生成諸如 activity.tv=activity.findViewById(R.id.xxx) 之類的語句
        for (Element e : elements) {
            sb.append("activity." + e.getSimpleName() + "=activity.findViewById(" + e.getAnnotation(BindView.class).value() + ");\n");
        }
 
        sb.append("\n}");
        sb.append("\n }");
 
        //檔案內容確定以後 直接生成即可
        JavaFileObject sourceFile = filer.createSourceFile(className + "ViewBinding");
        Writer writer = sourceFile.openWriter();
        writer.write(sb.toString());
        writer.close();
    }
 
    @Override
    public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {
 
        // key 就是使用註解的class的類名 element就是使用註解本身的元素 一個class 可以有多個使用註解的field
        Map<String, List<Element>> fieldMap = new HashMap<>();
        // 這裡 獲取到 所有使用了 BindView 註解的 element
        for (Element element : roundEnvironment.getElementsAnnotatedWith(BindView.class)) {
            //取到 這個註解所屬的class的Name
            String className = element.getEnclosingElement().getSimpleName().toString();
            //取到值以後 判斷map中 有沒有 如果沒有就直接put 有的話 就直接在這個value中增加一個element
            if (fieldMap.get(className) != null) {
                List<Element> elementList = fieldMap.get(className);
                elementList.add(element);
            } else {
                List<Element> elements = new ArrayList<>();
                elements.add(element);
                fieldMap.put(className, elements);
            }
        }
 
        //遍歷map,開始生成輔助類
        for (Map.Entry<String, List<Element>> entry : fieldMap.entrySet()) {
            try {
                generateCodeByStringBuffer(entry.getKey(), entry.getValue());
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        return false;
    }
 
    //要支援哪些註解
    @Override
    public Set<String> getSupportedAnnotationTypes() {
        return Collections.singleton(BindView.class.getCanonicalName());
    }
}

最後看下效果:

雖然生成的程式碼格式不太好看,但是執行起來是ok的。這裡要注意一下Element 這個介面,實際上使用編譯期註解的時候 如果能夠理解了Element,那後續的工作就簡單不少。

主要關注Element的這5個子類即可,舉個例子:

package com.smart.annotationlib_2;//PackageElement |表示一個包程式元素
//  TypeElement 表示一個類或介面程式元素。
public class VivoTest {
    //VariableElement |表示一個欄位、enum 常量、方法或構造方法引數、區域性變數或異常引數。
    int a;
 
    //VivoTest 這個方法 :ExecutableElement|表示某個類或介面的方法、構造方法或初始化程式(靜態或例項),包括註釋型別元素。
    //int b 這個函式引數: TypeParameterElement |表示一般類、介面、方法或構造方法元素的形式型別引數。
    public VivoTest(int b ) {
        this.a = b;
    }
}

四、Javapoet生成程式碼

有了上面的基礎 再用 Javapoet 寫一遍字串拼接來生成java程式碼的過程, 就不會難以理解了。

private void generateCodeByJavapoet(String className, List<Element> elements) throws IOException {
 
    //宣告構造方法
    MethodSpec.Builder constructMethodBuilder =
            MethodSpec.constructorBuilder().addModifiers(Modifier.PUBLIC).addParameter(ClassName.bestGuess(className), "activity");
    //構造方法裡面 增加語句
    for (Element e : elements) {
        constructMethodBuilder.addStatement("activity." + e.getSimpleName() + "=activity.findViewById(" + e.getAnnotation(BindView.class).value() + ");");
    }
 
    //宣告類
    TypeSpec viewBindingClass =
            TypeSpec.classBuilder(className + "ViewBinding").addModifiers(Modifier.PUBLIC).addMethod(constructMethodBuilder.build()).build();
    String packageName = elementUtils.getPackageOf(elements.get(0)).getQualifiedName().toString();
     
    JavaFile build = JavaFile.builder(packageName, viewBindingClass).build();
    build.writeTo(filer);
}

這裡要提一下,現在越來越多的人使用Kotlin語言開發app,你甚至可以使用https://github.com/square/kotlinpoet 來直接生成Kotlin程式碼。有興趣的可以嘗試一下。

五、編譯期註解的總結

首先是大家關注的效能方面,對於執行時註解來說,會產生大量的反射程式碼,而且反射呼叫的次數會隨著專案複雜度的提高而變的越來越多,是一個逐步劣化的過程,而對於編譯期註解來說,反射的呼叫次數是固定的,他並不會隨著專案複雜度的提高而變的效能越來越差,實際上對於大多數執行時註解的專案都可以通過編譯期註解來大幅提高框架的效能,比如著名的Dagger、EventBus 等等,他們的首個版本都是執行時註解,後續版本都統一替換成了編譯期註解。

其次回顧一下前面我們編譯期註解的開發流程以後,可以得出以下幾點結論:

  • 編譯期註解只能生成程式碼,但是不能修改程式碼;

  • 註解生成的程式碼 必須要手動被呼叫,他自己是不會被呼叫的;

  • 對於SDK的編寫者來說,即使是編譯期註解,往往也免不了至少要走一次反射,而反射的作用主要就是呼叫你註解處理器生成的程式碼。

這裡可能會有小夥伴問,既然編譯期註解只能生成程式碼不能修改程式碼,那作用很有限啊,為啥不直接用類似於ASM 、Javassist 等位元組碼工具呢,這些工具不但可以生成程式碼而且還可以修改程式碼,功能更強勁。因為這些位元組碼工具生成的直接是class,且寫法複雜容易出錯,也不易於除錯,小規模寫一下類似於防止快速點選之類的東西還可以,大規模開發第三方框架其實也挺不方便的,遠遠不如編譯期註解來的效率高。

此外,再仔細想想,我們前文中提到的編譯期註解的寫法做成第三方庫給別人使用以後,還是需要使用者手動的在合適的時機呼叫一下 “init” 方法的,但是有些出色的第三方庫可以做到連init方法都不需要使用者手動呼叫了,使用起來非常方便,這又是怎麼做到的?其實也不難,多數情況都是這些第三方庫用編譯期註解生成了程式碼以後,再配合ASM等位元組碼工具直接幫你呼叫了init方法 ,從而讓你免去手動呼叫的過程。核心仍舊是編譯期註解,只不過是用位元組碼工具省略了一步而已。

作者:vivo網際網路客戶端團隊-Wu Yue

相關文章