組件化頁面路由框架實現(xiàn)原理

本文 Demo 源碼:https://github.com/asmitaliyao/RouterDemo

前言

在 app 實現(xiàn)了組件化之后,由于組件之間存在代碼隔離,不允許相互引用,所以組件之間不能進行直接溝通。而在整個 app 中,不可避免地要進行頁面跳轉(zhuǎn),包括 Activity 和 Fragment 跳轉(zhuǎn)。也就是說,組件間的頁面跳轉(zhuǎn),是在組件化開發(fā)過程中一個必須要面對的問題。
解決這個問題的方式有很多,可以想到的方案是,可以通過隱式跳轉(zhuǎn)來實現(xiàn),但是隨著頁面的增多,intent-filter 的過濾條件會增多,后期維護就更加麻煩。同時,也存在安全隱患,因為其他 app 也可以通過隱式 intent 跳轉(zhuǎn)到我們的 Activity,所以需要設置 exported = false,確保只有自己的 app 能啟動組件。隱式跳轉(zhuǎn)是原生的方案,和廣播一樣,范圍是整個 Android 系統(tǒng)。也可以直接通過反射來實現(xiàn),但是這樣會不可避免地增加很多重復的代碼。
參考計算機網(wǎng)絡中的路由器概念,將各個組件看成不同的局域網(wǎng),通過路由做中轉(zhuǎn)站,這個中轉(zhuǎn)站可以攔截一些不安全的跳轉(zhuǎn),或者設定一些特定的攔截服務。由此,誕生了一系列 Android 中的頁面路由框架,比如阿里巴巴開源的 ARouter 框架。

簡單說一下路由框架的使用,以 ARouter 為例(熟悉的可以直接略過):
1、在 module 中添加路由框架的依賴。(通常該 module 為組件化單獨的功能組件 module)

implementation ‘com.alibaba:arouter-api:1.4.0'
annotationProcessor 'com.alibaba:arouter-compiler:1.2.1'

2、在個模塊 build.gradle 的 defaultConfig 中加入。

javaCompileOptions {
    annotationProcessorOptions {
        arguments = [moduleName :project.getName() ]
    }
}

3、在 Application 中初始化路由框架。

if (BuildConfig.isDebug){
    ARouter.openLog();
    ARouter.openDebug();
    //需要在init之前配置才有效
}
ARouter.init(XXXApplication.this);

4、在支持路由的頁面上添加注解,配置路由 url。

@Route(path = "/app/main")
public class MainActivity extends BaseActivity {
    ...
}

5、在業(yè)務代碼中執(zhí)行跳轉(zhuǎn)

 ARouter.getInstance().build("/app/main").navigation();

可以看到,組件化場景下的路由跳轉(zhuǎn)和原生跳轉(zhuǎn)相比存在以下優(yōu)勢:
1、原生顯示跳轉(zhuǎn)是直接的類依賴,耦合嚴重,在組件化中,組件之間相互隔離,直接依賴會破壞組件化。路由跳轉(zhuǎn)則是通過 URL 索引,無需依賴。
2、原生隱式跳轉(zhuǎn)通過 AndroidManifest 集中管理,維護困難。路由在各自業(yè)務模塊中使用注解管理,維護更加獨立。
3、原生跳轉(zhuǎn)擴展性差。路由跳轉(zhuǎn)可以統(tǒng)一定義頁面 url,配合數(shù)據(jù)上報,可以統(tǒng)一實現(xiàn)頁面跳轉(zhuǎn)相關(guān)的數(shù)據(jù)上報功能。路由攔截,可以擴展實現(xiàn)登錄狀態(tài)檢測的攔截,可以實現(xiàn)跳轉(zhuǎn)降級等等功能。

框架功能梳理

通過上面對路由框架的簡單了解,可以知道路由框架的核心功能:對于一個給定的頁面 URL,根據(jù)映射關(guān)系表,來打開特定的頁面的組件。

需要實現(xiàn)的頁面路由框架,主要需要包含下面的能力:
1、使用 URL 標記頁面。
頁面路由框架的核心是根據(jù) URL 和頁面的映射關(guān)系去打開頁面,所以首先就需要我們開發(fā)人員去標記出來 URL 和頁面之間的對應關(guān)系,具體怎么標記需要由頁面路由框架提供。參考 ARouter 通過注解標記頁面。

2、收集 URL 和其標記的頁面。
在標記了頁面之間的對應關(guān)系之后,路由框架一定需要收集這些關(guān)系,并統(tǒng)一記錄映射關(guān)系表,這樣才能在運行時根據(jù)映射關(guān)系表來打開對應的頁面。

3、將 URL 和頁面映射關(guān)系匯總并注冊在內(nèi)存中。
比如以 Map 形式使保存 URL 和頁面完整類名的映射關(guān)系。如果在非組件化場景中,比如整個項目的頁面都在一個模塊下,那么可以直接給該映射關(guān)系表固定命名,在該模塊中直接讀取這樣一個映射關(guān)系表。如果在組件化場景中,由于組件之間沒有相互依賴,所以上面 1、2 兩步標記頁面和收集頁面的過程發(fā)生在每個子工程組件中,所以每個子工程組件中都會生成一個映射表。而為了確保整個應用在運行期間每個 URL 都能找到對應的頁面,我們就需要把所有的映射表在運行的時候注冊到路由框架中。也就是把每個子工程組件中的映射表統(tǒng)一到路由框架中。而如果采取手動注冊的方式的話,就需要在項目下 app 子工程中逐個去注冊映射表,這種人工的方式比較麻煩而且可能會有遺漏,從而導致因為映射表沒有注冊,無法通過 URL 打開頁面。針對這個問題,路由框架應該提供自動注冊的機制。

4、提供接口完成打開頁面操作。
開發(fā)者根據(jù)業(yè)務具體場景調(diào)用路由框架的提供的接口傳入具體的 URL 并調(diào)用路由功能,路由框架根據(jù) URL 在映射表中找到對應的頁面,再打開對應的 Activity,甚至是 Fragment。

5、其他可選功能。
自動生成文檔。當路由框架收集好了映射關(guān)系之后,我們可以生成一個頁面的文檔,因為打開頁面的時候我們必須得找到這個頁面對應的 URL 去打開對應頁面,而在工程中的頁面可能很多,不可能每次需要打開頁面都去問一下對應的開發(fā)人員該頁面的 URL 是什么。所以我們需要在路由框架中幫助生成一個統(tǒng)一的文檔,記錄 URL 和頁面之間的對應關(guān)系,當我們需要打開某個頁面的時候,自己去查閱文檔即可。
頁面跳轉(zhuǎn)攔截器。打開頁面的過程中,可能需要在打開某些頁面的過程中,進行攔截,處理對應的邏輯。比如在打開某些需要登錄態(tài)的頁面時,統(tǒng)一檢查登錄態(tài),如果已登錄就跳轉(zhuǎn)到指定頁面,如果未登錄則攔截打開登錄頁面。
其中,第 1 步標記頁面、第 2 步收集頁面、第 3 步注冊映射三個步驟都需要在編譯期間完成,這時候就可以考慮提供一個 gradle 插件將這些步驟封裝在里面,對于路由框架的使用者來說是非常友好的。

頁面路由——標記頁面、收集頁面

對于一個 url,根據(jù)映射關(guān)系表,來打開特定的頁面。核心是建設一個頁面 url 到真實頁面類名的映射關(guān)系表。
最無腦的方式是手動維護這樣的關(guān)系表。創(chuàng)建一個映射表工具類,里面提供一個 get() 方法,方法返回 Map 對象。在方法中,初始化 Map 對象后,不停地填入 URL 和頁面的完整類名。如下:

public class RouterMapping {

    public static Map<String, String> get() {
        Map<String, String> mapping = new HashMap<>();
        mapping.put("router://xxx/xxx", "com.example.xxx.xxx");
        // ...
        return mapping;
    }
}

這種手動維護的方式存在很多問題。其中一個問題是太過集中化,所有的開發(fā)人員都需要共同來維護這樣一個獨立的關(guān)系表。另外一個問題是在開發(fā)過程中,我們可能會需要重構(gòu)代碼,真實的類名或者包名是可能會發(fā)生變化的,而變化后需要更新這個關(guān)系表,這種情況下存在遺漏的風險。
所以我們需要的是一種分布式并且更加自動化的方式來維護映射關(guān)系表。分布式指的是,每一個開發(fā)人員在標記自己開發(fā)的頁面的時候,只需要在自己的代碼中添加標記即可,不應該影響別人的代碼。自動化指的是在分布式標記的前提下,自動匯總成一個最終的映射關(guān)系表。
這個時候就需要引入一項很方便的技術(shù):APT。

APT

APT 概述

APT 即 Annotation Processing Tool。它是 javac 的一個工具,中文意思為編譯時注解處理器。
注解,Annotation,可以理解為一種用來描述數(shù)據(jù)的標注。這里被描述的數(shù)據(jù)可以是類:比如 MainActivity,也可以是方法,也可以是變量。在 Java 中,類、方法、變量都是可以被注解進行標注的。以 @Override 注解為例,我們在創(chuàng)建 Activity 時經(jīng)常會看到它。它是用來標注重寫父類方法的注解。假如我們?nèi)サ袅?@Override 注解,仍然是可以編譯通過的。但是如果我們給一個不是重寫父類的方法添加了 @Override 注解,那么編譯的時候就會報錯,使用 IDE 的話也會在編寫代碼的時候錯誤提示出來。
即使我們在代碼中給方法標記了 @Override 注解,但是如果在代碼中沒有一個角色來對標注的注解進行識別和處理的話,這些標記其實是沒有用的。所以需要有個角色來識別和處理我們標記的注解。這個角色就是 APT 即注解處理器。首先我們知道,java 代碼是用 javac 來編譯的,而確切的說注解處理器是 javac 的一個工具,它用來在編譯時掃描和處理注解。在源代碼的編譯階段,我們可以通過 APT 來掃描代碼中的注解相關(guān)的內(nèi)容,獲取到注解和被注解對象的相關(guān)信息。最常用的用法就是在編譯階段通過掃描注解獲取到相關(guān)信息后來動態(tài)地生成一些代碼,通常都是一些具有規(guī)律性的重復代碼,省去了手動編寫的工作。獲取注解及生成代碼都是在代碼編譯的時候完成的,相比反射在運行時處理注解大大提高了程序性能。APT 的優(yōu)點就是簡單、方便,可以減少很多重復的代碼,這一點從我們 Android 項目中使用的 EventBus 注解框架就可以感受到。

APT 基本開發(fā)流程

1、創(chuàng)建注解工程,定義注解。
2、創(chuàng)建注解處理器工程,編寫注解處理器。
3、在業(yè)務模塊中調(diào)用注解與注解處理器。

下面就是 Demo 中具體的實現(xiàn)。

標記頁面

定義注解:@Destination
1、建立注解工程
建立注解子工程:router-annotations
配置 build.gradle 文件:

// 1、應用 java 插件
plugins {
    id 'java-library'
}

// 2、設置源碼兼容性
java {
    sourceCompatibility = JavaVersion.VERSION_1_7
    targetCompatibility = JavaVersion.VERSION_1_7
}

配置 settings.gradle

include ':router-annotations'

2、定義注解
在注解子工程中創(chuàng)建注解接口:Destination

@Target({ElementType.TYPE}) // 元注解,說明當前注解可以修飾的元素,此處標識可以用于標記在類上面
@Retention(RetentionPolicy.CLASS) // 元注解,說明當前注解的生命周期。也就是可以保留的時間。保留到編譯為 class 文件。
public @interface Destination {

    /**
     * 當前頁面定義的 url,不能為空
     * @return 頁面定義的 url
     */
    String url();

    /**
     * 定義當前頁面的描述
     * @return 頁面描述內(nèi)容
     */
    String description() default "no description";
}

3、使用注解
在業(yè)務代碼中添加注解依賴:

implementation project(':router-annotations')

使用注解:

@Destination(url = "/app/first", description = "first page")
public class FirstActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_first);
    }
}

收集頁面

實現(xiàn)注解處理器:DestinationProcessor
1、建立注解處理器工程
建立注解子工程:router-processor
配置 build.gradle 文件:

// 1、應用 java 插件
plugins {
    id 'java-library'
}

// 2、設置源碼兼容性
java {
    sourceCompatibility = JavaVersion.VERSION_1_7
    targetCompatibility = JavaVersion.VERSION_1_7
}

// 3、添加注解工程的依賴
dependencies {
    implementation project(':router-annotations')
}

2、定義注解處理類
在注解處理器子工程中創(chuàng)建注解處理類 DestinationProcessor,主要負責采集注解信息:

public class DestinationProcessor extends AbstractProcessor {

    private static final String TAG = "DestinationProcessor";

    /**
     * 告訴編譯器當前注解處理器支持處理哪些注解
     * 在這里返回之后,Javac 就會幫我們收集對應的注解,傳給 DestinationProcessor
     * @return
     */
    @Override
    public Set<String> getSupportedAnnotationTypes() {
        return Collections.singleton(
                Destination.class.getCanonicalName()
        );
    }

    /**
     * 編譯器幫我們收集到我們需要的注解后,會回調(diào)的方法
     * @param set 編譯器幫我們收集到的注解信息
     * @param roundEnvironment 當前的編譯環(huán)境
     * @return
     */
    @Override
    public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {
        // 避免多次調(diào)用 process
        if (roundEnvironment.processingOver()) {
            return false;
        }

        print("process called");
        // 獲取所有標記了 @Destination 注解的類的信息
        Set<? extends Element> elements = roundEnvironment.getElementsAnnotatedWith(Destination.class);
        print("all Destination elements size = " + elements.size());
        // 當未搜集到 @Destination 注解標注的類的信息時,跳過
        if (elements.isEmpty()) {
            print("process finish");
            return false;
        }

        parseRoutes(elements);

        print("process finish");

        return false;
    }

    private void parseRoutes(Set<? extends Element> elements) {
        // 遍歷所有 @Destination 注解標注的類
        for (Element element : elements) {
            final TypeElement typeElement = (TypeElement) element;
            // 嘗試在當前類上獲取 @Destination 的信息
            final Destination destination = typeElement.getAnnotation(Destination.class);
            if (destination == null) {
                continue;
            }
            final String url = destination.url();
            final String description = destination.description();
            final String realClassName = typeElement.getQualifiedName().toString();
            print("url = " + url);
            print("description = " + description);
            print("realClassName = " + realClassName);
        }
    }

    private void print(String text) {
        System.out.println(TAG + " >>>>>> " + text);
    }
}

3、注冊注解處理器
在 src/main/ 目錄下創(chuàng)建 META-INF 目錄,并在其中創(chuàng)建 service/javax.annotation.process.processor 目錄,javac 編譯器會順著此目錄和文件名查找,在文件名對應的文件中,把 DestinationProcessor 類的全類名標注進去。
或者更推薦使用 google 的 auto-service 庫,幫助我們自動完成上述步驟,更加簡單、便捷。
router-processor 子工程的 build.gradle 中添加依賴:

implementation 'com.google.auto.service:auto-service:1.0-rc6'
annotationProcessor 'com.google.auto.service:auto-service:1.0-rc6'

然后在 DestinationProcessor 類中添加注解:

@AutoService(Processor.class)
public class DestinationProcessor extends AbstractProcessor {
    ...
}

然后需要在各個業(yè)務模塊中添加注解處理器依賴:

annotationProcessor project(':router-processor')

最后可以通過命令 ./gradlew :app:assembleDebug -q 編譯驗證:


采集標注.png

4、統(tǒng)一記錄映射關(guān)系表
自動生成映射表類:

private void parseRoutes(Set<? extends Element> elements, RoundEnvironment roundEnvironment) {

    print("generate method get()");
    ClassName hashMap = ClassName.get("java.util", "HashMap");
    ClassName map = ClassName.get("java.util", "Map");
    ClassName string = ClassName.get("java.lang", "String");
    ParameterizedTypeName mapOfStringString = ParameterizedTypeName.get(map, string, string);

    MethodSpec.Builder builder = MethodSpec.methodBuilder("get")
            .addModifiers(Modifier.PUBLIC, Modifier.STATIC)
            .returns(mapOfStringString)
            .addStatement("$T mapping = new $T<>()", mapOfStringString, hashMap);
    for (Element element : elements) {
        final TypeElement typeElement = (TypeElement) element;
        // 嘗試在當前類上獲取 @Destination 的信息
        final Destination destination = typeElement.getAnnotation(Destination.class);
        if (destination == null) {
            continue;
        }
        final String url = destination.url();
        final String description = destination.description();
        final String realClassName = typeElement.getQualifiedName().toString();
        print("url = " + url);
        print("description = " + description);
        print("realClassName = " + realClassName);

        builder.addStatement("mapping.put($S, $S)", url, realClassName);
    }
    builder.addStatement("return mapping");
    MethodSpec get = builder.build();

    String className = "RouterMapping_" + System.currentTimeMillis();   // 生成的類的類名
    print("generate class " + className);
    TypeSpec clazzRouterMapping = TypeSpec.classBuilder(className)
            .addModifiers(Modifier.PUBLIC)
            .addMethod(get)
            .build();

    print("generate java file");
    JavaFile javaFile = JavaFile.builder("com.example.router.mapping", clazzRouterMapping)
            .build();

    print("write java file to...");
    try {
        javaFile.writeTo(processingEnv.getFiler());
        print("java file write to filer, success");
    } catch (IOException e) {
        print("java file write to filer, error = " + e);
    }
}

再次執(zhí)行編譯后,可在對應模塊的 build/generated/ap_generated_sources/ 內(nèi)找到對應包名路徑的 java 文件。也可以在打包好的 apk 文件中查看 classes.dex。
自動生成 .java 文件的代碼為第三方的 sdk 提供的相應的 api ,具體使用:https://github.com/square/javapoet

頁面路由——匯總映射表

雖然前面我們已經(jīng)通過注解和注解處理器生成好了頁面映射關(guān)系表,但是組件化場景下,整個應用工程是由多個子工程甚至第三方依賴組成的,這些子工程組件尤其是業(yè)務組件,可能會包含相應的 Activity 頁面。所以通過 APT 生成的頁面映射關(guān)系表,在每個子工程下是各自獨立生成的。這樣的話,在一個 app 中就可能擁有多份頁面映射表。在應用程序運行期間,為了可以實現(xiàn)跨組件路由頁面,就必須把所有的這些頁面映射關(guān)系表找到,并且注冊到內(nèi)存中去。
這種場景下,無論是子工程的代碼還是 aar 包里面的代碼,最終都會以 .class 字節(jié)碼的形式存在,然后一起被打包成為 dex 文件。所以就可以采用特定的技術(shù)捕捉到這個時間點,解析 .class 中的字節(jié)碼信息,找到其中的映射表類,把這些類匯總起來,然后生成一個具有固定名稱的映射表。在后續(xù)運行的時候,只需要注冊這一個固定名稱的總的映射表就行了。
在這個場景中,需要使用到的技術(shù)即:字節(jié)碼插樁。

字節(jié)碼插樁

字節(jié)碼:

開發(fā)人員平時編寫的代碼,一般是 java 或者 kotlin 文件,這些文件在編譯的時候其實都會被 javac 或者 kotlinc 編譯成為 .class 文件,這個 .class 文件其實就是字節(jié)碼文件。字節(jié)碼是 java 虛擬機執(zhí)行的指令的格式。字節(jié)碼隨后會被編譯成為 dex 文件,最終被打包到 apk 里面,然后在用戶的手機上運行。

字節(jié)碼插樁:

插樁是保證程序在原有的邏輯完整的基礎上,在程序中插入一些代碼段,從而達到一些諸如信息采集的目的。通俗來說,插樁就是把一段代碼通過某種策略插入到另一段代碼中去,或者是替換掉另一段代碼。而字節(jié)碼插樁就是在 .class 文件轉(zhuǎn)化為 dex 文件之前修改 .class 文件,從而達到修改或替換代碼的目的。

應用場景:

代碼插入:比如如果需要監(jiān)控應用程序里面方法的所有執(zhí)行耗時。面對這種大量的重復性的問題,首先需要考慮自動化解決。通過字節(jié)碼插樁掃描每個編譯好的 class 文件,并且使用特定規(guī)則,修改字節(jié)碼,達到監(jiān)控方法耗時的目的。
代碼替換:比如如果需要將項目中用到的某種的方法,例如 dialog.show() 方法,替換為我們自己包裝過的方法。全局快捷鍵替換,有錯誤替換風險,同時如果一些第三方的方法也用到了這個方法,這個時候全局快捷鍵替換就替換不了了。這個時候就可以在 class 編譯成 dex 之前,掃描每個 class 文件,并把對應方法的調(diào)用,統(tǒng)一替換。這種替換方式既可以避免出錯,又可以修改到第三方 jar 包中的方法。
無痕埋點、性能監(jiān)控等等場景,很多都用到了字節(jié)碼插樁技術(shù)。很多框架其實也是在編譯期生成了代碼,從而省去了開發(fā)人員的操作。

技術(shù)原理:

.java -> .class -> .dex -> .apk
1、怎么捕捉到 .class 轉(zhuǎn)換成為 .dex 的時間點?
Android 提供了 Transform 接口:A.class -> ASM -> A'.class。只需要實現(xiàn)一個 gradle 插件,在插件中提供一個自定義的 Transform,然后將其注冊到構(gòu)建過程中,就可以在 .class 轉(zhuǎn)化為 .dex 之前收到相應的回調(diào)。在這個方法的回調(diào)里面,我們將會拿到已經(jīng)編譯好的全部的 .class 的集合。然后我們需要把目標 .class 文件進行修改,得到我們最終的 .class 文件。

2、如何對 .class 文件進行修改和解析?
.class 文件是一種具有特定格式的二進制文件,如果手動去解析的話其實是比較麻煩的,我們可以借助一個名為 ASM 的工具,可以比較方便地去解析、修改甚至是生成 .class 文件。這樣我們可以稍微忽略掉 .class 文件內(nèi)部的復雜結(jié)構(gòu),專注在字節(jié)碼插樁這個事情本身上了。

3、什么是 ASM?
ASM 是一個字節(jié)碼操作庫,它可以直接修改已經(jīng)存在的 class 文件或者生成 class 文件。 ASM 提供了一系列便捷的功能來操作字節(jié)碼內(nèi)容,與其它字節(jié)碼的操作框架相比(例如 AspectJ),ASM 更加偏向于底層,直接操作字節(jié)碼,在設計上更小、更快,性能上更好,而且?guī)缀蹩梢孕薷娜我庾止?jié)碼。

Gradle 插件

基本概念

Gradle 是一個構(gòu)建工具,負責讓工程構(gòu)建變得更加自動化。不過,gradle 只是一個執(zhí)行環(huán)境,提供了基本的框架,而真正的構(gòu)建行為并不是由它來提供。gradle 負責在運行的時候找到所有需要執(zhí)行的任務,依次執(zhí)行。真正的任務,可以由我們手動創(chuàng)建任務提供,比如可以在自定義任務里面去編譯工程的 java 代碼。但是幾乎所有 android 團隊都需要去編譯 java 代碼,而如果讓所有團隊自己去實現(xiàn)編譯 java 代碼的任務的話,是極不合理的,這個時候就需要插件。
在 gradle 的世界中,幾乎所有的功能都是以插件的方式提供的。插件負責封裝 gradle 運行期間需要的 task,在工程中依賴某個插件之后,就能復用這個插件提供的構(gòu)建行為,增強了 gradle 代碼的可讀性。gradle 內(nèi)置了很多核心的語言插件,基本上能夠滿足大部分的構(gòu)建工作,但是有的插件沒有內(nèi)置,或者有些功能沒有提供,這個時候就可以通過自定義插件來解決。比如 Android Gradle 插件就是基于 Java 插件來拓展的,它在編譯 Java 代碼的基礎上,還提供了編譯資源、打包 Apk 的功能。
總的來說,gradle 插件負責提供具體的構(gòu)建功能(Task),提高了代碼的復用性。

如何使用 Gradle 插件

Gradle 插件主要有兩種類型,二進制插件和腳本插件。
1、二進制插件
通常是實現(xiàn)了 plugin 接口,它可以存在于一個獨立的編譯腳本里面,也可以作為一個獨立的工程去維護。這些插件最終會對外發(fā)布成一個插件 jar 包。我們平時使用得最多的二進制插件其實就是 android 插件。
使用二進制插件通常需要三大步驟:
1)聲明插件 id 和版本號
在項目根目錄的 build.gradle 里,找到 buildscript 代碼塊中的 dependencies 代碼塊,這里的聲明負責告訴 gradle 去哪里找對應的插件,也就是使用插件的名稱和版本號,例如 android 插件:

buildscript {
    dependencies {
        classpath 'com.android.tools.build:gradle:4.0.2'
    }
}

創(chuàng)建 Android 工程時,Android Studio 會默認添加好這些信息,不過如果后續(xù)需要升級插件版本,則需要修改這里的版本號。聲明好這些之后,gradle 會將插件下載到本地,但是還未實際將插件和工程進行綁定。

2)應用插件
在 app 子工程的 build.gradle 文件中通過 apply 關(guān)鍵字使用插件,例如:

apply plugin: 'com.android.application'

3)插件參數(shù)配置
在 apply 插件后,我們可能還需要對插件進行一些參數(shù)上的配置,是否需要配置是由插件自己去定義的。比如對于一些 android 應用來說,我們還需要指定它的 sdk 版本、包名等信息,例如:

android {
    compileSdkVersion 29
    buildToolsVersion "29.0.2"
    ...
}

2、腳本插件
它相比二進制插件顯得更加輕量級一些,因為它是一個獨立的 gradle 腳本,腳本中通常可以對工程的 build.gradle 腳本進行進一步的配置或補充。這個腳本它既可以存在于工程的目錄里面,也可以存在于某個遠程服務器地址中。一般來說,插件最開始的形式回事一個腳本插件,因為只需要新建一個腳本即可開始開發(fā),等到腳本中的代碼需要復用之后,會需要考慮把腳本插件包裝成二進制插件,方便在不同的團隊或者工程里面共享。
腳本插件之所以輕量,是因為它只是工程中的一個獨立腳本,所以腳本插件的使用方法也很簡單:
1)創(chuàng)建腳本文件,并編寫腳本代碼。
示例:工程根目錄下創(chuàng)建腳本文件 test.gradle,腳本中添加打印信息。

2)在需要使用的子工程 build.gradle 文件中聲明即可。格式為: apply from: 腳本路徑。
示例:apply from: project.rootProject.file("test.gradle")

如何開發(fā) Gradle 插件

gradle 內(nèi)置的各種核心語言插件可以滿足大部分的構(gòu)建工作,但有些插件沒有內(nèi)置或有些功能沒有提供,這個時候就可以通過自定義插件來解決。
這里主要介紹二進制插件的開發(fā)方式,主要包括三大步:
1)建立插件工程,在插件工程里面配置好插件的入口。
2)實現(xiàn)插件內(nèi)部邏輯,以及可能會需要編寫插件的參數(shù)注入邏輯。
3)發(fā)布與使用插件。

下面就是 Demo 中的具體的實現(xiàn)。

建立 Transform

建立 Transform 類,并且注冊到 gradle plugin 里面。

1、建立 buildSrc 子工程
首先在項目根目錄下創(chuàng)建文件夾且命名為 buildSrc(命名必須為 buildSrc ),然后在 buildSrc 目錄下創(chuàng)建文件且命名為 build.gradle,在其中按順序編寫以下代碼:

// 1、引入 groovy 插件,編譯插件工程中的代碼
apply plugin: 'groovy'

// 2、聲明倉庫的地址
repositories {
    mavenCentral()
    google()
}

// 3、聲明依賴的包
dependencies {
    implementation gradleApi()
    implementation localGroovy()
    implementation 'com.android.tools.build:gradle:4.2.1'
}

2、編寫 RouterMappingTransform.groovy 類
在 buildSrc 目錄下建立一個源碼目錄 src,接著在 src 下建立 main 目錄,再在 main 下建立 groovy 子目錄。
在添加類之前,需要建立好包結(jié)構(gòu),所以在 groovy 目錄下,建立 com/example/router/gradle 目錄路徑,所以包名將會是 com.example.router.gradle,然后在 gradle 包下新建 groovy 文件 RouterMappingTransform.groovy。在其中添加以下代碼:

class RouterMappingTransform extends Transform {

    /**
     * 返回當前 Transform 名稱,這個名稱會被打印到 gradle 的日志里面
     * @return
     */
    @Override
    String getName() {
        return "RouterMappingTransform"
    }

    /**
     * 返回對象的作用是用來告知編譯器,當前 Transform 需要消費的輸入類型。
     * 也就是我們需要編譯器幫我們傳入的對象的類型。
     * 這里我們要處理的對象是 class,所以要求編譯器安徽 class 類型。
     * @return
     */
    @Override
    Set<QualifiedContent.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS
    }

    /**
     * 用來告訴編譯器,當前的 Transform 需要作用的范圍是在哪里。
     * 是整個工程還是當前子工程。
     * @return
     */
    @Override
    Set<? super QualifiedContent.Scope> getScopes() {
        return TransformManager.SCOPE_FULL_PROJECT
    }

    /**
     * 告訴編譯器單簽 Transform 是否支持增量
     * 通常直接返回 false
     * @return
     */
    @Override
    boolean isIncremental() {
        return false
    }

    /**
     * 當編譯器把所有的 class 都收集好以后,會將它們打包成為 TransformInvocation
     * 然后通過這個方法將打包好的結(jié)果回調(diào)給我們
     * 所以我們就可以在這個方法里面對回調(diào)給我們的 class 作二次處理。
     * @param transformInvocation
     * @throws TransformException
     * @throws InterruptedException
     * @throws IOException
     */
    @Override
    void transform(TransformInvocation transformInvocation)
            throws TransformException, InterruptedException, IOException {
        super.transform(transformInvocation)
        // 1、遍歷所有的 input
        // 2、對 input 進行二次處理
        // 3、將 input 拷貝到目標目錄
        // 其中 1、3 步是固定的
        
        // 遍歷所有的 input
        transformInvocation.inputs.each {
            // 把工程中文件夾類型的輸入拷貝到目標目錄
            it.directoryInputs.each {directoryInput ->
                def destDir = transformInvocation.outputProvider
                        .getContentLocation(
                                directoryInput.name,
                                directoryInput.contentTypes,
                                directoryInput.scopes,
                                Format.DIRECTORY)

                FileUtils.copyDirectory(directoryInput.file, destDir)
            }
            // 把工程中 jar 類型的輸入拷貝到目標目錄
            it.jarInputs.each {jarInput ->
                def dest = transformInvocation.outputProvider
                        .getContentLocation(
                                jarInput.name,
                                jarInput.contentTypes,
                                jarInput.scopes,
                                Format.JAR)
                FileUtils.copyFile(jarInput.file, dest)
            }
        }
    }
}

3、注冊 RouterMappingTransform
然后在 gradle 包下新建 groovy 文件 RouterPlugin.groovy。在其中添加以下代碼:

class RouterPlugin implements Plugin<Project> {

    @Override
    void apply(Project project) {
        // 當采用 apply 關(guān)鍵字在工程里面去引用插件的時候,apply 方法里面的邏輯將會被執(zhí)行
        // 所以這里可以寫注入插件的邏輯,比如往工程里面動態(tài)添加 task
        println("RouterPlugin, apply from $project.name")

        // 判斷當前工程是否有 com.android.application
        if (project.plugins.hasPlugin(AppPlugin)) {
            // 注冊 Transform
            AppExtension appExtension = project.extensions.getByType(AppExtension)
            Transform transform = new RouterMappingTransform()
            appExtension.registerTransform(transform)
        }
    }
}

在 buildSrc 的 main 目錄下新建 resources 目錄,并在其中建立子目錄 META-INF,再在其中添加子目錄 gradle-plugins,在 gradle-plugin 目錄下新建 com.example.router.properties 文件。在其中添加以下代碼:

implementation-class=com.example.router.gradle.RouterPlugin

在 app 子工程下的 build.gradle 文件中添加以下代碼后,執(zhí)行編譯命令,即可看到輸出內(nèi)容 RouterPlugin, apply from app。

plugins {
    id 'com.android.application'
    id 'com.example.router'  // 添加的代碼,引入 gradle 插件
}

在 app 工程下 build/intermediates/transforms/ 目錄下能夠看到生成的 RouterMappingTransform 文件夾,即代表 transform 操作成功。

收集目標類

transform 操作成功后,下面就要開始收集 RouterMapping_xxx.class 文件了。
在 gradle 包下新建 groovy 文件 RouterMappingCollector.groovy ,并編寫以下代碼:

class RouterMappingCollector {

    private static final String PACKAGE_NAME = "com/example/router/mapping"
    private static final String CLASS_NAME_PREFIX = "RouterMapping_"
    private static final String CLASS_FILE_SUFFIX = ".class"

    private final Set<String> mappingClassNames = new HashSet<>()

    /**
     * 獲取收集到的映射表類名
     * @return
     */
    Set<String> getMappingClassNames() {
        return mappingClassNames
    }

    /**
     * 收集傳遞進來的 class 文件或者 class 文件目錄中的映射表類
     * @param classFile
     */
    void collect(File classFile) {
        if (classFile == null || !classFile.exists()) return
        if (classFile.isFile()) {
            // 是 class 文件
            if (classFile.absolutePath.contains(PACKAGE_NAME)
                    && classFile.name.startsWith(CLASS_NAME_PREFIX)
                    && classFile.name.endsWith(CLASS_FILE_SUFFIX)) {
                // 同時滿足:1、絕對路徑包含包名。2、文件名為"RouterMapping_"開頭。3、文件名以".class"結(jié)尾。
                String className = classFile.name.replace(CLASS_FILE_SUFFIX, "")
                mappingClassNames.add(className)
            }
        } else {
            // 是一個目錄
            classFile.listFiles().each {
                collect(it)
            }
        }
    }

    /**
     * 收集 jar 包中的映射表類
     * @param jarFile
     */
    void collectFromJarFile(File jarFile) {
        Enumeration enumeration = new JarFile(jarFile).entries()

        while (enumeration.hasMoreElements()) {
            JarEntry jarEntry = enumeration.nextElement()
            String entryName = jarEntry.name

            if (entryName.contains(PACKAGE_NAME)
                    && entryName.contains(CLASS_NAME_PREFIX)
                    && entryName.contains(CLASS_FILE_SUFFIX)) {
                String className = entryName
                        .replace(PACKAGE_NAME, "")
                        .replace("/", "")
                        .replace(CLASS_FILE_SUFFIX, "")
                mappingClassNames.add(className)
            }
        }
    }
}

clean 以后重新編譯工程,可以看到下面的日志:


收集目標類.png

生成匯總映射表

1、首先規(guī)劃一下最終生成好匯總映射表類的內(nèi)容,類似下面的代碼:

public class RouterMapping {
    
    public static Map<String, String> get() {
        Map<String, String> mapping = new HashMap<>();
        
        mapping.putAll(RouterMapping_1.get());
        mapping.putAll(RouterMapping_2.get());
        // ...
        
        return mapping;
    }
}

2、開始編碼實現(xiàn)生成匯總的映射表。
在 gradle 包下新建 groovy 文件 RouterMappingByteCodeBuilder.groovy ,并編寫以下代碼:

class RouterMappingByteCodeBuilder implements Opcodes{

    public static final String CLASS_NAME = "com/example/router/mapping/RouterMapping"

    static byte[] get(Set<String> allMappingNames) {
        // 1、創(chuàng)建一個類
        // 2、創(chuàng)建一個構(gòu)造方法(手動生成字節(jié)碼的時候,構(gòu)造方法需要由我們手動創(chuàng)建)
        // 3、創(chuàng)建一個 get() 方法
        //  1)創(chuàng)建一個 map
        //  2)向 map 中裝入所有映射表的內(nèi)容
        //  3)返回 map
    }

}

其中,我們需要在 get 方法中實現(xiàn) 1、2、3 步邏輯對應的字節(jié)碼,并返回 byte[]。直接編寫 java 字節(jié)碼,其實門檻是比較高的,因為我們不僅需要去關(guān)注具體的邏輯的實現(xiàn),還必須確保我們生成的字節(jié)碼是符合虛擬機規(guī)范的。這里我們引入一個 ASM 工具,它把字節(jié)碼相關(guān)的操作都封裝成了一系列接口供我們調(diào)用(但是這個 ASM 工具提供的接口其實也很多很復雜)。

Android Studio -> Preferences -> 搜索 plugin -> 搜索 ASM Bytecode Viewer Sypport Kotlin,安裝并重啟。然后再 RouterMapping.java 類上右鍵選擇 ASM Bytecode Viewer,幫助查看對應的字節(jié)碼文件。
如下圖:


查看字節(jié)碼文件.png

選擇 ASMified Tab 選項卡,可以看到工具幫助我們生成的編寫字節(jié)碼的 java 代碼。


編寫字節(jié)碼的 java 代碼.png

下面就可以開始參考工具生成的代碼開始編寫 RouterMappingBytecodeBuilder 的代碼:

class RouterMappingBytecodeBuilder implements Opcodes {

    public static final String CLASS_NAME = "com/example/router/mapping/RouterMapping"

    static byte[] get(Set<String> allMappingNames) {
        // 1、創(chuàng)建一個類
        // 2、創(chuàng)建一個構(gòu)造方法(手動生成字節(jié)碼的時候,構(gòu)造方法需要由我們手動創(chuàng)建)
        // 3、創(chuàng)建一個 get() 方法
        //  1)創(chuàng)建一個 map
        //  2)向 map 中裝入所有映射表的內(nèi)容
        //  3)返回 map

        ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS)
        MethodVisitor methodVisitor

        // 創(chuàng)建類
        classWriter.visit(V1_8,
                ACC_PUBLIC | ACC_SUPER,
                CLASS_NAME,
                null,
                "java/lang/Object",
                null)

        classWriter.visitSource("RouterMapping.java", null);

        // 創(chuàng)建構(gòu)造方法
        methodVisitor = classWriter.visitMethod(
                ACC_PUBLIC,
                "<init>",
                "()V",
                null,
                null)
        methodVisitor.visitCode()   // 開啟字節(jié)碼的生成或訪問,下面開始寫字節(jié)碼指令

        methodVisitor.visitVarInsn(ALOAD, 0)
        methodVisitor.visitMethodInsn(INVOKESPECIAL,
                "java/lang/Object",
                "<init>",
                "()V",
                false)
        methodVisitor.visitInsn(RETURN)

        methodVisitor.visitMaxs(1, 1)
        methodVisitor.visitEnd()    // 關(guān)閉字節(jié)碼的生成或訪問

        // 創(chuàng)建 get() 方法
        methodVisitor = classWriter.visitMethod(
                ACC_PUBLIC | ACC_STATIC,
                "get",
                "()Ljava/util/Map;",
                "()Ljava/util/Map<Ljava/lang/String;Ljava/lang/String;>;",
                null)
        methodVisitor.visitCode()

        methodVisitor.visitTypeInsn(NEW, "java/util/HashMap")   // 創(chuàng)建一個 map
        methodVisitor.visitInsn(DUP)    // 將其入棧
        methodVisitor.visitMethodInsn(INVOKESPECIAL,
                "java/util/HashMap",
                "<init>",
                "()V",
                false)  // 入棧后調(diào)用 HashMap 的構(gòu)造方法得到 HashMap 的實例
        methodVisitor.visitVarInsn(ASTORE, 0)   // 將 map 保存起來

        // 向匯總映射表中裝入所有子工程生成的映射表
        allMappingNames.each {
            methodVisitor.visitVarInsn(ALOAD, 0)
            methodVisitor.visitMethodInsn(INVOKESTATIC,
                    "com/example/router/mapping/$it",
                    "get",
                    "()Ljava/util/Map;",
                    false)
            methodVisitor.visitMethodInsn(INVOKEINTERFACE,
                    "java/util/Map",
                    "putAll",
                    "(Ljava/util/Map;)V",
                    true)
        }
        methodVisitor.visitVarInsn(ALOAD, 0)
        methodVisitor.visitInsn(ARETURN)
        methodVisitor.visitMaxs(2, 1)

        methodVisitor.visitEnd()
        classWriter.visitEnd()

        return classWriter.toByteArray();
    }

}

在完成生成字節(jié)碼的編碼之后,接下來我們就要將生成的字節(jié)碼寫入 class 文件。所以回到 RouterMappingTransform.groovy 文件,編寫以下代碼:

@Override
void transform(TransformInvocation transformInvocation)
        throws TransformException, InterruptedException, IOException {
    super.transform(transformInvocation)
    ...
    // 將生成的字節(jié)碼寫入文件
    File mappingJarFile = transformInvocation.outputProvider
            .getContentLocation(
                    "router_mapping",
                    getOutputTypes(),
                    getScopes(),
                    Format.JAR
            )   // 得到即將生成的 jar 包存放的位置
    println(getName() + " mappingJarFile = " + mappingJarFile)
    if (!mappingJarFile.getParentFile().exists()) {
        mappingJarFile.getParentFile().mkdirs()
    }
    if (mappingJarFile.exists()) {
        mappingJarFile.delete()
    }
    FileOutputStream fileOutPutStream = new FileOutputStream(mappingJarFile)
    JarOutputStream jarOutputStream = new JarOutputStream(fileOutPutStream)
    ZipEntry zipEntry = new ZipEntry(RouterMappingBytecodeBuilder.CLASS_NAME + ".class")
    jarOutputStream.putNextEntry(zipEntry)
    jarOutputStream.write(RouterMappingBytecodeBuilder.get(collector.mappingClassNames))
    jarOutputStream.closeEntry()
    jarOutputStream.close()
    fileOutPutStream.close()
}

最后 clean 后再編譯工程,輸出以下日志:


字節(jié)碼編碼日志.png

然后再對應的目錄下可以查看到 45.jar,解壓該 jar 包,可以看到生成的 class 文件:


編譯目錄查看字節(jié)碼編碼結(jié)果.png

在編譯生成的 apk 文件中也能看到生成的 RouterMapping 文件:


apk 中查看字節(jié)碼編碼結(jié)果.png

頁面路由——打開頁面

最后需要完成的主要功能就是設計接口,讓應用在運行期間通過傳入 url 在映射文件中查找對應類名,執(zhí)行打開對應頁面操作。
首先,肯定需要建立一個子工程,用于開發(fā)相關(guān)的代碼。
然后,因為當應用運行時,我們需要把在編譯期生成好的頁面映射加載到內(nèi)存中。所以需要提供相應的 init 方法。
接下來,就是開發(fā)路由接口,在應用運行時等待傳入 url,然后再對 url 進行匹配。
最后,實現(xiàn)打開 Activity 跳轉(zhuǎn)到相應的頁面的邏輯。
當然,也可以擴展一些跳轉(zhuǎn) Fragment、跳轉(zhuǎn)攜帶參數(shù)、路由攔截的功能。

1、創(chuàng)建 router-api 工程,編寫初始化代碼:

public class Router {

    private static final String TAG = "Router";

    // 編譯期間生成的總映射表
    private static final String GENERATED_MAPPING = "com.example.router.mapping.RouterMapping";

    // 存儲所有映射表信息
    private static Map<String, String> mapping = new HashMap<>();

    public static void init() {
        // 反射獲取 GENERATED_MAPPING 類的 get() 方法
        try {
            Class<?> clazz = Class.forName(GENERATED_MAPPING);
            Method getMethod = clazz.getMethod("get");
            Map<String, String> allMapping = (Map<String, String>) getMethod.invoke(null);
            if (allMapping != null && !allMapping.isEmpty()) {
                Log.i(TAG, "init: get all mapping");
                Set<Map.Entry<String, String>> entrySet = allMapping.entrySet();
                for (Map.Entry<String, String> entry : entrySet) {
                    Log.i(TAG, "mapping: key = " + entry.getKey() + ", value = " + entry.getValue());
                }
                mapping.putAll(allMapping);
            }
        } catch (ClassNotFoundException e) {
            Log.e(TAG, "init called: " + e);
        } catch (NoSuchMethodException e) {
            Log.e(TAG, "init called: " + e);
        } catch (IllegalAccessException e) {
            Log.e(TAG, "init called: " + e);
        } catch (InvocationTargetException e) {
            Log.e(TAG, "init called: " + e);
        }
    }
}

然后在應用中初始化:

public class MyApplication extends Application {

    @Override
    public void onCreate() {
        super.onCreate();
        Router.init();
    }
}

編譯驗證有以下日志輸出:

2021-08-19 11:28:01.347 17799-17799/com.example.routerdemo I/Router: init: get all mapping
2021-08-19 11:28:01.347 17799-17799/com.example.routerdemo I/Router: mapping: key = /app/second, value = com.example.bm_a.SecondActivity
2021-08-19 11:28:01.347 17799-17799/com.example.routerdemo I/Router: mapping: key = /app/third, value = com.example.bm_b.ThirdActivity
2021-08-19 11:28:01.347 17799-17799/com.example.routerdemo I/Router: mapping: key = /app/first, value = com.example.bm_a.FirstActivity

2、實現(xiàn) url 的匹配和打卡頁面。

public static void navigation(Context context, String url) {
    if (context == null || TextUtils.isEmpty(url)) {
        Log.i(TAG, "navigation called: param error");
        return;
    }
    // 1、匹配 url,找到目標頁面
    Uri uri = Uri.parse(url);
    String scheme = uri.getScheme();
    String host = uri.getHost();
    String path = uri.getPath();

    String targetActivityClass = "";
    Set<Map.Entry<String, String>> entries = mapping.entrySet();
    for (Map.Entry<String, String> entry : entries) {
        Uri sUri = Uri.parse(entry.getKey());
        String sScheme = sUri.getScheme();
        String sHost = sUri.getHost();
        String sPath = sUri.getPath();

        if (TextUtils.equals(scheme, sScheme)
                && TextUtils.equals(host, sHost)
                && TextUtils.equals(path, sPath)) {
            targetActivityClass = entry.getValue();
        }
    }

    if (TextUtils.isEmpty(targetActivityClass)) {
        Log.i(TAG, "navigation called: no destination found");
        return;
    }

    // 2、打開對應頁面
    try {
        Class<?> clazz = Class.forName(targetActivityClass);
        Intent intent = new Intent(context, clazz);
        context.startActivity(intent);
    } catch (ClassNotFoundException e) {
        Log.e(TAG, "navigation called: " + e);
    }
}

在工程中驗證:

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_first);

    findViewById(R.id.button1).setOnClickListener(v ->
            Router.navigation(FirstActivity.this, "router://example.com/app/second"));

    findViewById(R.id.button2).setOnClickListener(v ->
            Router.navigation(FirstActivity.this, "router://example.com/app/third"));
}

總結(jié)

本文主要分享了組件化頁面路由框架的核心實現(xiàn)思路,并在 Demo 中實現(xiàn)了路由功能的基本邏輯。在這個過程中,觸及到了 APT、字節(jié)碼插樁、Gradle 插件開發(fā)等各個知識點,實際上本次分享中對這些技術(shù)的介紹都還只是簡單的應用。在實際項目過程中,還是推薦使用 ARouter 等成熟的框架。不過在實際工作中,通過對 APT、字節(jié)碼插樁、Gradle 插件等技術(shù)的簡單了解,能為一些問題或方案設計提供更多的思路。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內(nèi)容