组件化页面路由框架实现原理

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

前言

在 app 实现了组件化之后,由于组件之间存在代码隔离,不允许相互引用,所以组件之间不能进行直接沟通。而在整个 app 中,不可避免地要进行页面跳转,包括 Activity 和 Fragment 跳转。也就是说,组件间的页面跳转,是在组件化开发过程中一个必须要面对的问题。
解决这个问题的方式有很多,可以想到的方案是,可以通过隐式跳转来实现,但是随着页面的增多,intent-filter 的过滤条件会增多,后期维护就更加麻烦。同时,也存在安全隐患,因为其他 app 也可以通过隐式 intent 跳转到我们的 Activity,所以需要设置 exported = false,确保只有自己的 app 能启动组件。隐式跳转是原生的方案,和广播一样,范围是整个 Android 系统。也可以直接通过反射来实现,但是这样会不可避免地增加很多重复的代码。
参考计算机网络中的路由器概念,将各个组件看成不同的局域网,通过路由做中转站,这个中转站可以拦截一些不安全的跳转,或者设定一些特定的拦截服务。由此,诞生了一系列 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、在业务代码中执行跳转

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

可以看到,组件化场景下的路由跳转和原生跳转相比存在以下优势:
1、原生显示跳转是直接的类依赖,耦合严重,在组件化中,组件之间相互隔离,直接依赖会破坏组件化。路由跳转则是通过 URL 索引,无需依赖。
2、原生隐式跳转通过 AndroidManifest 集中管理,维护困难。路由在各自业务模块中使用注解管理,维护更加独立。
3、原生跳转扩展性差。路由跳转可以统一定义页面 url,配合数据上报,可以统一实现页面跳转相关的数据上报功能。路由拦截,可以扩展实现登录状态检测的拦截,可以实现跳转降级等等功能。

框架功能梳理

通过上面对路由框架的简单了解,可以知道路由框架的核心功能:对于一个给定的页面 URL,根据映射关系表,来打开特定的页面的组件。

需要实现的页面路由框架,主要需要包含下面的能力:
1、使用 URL 标记页面。
页面路由框架的核心是根据 URL 和页面的映射关系去打开页面,所以首先就需要我们开发人员去标记出来 URL 和页面之间的对应关系,具体怎么标记需要由页面路由框架提供。参考 ARouter 通过注解标记页面。

2、收集 URL 和其标记的页面。
在标记了页面之间的对应关系之后,路由框架一定需要收集这些关系,并统一记录映射关系表,这样才能在运行时根据映射关系表来打开对应的页面。

3、将 URL 和页面映射关系汇总并注册在内存中。
比如以 Map 形式使保存 URL 和页面完整类名的映射关系。如果在非组件化场景中,比如整个项目的页面都在一个模块下,那么可以直接给该映射关系表固定命名,在该模块中直接读取这样一个映射关系表。如果在组件化场景中,由于组件之间没有相互依赖,所以上面 1、2 两步标记页面和收集页面的过程发生在每个子工程组件中,所以每个子工程组件中都会生成一个映射表。而为了确保整个应用在运行期间每个 URL 都能找到对应的页面,我们就需要把所有的映射表在运行的时候注册到路由框架中。也就是把每个子工程组件中的映射表统一到路由框架中。而如果采取手动注册的方式的话,就需要在项目下 app 子工程中逐个去注册映射表,这种人工的方式比较麻烦而且可能会有遗漏,从而导致因为映射表没有注册,无法通过 URL 打开页面。针对这个问题,路由框架应该提供自动注册的机制。

4、提供接口完成打开页面操作。
开发者根据业务具体场景调用路由框架的提供的接口传入具体的 URL 并调用路由功能,路由框架根据 URL 在映射表中找到对应的页面,再打开对应的 Activity,甚至是 Fragment。

5、其他可选功能。
自动生成文档。当路由框架收集好了映射关系之后,我们可以生成一个页面的文档,因为打开页面的时候我们必须得找到这个页面对应的 URL 去打开对应页面,而在工程中的页面可能很多,不可能每次需要打开页面都去问一下对应的开发人员该页面的 URL 是什么。所以我们需要在路由框架中帮助生成一个统一的文档,记录 URL 和页面之间的对应关系,当我们需要打开某个页面的时候,自己去查阅文档即可。
页面跳转拦截器。打开页面的过程中,可能需要在打开某些页面的过程中,进行拦截,处理对应的逻辑。比如在打开某些需要登录态的页面时,统一检查登录态,如果已登录就跳转到指定页面,如果未登录则拦截打开登录页面。
其中,第 1 步标记页面、第 2 步收集页面、第 3 步注册映射三个步骤都需要在编译期间完成,这时候就可以考虑提供一个 gradle 插件将这些步骤封装在里面,对于路由框架的使用者来说是非常友好的。

页面路由——标记页面、收集页面

对于一个 url,根据映射关系表,来打开特定的页面。核心是建设一个页面 url 到真实页面类名的映射关系表。
最无脑的方式是手动维护这样的关系表。创建一个映射表工具类,里面提供一个 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;
    }
}

这种手动维护的方式存在很多问题。其中一个问题是太过集中化,所有的开发人员都需要共同来维护这样一个独立的关系表。另外一个问题是在开发过程中,我们可能会需要重构代码,真实的类名或者包名是可能会发生变化的,而变化后需要更新这个关系表,这种情况下存在遗漏的风险。
所以我们需要的是一种分布式并且更加自动化的方式来维护映射关系表。分布式指的是,每一个开发人员在标记自己开发的页面的时候,只需要在自己的代码中添加标记即可,不应该影响别人的代码。自动化指的是在分布式标记的前提下,自动汇总成一个最终的映射关系表。
这个时候就需要引入一项很方便的技术:APT。

APT

APT 概述

APT 即 Annotation Processing Tool。它是 javac 的一个工具,中文意思为编译时注解处理器。
注解,Annotation,可以理解为一种用来描述数据的标注。这里被描述的数据可以是类:比如 MainActivity,也可以是方法,也可以是变量。在 Java 中,类、方法、变量都是可以被注解进行标注的。以 @Override 注解为例,我们在创建 Activity 时经常会看到它。它是用来标注重写父类方法的注解。假如我们去掉了 @Override 注解,仍然是可以编译通过的。但是如果我们给一个不是重写父类的方法添加了 @Override 注解,那么编译的时候就会报错,使用 IDE 的话也会在编写代码的时候错误提示出来。
即使我们在代码中给方法标记了 @Override 注解,但是如果在代码中没有一个角色来对标注的注解进行识别和处理的话,这些标记其实是没有用的。所以需要有个角色来识别和处理我们标记的注解。这个角色就是 APT 即注解处理器。首先我们知道,java 代码是用 javac 来编译的,而确切的说注解处理器是 javac 的一个工具,它用来在编译时扫描和处理注解。在源代码的编译阶段,我们可以通过 APT 来扫描代码中的注解相关的内容,获取到注解和被注解对象的相关信息。最常用的用法就是在编译阶段通过扫描注解获取到相关信息后来动态地生成一些代码,通常都是一些具有规律性的重复代码,省去了手动编写的工作。获取注解及生成代码都是在代码编译的时候完成的,相比反射在运行时处理注解大大提高了程序性能。APT 的优点就是简单、方便,可以减少很多重复的代码,这一点从我们 Android 项目中使用的 EventBus 注解框架就可以感受到。

APT 基本开发流程

1、创建注解工程,定义注解。
2、创建注解处理器工程,编写注解处理器。
3、在业务模块中调用注解与注解处理器。

下面就是 Demo 中具体的实现。

标记页面

定义注解:@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、定义注解
在注解子工程中创建注解接口:Destination

@Target({ElementType.TYPE}) // 元注解,说明当前注解可以修饰的元素,此处标识可以用于标记在类上面
@Retention(RetentionPolicy.CLASS) // 元注解,说明当前注解的生命周期。也就是可以保留的时间。保留到编译为 class 文件。
public @interface Destination {

    /**
     * 当前页面定义的 url,不能为空
     * @return 页面定义的 url
     */
    String url();

    /**
     * 定义当前页面的描述
     * @return 页面描述内容
     */
    String description() default "no description";
}

3、使用注解
在业务代码中添加注解依赖:

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);
    }
}

收集页面

实现注解处理器: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、定义注解处理类
在注解处理器子工程中创建注解处理类 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()
        );
    }

    /**
     * 编译器帮我们收集到我们需要的注解后,会回调的方法
     * @param set 编译器帮我们收集到的注解信息
     * @param roundEnvironment 当前的编译环境
     * @return
     */
    @Override
    public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {
        // 避免多次调用 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/ 目录下创建 META-INF 目录,并在其中创建 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 {
    ...
}

然后需要在各个业务模块中添加注解处理器依赖:

annotationProcessor project(':router-processor')

最后可以通过命令 ./gradlew :app:assembleDebug -q 编译验证:


采集标注.png

4、统一记录映射关系表
自动生成映射表类:

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);
    }
}

再次执行编译后,可在对应模块的 build/generated/ap_generated_sources/ 内找到对应包名路径的 java 文件。也可以在打包好的 apk 文件中查看 classes.dex。
自动生成 .java 文件的代码为第三方的 sdk 提供的相应的 api ,具体使用:https://github.com/square/javapoet

页面路由——汇总映射表

虽然前面我们已经通过注解和注解处理器生成好了页面映射关系表,但是组件化场景下,整个应用工程是由多个子工程甚至第三方依赖组成的,这些子工程组件尤其是业务组件,可能会包含相应的 Activity 页面。所以通过 APT 生成的页面映射关系表,在每个子工程下是各自独立生成的。这样的话,在一个 app 中就可能拥有多份页面映射表。在应用程序运行期间,为了可以实现跨组件路由页面,就必须把所有的这些页面映射关系表找到,并且注册到内存中去。
这种场景下,无论是子工程的代码还是 aar 包里面的代码,最终都会以 .class 字节码的形式存在,然后一起被打包成为 dex 文件。所以就可以采用特定的技术捕捉到这个时间点,解析 .class 中的字节码信息,找到其中的映射表类,把这些类汇总起来,然后生成一个具有固定名称的映射表。在后续运行的时候,只需要注册这一个固定名称的总的映射表就行了。
在这个场景中,需要使用到的技术即:字节码插桩。

字节码插桩

字节码:

开发人员平时编写的代码,一般是 java 或者 kotlin 文件,这些文件在编译的时候其实都会被 javac 或者 kotlinc 编译成为 .class 文件,这个 .class 文件其实就是字节码文件。字节码是 java 虚拟机执行的指令的格式。字节码随后会被编译成为 dex 文件,最终被打包到 apk 里面,然后在用户的手机上运行。

字节码插桩:

插桩是保证程序在原有的逻辑完整的基础上,在程序中插入一些代码段,从而达到一些诸如信息采集的目的。通俗来说,插桩就是把一段代码通过某种策略插入到另一段代码中去,或者是替换掉另一段代码。而字节码插桩就是在 .class 文件转化为 dex 文件之前修改 .class 文件,从而达到修改或替换代码的目的。

应用场景:

代码插入:比如如果需要监控应用程序里面方法的所有执行耗时。面对这种大量的重复性的问题,首先需要考虑自动化解决。通过字节码插桩扫描每个编译好的 class 文件,并且使用特定规则,修改字节码,达到监控方法耗时的目的。
代码替换:比如如果需要将项目中用到的某种的方法,例如 dialog.show() 方法,替换为我们自己包装过的方法。全局快捷键替换,有错误替换风险,同时如果一些第三方的方法也用到了这个方法,这个时候全局快捷键替换就替换不了了。这个时候就可以在 class 编译成 dex 之前,扫描每个 class 文件,并把对应方法的调用,统一替换。这种替换方式既可以避免出错,又可以修改到第三方 jar 包中的方法。
无痕埋点、性能监控等等场景,很多都用到了字节码插桩技术。很多框架其实也是在编译期生成了代码,从而省去了开发人员的操作。

技术原理:

.java -> .class -> .dex -> .apk
1、怎么捕捉到 .class 转换成为 .dex 的时间点?
Android 提供了 Transform 接口:A.class -> ASM -> A'.class。只需要实现一个 gradle 插件,在插件中提供一个自定义的 Transform,然后将其注册到构建过程中,就可以在 .class 转化为 .dex 之前收到相应的回调。在这个方法的回调里面,我们将会拿到已经编译好的全部的 .class 的集合。然后我们需要把目标 .class 文件进行修改,得到我们最终的 .class 文件。

2、如何对 .class 文件进行修改和解析?
.class 文件是一种具有特定格式的二进制文件,如果手动去解析的话其实是比较麻烦的,我们可以借助一个名为 ASM 的工具,可以比较方便地去解析、修改甚至是生成 .class 文件。这样我们可以稍微忽略掉 .class 文件内部的复杂结构,专注在字节码插桩这个事情本身上了。

3、什么是 ASM?
ASM 是一个字节码操作库,它可以直接修改已经存在的 class 文件或者生成 class 文件。 ASM 提供了一系列便捷的功能来操作字节码内容,与其它字节码的操作框架相比(例如 AspectJ),ASM 更加偏向于底层,直接操作字节码,在设计上更小、更快,性能上更好,而且几乎可以修改任意字节码。

Gradle 插件

基本概念

Gradle 是一个构建工具,负责让工程构建变得更加自动化。不过,gradle 只是一个执行环境,提供了基本的框架,而真正的构建行为并不是由它来提供。gradle 负责在运行的时候找到所有需要执行的任务,依次执行。真正的任务,可以由我们手动创建任务提供,比如可以在自定义任务里面去编译工程的 java 代码。但是几乎所有 android 团队都需要去编译 java 代码,而如果让所有团队自己去实现编译 java 代码的任务的话,是极不合理的,这个时候就需要插件。
在 gradle 的世界中,几乎所有的功能都是以插件的方式提供的。插件负责封装 gradle 运行期间需要的 task,在工程中依赖某个插件之后,就能复用这个插件提供的构建行为,增强了 gradle 代码的可读性。gradle 内置了很多核心的语言插件,基本上能够满足大部分的构建工作,但是有的插件没有内置,或者有些功能没有提供,这个时候就可以通过自定义插件来解决。比如 Android Gradle 插件就是基于 Java 插件来拓展的,它在编译 Java 代码的基础上,还提供了编译资源、打包 Apk 的功能。
总的来说,gradle 插件负责提供具体的构建功能(Task),提高了代码的复用性。

如何使用 Gradle 插件

Gradle 插件主要有两种类型,二进制插件和脚本插件。
1、二进制插件
通常是实现了 plugin 接口,它可以存在于一个独立的编译脚本里面,也可以作为一个独立的工程去维护。这些插件最终会对外发布成一个插件 jar 包。我们平时使用得最多的二进制插件其实就是 android 插件。
使用二进制插件通常需要三大步骤:
1)声明插件 id 和版本号
在项目根目录的 build.gradle 里,找到 buildscript 代码块中的 dependencies 代码块,这里的声明负责告诉 gradle 去哪里找对应的插件,也就是使用插件的名称和版本号,例如 android 插件:

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

创建 Android 工程时,Android Studio 会默认添加好这些信息,不过如果后续需要升级插件版本,则需要修改这里的版本号。声明好这些之后,gradle 会将插件下载到本地,但是还未实际将插件和工程进行绑定。

2)应用插件
在 app 子工程的 build.gradle 文件中通过 apply 关键字使用插件,例如:

apply plugin: 'com.android.application'

3)插件参数配置
在 apply 插件后,我们可能还需要对插件进行一些参数上的配置,是否需要配置是由插件自己去定义的。比如对于一些 android 应用来说,我们还需要指定它的 sdk 版本、包名等信息,例如:

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

2、脚本插件
它相比二进制插件显得更加轻量级一些,因为它是一个独立的 gradle 脚本,脚本中通常可以对工程的 build.gradle 脚本进行进一步的配置或补充。这个脚本它既可以存在于工程的目录里面,也可以存在于某个远程服务器地址中。一般来说,插件最开始的形式回事一个脚本插件,因为只需要新建一个脚本即可开始开发,等到脚本中的代码需要复用之后,会需要考虑把脚本插件包装成二进制插件,方便在不同的团队或者工程里面共享。
脚本插件之所以轻量,是因为它只是工程中的一个独立脚本,所以脚本插件的使用方法也很简单:
1)创建脚本文件,并编写脚本代码。
示例:工程根目录下创建脚本文件 test.gradle,脚本中添加打印信息。

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

如何开发 Gradle 插件

gradle 内置的各种核心语言插件可以满足大部分的构建工作,但有些插件没有内置或有些功能没有提供,这个时候就可以通过自定义插件来解决。
这里主要介绍二进制插件的开发方式,主要包括三大步:
1)建立插件工程,在插件工程里面配置好插件的入口。
2)实现插件内部逻辑,以及可能会需要编写插件的参数注入逻辑。
3)发布与使用插件。

下面就是 Demo 中的具体的实现。

建立 Transform

建立 Transform 类,并且注册到 gradle plugin 里面。

1、建立 buildSrc 子工程
首先在项目根目录下创建文件夹且命名为 buildSrc(命名必须为 buildSrc ),然后在 buildSrc 目录下创建文件且命名为 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 子目录。
在添加类之前,需要建立好包结构,所以在 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
     * 然后通过这个方法将打包好的结果回调给我们
     * 所以我们就可以在这个方法里面对回调给我们的 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 关键字在工程里面去引用插件的时候,apply 方法里面的逻辑将会被执行
        // 所以这里可以写注入插件的逻辑,比如往工程里面动态添加 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 文件中添加以下代码后,执行编译命令,即可看到输出内容 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"结尾。
                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、首先规划一下最终生成好汇总映射表类的内容,类似下面的代码:

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、开始编码实现生成汇总的映射表。
在 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、创建一个类
        // 2、创建一个构造方法(手动生成字节码的时候,构造方法需要由我们手动创建)
        // 3、创建一个 get() 方法
        //  1)创建一个 map
        //  2)向 map 中装入所有映射表的内容
        //  3)返回 map
    }

}

其中,我们需要在 get 方法中实现 1、2、3 步逻辑对应的字节码,并返回 byte[]。直接编写 java 字节码,其实门槛是比较高的,因为我们不仅需要去关注具体的逻辑的实现,还必须确保我们生成的字节码是符合虚拟机规范的。这里我们引入一个 ASM 工具,它把字节码相关的操作都封装成了一系列接口供我们调用(但是这个 ASM 工具提供的接口其实也很多很复杂)。

Android Studio -> Preferences -> 搜索 plugin -> 搜索 ASM Bytecode Viewer Sypport Kotlin,安装并重启。然后再 RouterMapping.java 类上右键选择 ASM Bytecode Viewer,帮助查看对应的字节码文件。
如下图:


查看字节码文件.png

选择 ASMified Tab 选项卡,可以看到工具帮助我们生成的编写字节码的 java 代码。


编写字节码的 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、创建一个类
        // 2、创建一个构造方法(手动生成字节码的时候,构造方法需要由我们手动创建)
        // 3、创建一个 get() 方法
        //  1)创建一个 map
        //  2)向 map 中装入所有映射表的内容
        //  3)返回 map

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

        // 创建类
        classWriter.visit(V1_8,
                ACC_PUBLIC | ACC_SUPER,
                CLASS_NAME,
                null,
                "java/lang/Object",
                null)

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

        // 创建构造方法
        methodVisitor = classWriter.visitMethod(
                ACC_PUBLIC,
                "<init>",
                "()V",
                null,
                null)
        methodVisitor.visitCode()   // 开启字节码的生成或访问,下面开始写字节码指令

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

        methodVisitor.visitMaxs(1, 1)
        methodVisitor.visitEnd()    // 关闭字节码的生成或访问

        // 创建 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")   // 创建一个 map
        methodVisitor.visitInsn(DUP)    // 将其入栈
        methodVisitor.visitMethodInsn(INVOKESPECIAL,
                "java/util/HashMap",
                "<init>",
                "()V",
                false)  // 入栈后调用 HashMap 的构造方法得到 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();
    }

}

在完成生成字节码的编码之后,接下来我们就要将生成的字节码写入 class 文件。所以回到 RouterMappingTransform.groovy 文件,编写以下代码:

@Override
void transform(TransformInvocation transformInvocation)
        throws TransformException, InterruptedException, IOException {
    super.transform(transformInvocation)
    ...
    // 将生成的字节码写入文件
    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 后再编译工程,输出以下日志:


字节码编码日志.png

然后再对应的目录下可以查看到 45.jar,解压该 jar 包,可以看到生成的 class 文件:


编译目录查看字节码编码结果.png

在编译生成的 apk 文件中也能看到生成的 RouterMapping 文件:


apk 中查看字节码编码结果.png

页面路由——打开页面

最后需要完成的主要功能就是设计接口,让应用在运行期间通过传入 url 在映射文件中查找对应类名,执行打开对应页面操作。
首先,肯定需要建立一个子工程,用于开发相关的代码。
然后,因为当应用运行时,我们需要把在编译期生成好的页面映射加载到内存中。所以需要提供相应的 init 方法。
接下来,就是开发路由接口,在应用运行时等待传入 url,然后再对 url 进行匹配。
最后,实现打开 Activity 跳转到相应的页面的逻辑。
当然,也可以扩展一些跳转 Fragment、跳转携带参数、路由拦截的功能。

1、创建 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、实现 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"));
}

总结

本文主要分享了组件化页面路由框架的核心实现思路,并在 Demo 中实现了路由功能的基本逻辑。在这个过程中,触及到了 APT、字节码插桩、Gradle 插件开发等各个知识点,实际上本次分享中对这些技术的介绍都还只是简单的应用。在实际项目过程中,还是推荐使用 ARouter 等成熟的框架。不过在实际工作中,通过对 APT、字节码插桩、Gradle 插件等技术的简单了解,能为一些问题或方案设计提供更多的思路。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,056评论 5 474
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,842评论 2 378
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 148,938评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,296评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,292评论 5 363
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,413评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,824评论 3 393
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,493评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,686评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,502评论 2 318
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,553评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,281评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,820评论 3 305
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,873评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,109评论 1 258
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,699评论 2 348
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,257评论 2 341

推荐阅读更多精彩内容