我能用注解處理器APT做什么 - 手寫一個路由框架

引言

一般來說,我們在項目開發中,功能性類似的同一層級,會有許多相同邏輯。很多時候,一個簡單有效的方法,就是定義base類,比如我們已經司空見慣以至于寫習慣了的BaseActivity、BaseFragment、BasePresenter等,然后在子類對應的流程節點中,對super進行調用。
而在父類中實現的這些邏輯,被設計之初就是對應的某些特定的切面(Aspect),可以稱作AOP思想的一種體現。

但在某些情況下,很多邏輯并不適合放在Activity基類中,在無法多繼承的限制下,就需要通過代理或包裝的方式來實現解耦,例如androidx中的LifeCycle相關框架。

同樣的,面向對象的思想中,組合也是優于繼承的。
特別是對于一些可以高度抽象、代碼高度一致的情況,為了避免大量的重復性代碼,可以通過APT或ASM進行代碼的自動生成,或者對編譯完的class進行字節碼操作插樁。今天主要來聊一聊APT的使用。


APT 是什么

APT 全稱 Annotation Processing Tool,即注解處理器,APT 工具是用于注解處理的命令行程序,它可以找到源碼中對應注解的對象(類、方法、字段)并使用注解處理器對其進行處理。
進而根據需求在編譯期生成一些附加的代碼,然后與源碼共同進行編譯。最后打包到一起。

注解處理器是基于注解(Annotation)的,需要定義一些注解,對應到想要處理的對象。例如ButterKnife對字段的注入,需要定義字段上的注解;例如ARouter對頁面的關聯,需要定義類上的注解。
然后利用 javax.annotation.processing.Processor 對代碼中帶有對應注解的部分進行掃描,拿到注解下的類信息、字段信息、方法信息,按想要的邏輯進行自動編碼,生成供后期調用的邏輯。


路由框架要解決什么問題

在單項目單模塊的開發場景中,activity之間的相互跳轉沒有任何障礙,彼此間的代碼都是可見的,如果需要統一跳轉邏輯,方便review路由線路,也只需要一個工具類,把頁面引用、需要的參數進行簡單封裝而已。

但在模塊化的開發中,模塊間沒有互相依賴,無法感知其他模塊中的具體類型,就無法拿到構建intent需要的Activity.class。

當然也可以定義公用的字符串常量,使用反射進行class的加載。但反射的性能較差,固定的少量使用還好,但隨著項目增大,頁面數量上漲,頁面間數據交互復雜化,過多的反射使用就可能成為性能瓶頸。
而且,既然都要對頁面路由進行封裝了,難道就不搞個傳遞數據的自動注入?還用getExtras、putExtras一個一個的賦值?不會吧不會吧?

好,現在我們有了初步的預期,想要一個可以跨模塊的,可以根據字符串或數字對應頁面并實現跳轉的,可以傳遞各種參數、而且可以在目標頁面中自動注入參數的腳手架!

這篇文章,我們就一起來實現一個自己的路由框架。
本文很多地方參考了ARouter的實現,并做了許多簡化,重在說明APT的使用和路由框架的思想。


先來畫幾個圖,清晰一下總流程

自己畫的-1.jpg

如上圖,模塊化的項目中,主要業務功能集中在幾個功能模塊里,能夠引用的邏輯只有自身包含的,和基礎功能集中所包含的。而在APP模塊中,可以拿到所有模塊的引用
所以,我們可以在APP中,在啟動時,將各個模塊中想要暴露的XXX.class收集起來,放到基礎功能中某個地方中存起來,然后在功能模塊里,就可以使用約定好的方式,通過某個數字或字符串標簽,路由到對應頁面中了。


自己畫的湊合看吧-2.png

如上圖,這個流程用文字來描述就是:

  • 你要有一個路由器框架??????
  • 在每個功能模塊中,準備好自己包含的路由信息(有幾個頁面可以被別人調,每個頁面對應什么標簽)。
  • app啟動時,收集所有功能模塊中暴露出的路由信息,寫到公用路由器的路由表中。
  • 功能中需要跳轉時,調用路由器提供的方法,由路由器查詢路由表,并執行跳轉。

好!現在就能寫一個路由框架啦!

是的,根據上面的分析,現在真的能寫出路由框架了,其實比想象的還要簡單。

只需要定義這樣兩個類,就算是完事了,請看下面的代碼:

  • Router 單例路由器,包含路由表,提供跳轉方法。
  • MetaProvider 元信息提供者接口,需要功能模塊中提供實現,供app調用收集。

路由器

/** 沒錯這就是路由器本器 */
class Router private constructor() {
    companion object {
        val instance = Router()
    }

    // 路由表,存著支持跳轉的class信息
    private val metaMap = hashMapOf<String, Class<*>>()

    // 使用這個方法注入元信息,可多次使用
    fun injectMeta(provider: MetaProvider) {
        metaMap.putAll( provider.getMetaHere() )
    }

    fun jumpTo(context: Context, tag: String) {
        if(null == metaMap[tag]) {
            // 找不到,就打個日志好了
        } else {
            context.startActivity(Intent(context, metaMap[tag]))
        }
    }
}

元信息提供者

interface MetaProvider {
    fun getMetaHere(): Map<String, Class<*>>
}

模塊中的具體實現

A 模塊中有這么一個實現類,其他模塊也是類似
class A_MetaProvider : MetaProvider {
    // 在模塊中寫這個,當然可以使用自己包含的頁面引用
    override fun getMetaHere(): Map<String, Class<*>> {
        return mapOf(
            "A-Activity111" to Activity111::class.java,
            "A-Activity222" to Activity222::class.java,
            "A-Activity333" to Activity333::class.java,
        )
    }
}

就這,已經可以用辣

class SimpleApplication: Application() {
    // 分別調用幾個模塊中的提供者,把所有模塊提供的元信息都塞到路由表里面
    override fun onCreate() {
        super.onCreate()
        Router.instance.injectMeta(A_MetaProvider())
        Router.instance.injectMeta(B_MetaProvider())
        Router.instance.injectMeta(C_MetaProvider())
    }
}

具體使用跳轉的時候就像這樣:

----------- 省略一大堆 ------------

btnTest.setOnClickListener {
    Router.instance.jumpTo(this@SimpleActivity, "A-Activity222")
}
----------- 省略一大堆 ------------

是的沒錯,雖然很多情況還沒有考慮,但這還真就是我們路由框架的關鍵流程,這個sdk中的全部,就是這少到可憐的一個類,和一個接口。

真的,就這樣吧,一滴都沒有了。


欸?好像哪里不太對,這個文章標題是啥來著???

嘿,皮一下,就很舒服。
??????????????????????????????????????????????????

從上面的代碼中可以看出,有一些代碼是屬于重復性的模板代碼的。
比如功能模塊中的Provider實現類...們,
比如Application中幾乎完全相同的三句注入代碼。

這種規律性的重復代碼,很適合在編譯期生成,可以通過APT的能力,定義一個類注解,掛在Activity類上,注解的值就是tag字符串,這樣開發者就不需要再寫這些類似清單的蛋疼代碼了。


現在開始操刀進行APT改造

現在我們主要的任務是如何自動生成元信息提供者,和自動化的注入邏輯。路由類和提供者接口是不需要動的。

首先在sdk模塊中定義一個注解

  • 這個當然是要寫到路由框架模塊中的
@Target(AnnotationTarget.CLASS)  // 這個代表此注解可以在類上使用
@Retention(AnnotationRetention.SOURCE)  // 這個是作用范圍,只在源碼期有效,編譯完就沒了,反射拿不到
annotation class RouteMeta(val value: String)

掛到Activity上

@RouteMeta("A-Activity222")
class SimpleActivity2: AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // do something
    }
}

然后跳轉還是上面寫過的onClick中的內容。

因為是跨模塊的開發,我們定義一個變量聲明在模塊的gradle中

--- build.gradle ---  這里我們定一個 "ROUTER_NAME" 變量,值就是模塊項目名,稍后APT中會使用到

android {
    ......
    defaultConfig {
        ......
        javaCompileOptions {
            annotationProcessorOptions {
                arguments = [ROUTER_NAME : project.getName()]
            }
        }
    }
}

新建一個注解處理器的module,我們就叫它route-apt

這個庫中不會包含安卓資源,所以建個java library就可以了,需要添加google的auto-service庫,我們的全部本事都靠它才能施展。還需要把注解類按原包結構直接復制一份過來。
因為java-library不能依賴android-library,所以其實最好是把注解單獨作為一個模塊,這樣sdk和apt都可以對其依賴。
gradle全文:

apply plugin: 'java-library'

java {
    sourceCompatibility = JavaVersion.VERSION_1_8
    targetCompatibility = JavaVersion.VERSION_1_8
}

dependencies {
    implementation 'com.google.auto.service:auto-service:1.0.1'
}

最核心的是一個處理器類,繼承AbstractProcessor

因為我抄了很多現有java邏輯,這一部分就不用kotlin了(才不是懶呢)。

/** 這類就是我們所有處理的入口了 */
public class MetaProcessor extends AbstractProcessor {
    private static final String KEY_ROUTER_NAME = "ROUTER_NAME";

    private Messager messager;
    private Elements elementUtils;
    private Types types;

    // 這里重寫一下初始化,拿一些工具,因為這部分代碼是編譯時過程的一部分,沒有debug的機會,
    // 只能在每個關鍵階段多打一些日志來驗證了。
    // 當然,processingEnv其實是父類的成員變量,這里不拿也可以在任何地方拿到。
    @Override
    public synchronized void init(ProcessingEnvironment processingEnv) {
        super.init(processingEnv);
        messager = processingEnv.getMessager();
        types = processingEnv.getTypeUtils();
        elementUtils = processingEnv.getElementUtils();
    }

    // 這個方法重寫一下,返回我們要處理的注解類名,用于過濾
    @Override
    public Set<String> getSupportedAnnotationTypes() {
        HashSet<String> supportTypes = new HashSet<>();
        supportTypes.add(RouteMeta.class.getCanonicalName());
        return supportTypes;
    }

    // 重點就在這里了,這是處理的入口方法,會從這里開始處理源代碼,并根據邏輯生成新代碼。
    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        return false;
    }
}

process方法邏輯相對復雜,我們拿出來單獨看

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        String routerName = processingEnv.getOptions().get(KEY_ROUTER_NAME);

        messager.printMessage(Diagnostic.Kind.NOTE, "MetaProcessor: --------- process start --------- " + routerName);

        // 搞一個map在循環時找到目標暫存進去
        Map<String, TypeElement> elementMap = new HashMap<>();
        Set<? extends Element> elements = roundEnv.getElementsAnnotatedWith(RouteMeta.class);
        for (Element item : elements) {
            if(isActivity(item.asType())) {
                RouteMeta routeMeta = item.getAnnotation(RouteMeta.class);
                messager.printMessage(Diagnostic.Kind.NOTE,
                        "MetaProcessor: found activity: " + item.asType().toString()
                                + ", path = " + routeMeta.value());
                if(!elementMap.containsKey(routeMeta.value())) {
                    elementMap.put(routeMeta.value(), (TypeElement) item);
                }
            }
        }
        messager.printMessage(Diagnostic.Kind.NOTE,
                "MetaProcessor: find activity with annotation end, count is " + elementMap.size());

        // 已經找到了當前模塊中所有帶有 RouteMeta 注解的activity,開始生成對應provider類
        generateCode(routerName, elementMap);
        return true;
    }

    // 判斷是否是activity類型
    private boolean isActivity(TypeMirror typeMirror) {
        TypeMirror activityType = elementUtils.getTypeElement("android.app.Activity").asType();
        if(!types.isSubtype(typeMirror, activityType)) {
            messager.printMessage(Diagnostic.Kind.NOTE, "MetaProcessor: unsupported annotation on type: " + typeMirror.toString());
            return false;
        }
        return true;
    }

    // 生成包含全部帶注解的 activity 的 provider,這部分就是寫代碼了,根據拿到的信息,一句一句拼出來
    private void generateCode(String routerName, Map<String, TypeElement> elementMap) {
        if(elementMap.isEmpty()) {
            return;
        }
        String packageName = "com.example.router";
        String className = routerName + "_$_RouteProvider";

        StringBuilder stringBuffer = new StringBuilder("package ").append(packageName).append(";\n\n");
        stringBuffer.append("import java.util.Map;\n");
        stringBuffer.append("import java.util.HashMap;\n");
        stringBuffer.append("import com.example.route_sdk.MetaProvider;\n\n");
        stringBuffer.append("public class ").append(className).append(" implements MetaProvider {\n");
        stringBuffer.append("public Map<String, Class<?>> getMetaHere() {\n");
        stringBuffer.append("HashMap<String, Class<?>> map = new HashMap<>();\n");
        elementMap.forEach( (k, v) -> {
            stringBuffer.append("map.put(")
                    .append("\"").append(k).append("\"")
                    .append(", ")
                    .append(v.asType().toString())
                    .append(".class);\n");
        });
        stringBuffer.append("return map;\n");
        stringBuffer.append("}\n");
        stringBuffer.append("}\n");

        try {
            Writer writer = processingEnv.getFiler()
                    .createSourceFile(className)
                    .openWriter();
            writer.write(stringBuffer.toString());
            writer.close();
            messager.printMessage(Diagnostic.Kind.NOTE, "MetaProcessor: write java file done, class name is: " + className);
        } catch (IOException e) {
            messager.printMessage(Diagnostic.Kind.NOTE, "MetaProcessor: catch IOException when write java file, class name is: " + className);
            e.printStackTrace();
        }
    }

為了讓gradle識別我們的processor,需要添加配置文件。
在src\main下創建文件夾resources,在其中創建 META-INF\services,
創建一個文本文件:javax.annotation.processing.Processor
內容是處理器全類名,如:com.example.route_apt.MetaProcessor

好,代碼寫完,build一下項目,控制臺中可以看到輸出的日志了,代表我們的Processor生效了:

看個截圖吧1.png

然后,在模塊下的build文件夾中可以分別找到生成的java文件,和臨時編譯的class文件:
build\generated\ap_generated_sources\debug\out 下的 xxx.java
build\intermediates\javac\debug\classes\com\example\person 下的 xxx.class
(我的studio版本是北極狐,gradle版本7.0,不同版本下的臨時文件目錄可能不一致。)

看個截圖吧2.png

最后再簡化一下初始注入邏輯

APT的工作此時已經完成了,上面生成的類會隨著項目代碼一起打包到最終的apk中,代碼中也是可見的。在初始化時可以通過直接引用來完成路由注入。
但我們在知道其命名規則的情況下,完全可以通過類名反射的方式來使用,避免寫過多重復代碼,這部分的反射調用數量等于模塊的數量,不會太多,性能也不會成為問題。
在Router里加一個注入方法:

public class Router {

    ..................
    ............

    /**
     * 通過模塊名和固定的包名和類后綴,拼接要加載的Provider類名,通過反射進行路由信息收集
     * @param projectNames 要加載的模塊工程名
     */
    public void injectFromProject(String... projectNames) {
        String packageName = "com.example.router.";
        String classSuffix = "_$_RouteProvider";
        try {
            for (String item : projectNames) {
                MetaProvider provider = (MetaProvider) Class.forName(packageName + item + classSuffix).newInstance();
                injectMeta(provider);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

這個方法需要傳入模塊名,還是會有一些耦合性,阿里的ARouter這部分邏輯是遍歷apk中所有dex,根據包名和類型來找出對應的Provider 的。這部分代碼比較多,這里就不貼了。

最后,在Application的onCreate中,調用此方法完成初始化,就可以啦!

Router.getInstance().injectFromProject("app", "person", "order", "xxxx");

????????????????????????????????????????????????????????????????????

--------------------------- 完結,撒花 ---------------------------

????????????????????????????????????????????????????????????????????

轉載請注明出處,@via 小風風吖-我能用注解處理器APT做什么 - 手寫一個路由框架 蟹蟹。
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,563評論 6 544
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,694評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,672評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,965評論 1 318
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,690評論 6 413
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 56,019評論 1 329
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,013評論 3 449
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,188評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,718評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,438評論 3 360
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,667評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,149評論 5 365
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,845評論 3 351
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,252評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,590評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,384評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,635評論 2 380

推薦閱讀更多精彩內容