Android-Router路由框架

Android-Router路由框架实战

1. Router框架简介

对于一个大型项目来说,双向/循环依赖的场景非常常见,因此 Router 几乎是必备的(或是作为核心通信工具)。简单来说,Router 是基于 中介者模式 的组件间通信框架,其定义了组件的输入和输出标准,使得通信双方从「组件 - 组件」解耦为「组件 - Router」。

一个典型的双向依赖场景:假设一个项目包含 module_user、module_util 等 Module。(1)当用户登录时,module_user 需要依赖 module_util 使用某些工具;(2)而工具模块 module_util 中网络组件又需要使用 module_user 的用户信息鉴权。

  • 如果使用传统的解决方案,无外乎两个原则:减小模块颗粒度、抽象公共逻辑。例如抽象出一个独立的 module_userinfo 作为底层公共依赖。虽然同样可以解决问题,但当越来越多的 Module 出现互相依赖时,这种改造的复杂度可以说是灾难级的。
  • 使用 Router 框架,则只需要 module_user 定义用户信息的输出 Router.set("userInfo", userInfo);module_util 根据约定的方式读取 Router.get("userInfo") 即可。

当然该例只是为了表示 Router 的核心思路,实际的交互方式需要根据项目自定义。

目前应用较普遍的是阿里开源的 ARouter 框架。

1.1 ARouter的基本原理

ARouter 最开始是为了页面跳转之间解耦,本质上它提供了通过 String 的「路径 Path」对应到 Activity / Fragment 的路由表。其核心是通过 APT 在编译时自动检索添加了 @Route(path="/XXX/XXX") 注解的 Activity,并以对应的 Path 为 Key 生成 Map,然后在运行时根据 Map 存储的路由信息跳转。ARouter 要求 Path 必须包括至少两级,例如:/main/sub,将 main 称为主路径;sub 称为子路径。一个主路径可以包括多个子路径,例如:/main/sub, /main/sub2 等。

当项目很庞大或页面数量很多时就会生成一个巨大的路由表,为此 ARouter 做了 分段懒加载 的优化,即运行时不会立即将所有路由信息都加载进内存,而是在发起一个路由请求时,先读取缓存,如果缓存没有再懒加载目标主路径下的所有子路由信息。

1.2 ARouter编译时处理

ARouter 在编译时会通过 APT 生成两个表:

(1)对每个应用了 ARouter APT 的 Module,检索所有添加了 @Route 注解的页面组件(Activity / Fragment),并在 Module 下生成每个具体的 Path 对应页面的表 atlas

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// module_splash 目录下的 ADSActivity 会生成以下路由表:
public class EaseRouter_Group_splash implements IRouteRoot {
@Override
public void loadInto(Map<String, RouteMeta> atlas) {
atlas.put("/splash/ads", RouteMeta.build(RouteMeta.Type.ACTIVITY, ADSActivity.class, "/splash/ads", "splash"));
}
}

// module_user 目录下的 LoginActivity 和 RegisterActivity 会生成以下路由表:
public class EaseRouter_Group_user implements IRouteRoot {
@Override
public void loadInto(Map<String, RouteMeta> atlas) {
atlas.put("/user/login", RouteMeta.build(RouteMeta.Type.ACTIVITY, LoginActivity.class, "/user/login", "user"));
atlas.put("/user/register", RouteMeta.build(RouteMeta.Type.ACTIVITY, RegisterActivity.class, "/user/register", "user"));
}
}

(2)将所有路由信息生成主路径对应每个实际路由表 atlas 的分段表:

1
2
3
4
5
6
7
public class EaseRouter_Root_app implements IRouteRoot {
@Override
public void loadInto(Map<String, Class<? extends IRouteGroup>> routes) {
routes.put("slpash", EaseRouter_Group_splash.class);
routes.put("user", EaseRouter_Group_user.class);
}
}

实际上,不论是具体的路由信息表还是主路径索引的分段表,都实现了 IRouteRoot 接口。

1.3 ARouter运行时分段懒加载

ARouter 会在运行时收到路由请求后再懒加载主路径对应的分段表:

(1)初始化 ARouter.init(); 时,ARouter 会开启一个子线程扫描 apk 中的所有 dex 文件,遍历当前包名下所有实现了 IRouteRoot 接口的类,并存进一个 className 集合中。

(2)通过 ARouter.getInstance().build("/XXX/XXX").navigation(); 请求路由到指定 Path 对应的 Activity。

(3)尝试从缓存中读取对应的 Activity,如果命中缓存的路由信息,则直接定位并启动目标 Activity。

(4)如果未命中缓存,说明该路由对应所在的整个路由表都没有加载。假设目标路由为:/user/register,则 EaseRouter_Group_user 加载 atlas 时,会同时把当前主路径,也即 user 下的所有路由信息都加载,所以如果找不到 /user/register,就说明整个 user 都没有加载,则根据目标路由的主路径 user 加载所有 user 下的路由信息。

(5)将所有主路径为 user 的路由信息加载后,就能通过 routes.get("user"); 获取到 EaseRouter_Group_user,然后再通过 atlas.get("/user/register") 获取到 RegisterActivity

1.4 ARouter的局限性

ARouter 使得页面间的跳转不再依赖拿到对应的类,只需要 String 类型的路由信息即可,但也带来了以下问题:

  • 通过 String 类型发起的路由请求是单向的,且只能传递可序列化的数据进行通信。
  • 路由信息表需要统一管理,以避免出现重复、不规范的问题。

此外,ARouter 对跳转的解耦,指的是通信双方没有直接依赖,但所有子 Module 的依赖链仍需满足可达性(该规则是合理的,其他框架也不会解决这个问题)。

Gradle 在构建 App 时,如果一个 Module 的依赖链无法到达构建目标 Module,则打包时会忽略该 Module,以减小 APK 包大小和简化资源,但该规则是合理的。


2. 自定义Router的思路

在页面跳转的路由中 ARouter 主要作为一个单向驱动框架:

1
ComponentA --(Router)--> ComponentB

通过定义不同的参数可以细分请求类型,但这会使得路由表变得非常复杂。为了使得 Router 的效果兼具解耦和便捷性,可以梳理一下期望 Router 实现的能力:

  • 组件间保持解耦,不具有依赖、不关心实现;
  • 组件不一定非要是 Android 原生四大组件;
  • 组件的管理和生命周期尽可能自动化,减少学习成本;
  • 组件间的通信尽可能灵活;
  • 组件的访问需要考虑跨线程和跨进程的情况;

这些能力最终可以总结为 3 点:解耦和隔离、灵活访问、自动化管理。

2.1 解耦和隔离

面向接口编程 恰好满足这两个条件,因此可以将 Router 设计为 接口间的通信工具

(1)组件的定义

定义一个顶层组件可以使用接口基类或抽象基类(父类)两种方式,使用接口基类则可以避免无法多继承的问题:

1
2
3
4
5
/**
* 定义组件的接口基类,所有实现了这个接口或其子类接口的实现类都可以作为组件。
*/
public interface BaseServiceInterface {
}

(2)组件的实现

每个模块都可以根据其功能定义自己的子接口,以 Setting 模块为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 定义一个 Setting 模块的 Router 组件接口,需要继承自 IBaseServiceInterface。
*/
public interface SettingServiceInterface extends BaseServiceInterface {
void setA();
void setB(String param1, Object param2);
}

/**
* 实现对应的组件接口。
*/
public class SettingServiceImpl implements SettingServiceInterface {
@Override
public void callA() { ... }
@Override
public void callB(String param1, Object param2) { ... }
}

2.2 灵活访问

用一个公共的 module_base 存放所有 Module 的 Service 或 Event 等的接口,而对应的实现类则闭环在各个 Module 内,Module 间借助 module_base 内定义的公开的接口通信。

例如上文的 SettingServiceInterface 则存放在 module_base 中,而 SettingServiceImpl 则位于 module_setting 中,当其他模块想要访问 Setting 模块时,即可通过接口访问:

1
2
3
4
// 此为示意代码,还需考虑如何建立接口与实现类的映射关系。
SettingServiceInterface settingService = MyRouter.getSettingService();
settingService.callA();
settingService.callB("param1", new Object());

这样最大的好处就是:不再需要预先定义很多不同的路由表参数,直接通过接口调用近似等同于直接访问其他类,但实际上类的实现是隐藏且隔离的。

2.3 自动化管理

基于面向接口的设计模式下,可以通过 Map 记录组件接口与实现类的映射关系;为了使得所有组件可以自由访问,则需要通过 module_base 统一提供组件的接口。

2.3.1 自动映射接口与实现类

找出接口与实现类映射关系的方式有运行时和编译时两种,考虑到效率和对性能的影响,选择编译时的方式。

(1)运行时映射:

  • 通过当前线程的 ClassLoader#getResource(...) 扫描接口所在工作目录;
  • 通过 URL 截取工作目录中 Java 资源文件的相对目录,转换为包名;
  • 过滤所有 .class 文件,分别根据相对路径转换为全限定类名;
  • 过滤所有 .jar 文件,通过 JarFile#entries(...) 扫描 Jar 包内所有文件并通过 Enumeration 遍历所有类的全限定类名;
  • 根据收集的全限定类名反射实例化,并判断是否实现指定接口;

(2)编译时映射(手动注册):

  • Router 对外提供注册接口,用于映射组件的接口和实现类;
  • 在每个组件中调用 Router 手动注册映射关系,并在合适的时机手动调用每个组件的注册;

(3)编译时映射(自动注册):

  • Router 对外提供注册接口,用于映射组件的接口和实现类;
  • APT 可以在编译阶段获取项目中使用了特定注解的类、方法、变量等元素,因此可以通过自定义注解 + APT 自动生成注册代码,并在合适的时机手动调用注册;

2.3.2 自动集成组件的接口

虽然使用 module_base 可以达到灵活访问的目的,但会导致几个痒点:

  • 开发人员需要理解并依靠自觉来遵循框架的规则,难以开箱即用、也难以约束代码规范。
  • 代码迁移时需要分别从 module_base 中迁移接口、再从业务 Module 中迁移实现类。

因此还需要利用一些工具自动获取组件的接口并拷贝一份到 module_base 下,保持拷贝类的类名、包名等信息一致,这样每个 Module 的逻辑都能完全独立在本 Module 内。

2.3.3 自动管理组件的生命周期

因为 Router 需要考虑每个组件的接口与对应实现类通过何种方式映射、以及在什么时机注册。


3. 自动拷贝接口的错误尝试

上文 Router 的效果中,如何通过接口通信、如何确定接口与实现类的映射关系、在什么时机初始化实现类等都可以根据业务层自由设计,而难点之一就是:如何将每个组件的接口自动拷贝至 module_base 公开给其他组件

需要注意的是,因为仅拷贝组件的接口,所以需要识别出哪些类具有特定的注解(如 @Router)并且继承自接口基类(如 BaseServiceInterface)。

3.1 踩坑1:完全依赖APT

因为 APT 本身处理的就是具有指定注解的类,而拷贝组件的接口又恰好需要判断是否具有指定的接口,需求和能力不谋而合,因此首先想到的方案就是能否在 APT 生成映射关系的同时将已经过滤出来的组件接口拷贝至 module_base。

3.1.1 拷贝注解类的源文件

(1)通过 APT 获取源文件所在的路径

1
2
3
4
private void generate(Element classElement) {
String sourceFileName = ((ClassSymbol) classElement).sourcefile.getName();
final File copiedSourceFile = new File(sourceFileName);
}

(2)获取目标拷贝目录

在本例中希望将类拷贝至 module_base,并且希望开发人员只会改动源文件而不是拷贝的文件,因此拷贝的文件就应该位于 Build 目录下,Clean 时自动删除、Rebuild 时重新生成。Gradle 中可以通过 project.buildDir.absolutePath 获取 Android-Library 的 Build 目录,但 module_processor 中的 Java 代码如何读取到呢?

  • 通过 BuildConfig,不可行

在本例场景下,module_base 作为公共 Module 一定会包括 Android 相关特性,所以 module_base 一定只能是 Android-Livrary。

而 module_processor 作为 AnnotationProcessor 是一个纯 Java-Library,不能依赖 module_base,就无法访问 module_base 的 BuildConfig。

  • 通过环境变量,可行但不合适

既然无法通过依赖的方式获取,就考虑具有全局性的方式,例如环境变量。利用环境变量设置共享信息在 Android 开发中非常常见,例如 Keystore 的密钥、存储位置;多渠道打包时的 AppKey、组件化构建时的构建模式等等,尤其在有云端构建机的时候,环境变量使用的更为频繁。环境变量中的数据通常是一些固定的常量,使用环境变量设置全局信息的优点很明显:

  • 数据获取与代码无关,只需要设置在每个设备的本地即可,即便代码泄漏也不会泄漏环境变量中的敏感数据。
  • 数据修改与代码无关,只需要修改环境变量,则构建时会自动获取到新的环境变量,而不需要修改代码。

但在本例中 module_base 的路径并不需要上述特性,且项目在不同设备中的路径都是不一样的,因此通过环境变量指定 module_base 的路径是可行、但并不合适的。

  • 通过 SystemProperty,可行

SystemProperty 的特性有:

  • 与 JVM 运行时有关,每一次启动 JVM 都会重新初始化 SystemProperty。
  • 更新时效性高,在同一个 JVM 实例中,只要设置了 SystemProperty,随后就能立即获取到。与之相比,环境变量在大多数情况下需要重启 JVM 才能刷新。
  • 与环境变量具有同样的全局可见性。

某些场景下,由于 SystemProperty 与 JVM 实例相关所以无法保存一些永久化的数据,会被视作 SystemProperty 的一个缺点;但本例中 module_base 的路径在任何一次构建时都有可能发生改变,因此恰好需要这种运行时机制。

1
2
3
4
5
// 在 module_base 的 Gradle 中存入 Build 路径:
System.setProperty("build_dir_path_base", "${project.projectDir.absolutePath}")

// 在 Processor 的 Java 代码中获取:
String buildDirPath = System.getProperty("build_dir_path_base");

(3)从源文件拷贝至目标路径

通过上述方式 Processor 已经可以获取到 module_base 的 Build 目录,接下来就是将源文件拷贝至目标目录下。拷贝时还需要注意创建与源文件一致的的 Package 路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// 省略具体的源文件类型判断,假设此方法输入的均为已被过滤后需要拷贝的源文件。

private void generate(Element classElement) {
// module_base 的 Build 目录路径:
String buildDirPath = System.getProperty("build_dir_path_base");
// 统一将源文件拷贝至 module_base/build/generated/source/router/ 目录:
buildDirPath += File.separator + "generated" + File.separator + "source"
+ File.separator + "router";
// 源文件的包名和类名:
final String packageName = classElement.getEnclosingElement().toString();
final String className = classElement.getSimpleName().toString();

// 源文件的文件名:
final String srcFileName = ((ClassSymbol) classElement).sourcefile.getName();
// 检验源文件合法性,这里有个细节:
// 正常情况下 srcFileName 是源文件的绝对路径,例如:
// "/DemoProject/priv/demo/DemoClass.java"
// 但将 DemoClass 拷贝到 module_base 后,拷贝文件中也会包含 @Router 注解,
// 如果 module_base 依赖 module_processor 会导致拷贝的文件也触发 Processor,似乎是无限循环,
// 但实际上拷贝至 module_base 中的 DemoClass 如果获取 srcFileName 会返回裸文件名,例如:
// "DemoClass.java"
// 因此拷贝文件的 srcFileName 生成的 File 对象调用 exists() 返回是 false,
// 所以也变相避免了重复生成的问题,但不确定这是否符合期望,因此需要考虑使用更稳定的过滤方式。
final File srcFile = new File(srcFileName);
if (!srcFile.exists()) {
return;
}

// 将包名中的 . 替换为当前文件系统中的路径分隔符:
final String packageDir = packageName.replace('.', File.separatorChar);
// 将 module_base 的 Build 目录与源文件的包名、类名拼接成最终目标文件的绝对路径:
final String targetClassPath = buildDirPath + File.separator
+ packageDir + File.separator + className + ".java";
// 如果目录不存在则创建目录:
final File targetEventFile = new File(targetClassPath);
if (!targetEventFile.exists()) {
targetEventFile.mkdirs();
}
// 开始复制文件
try {
// 借助 Java 8 的 NIO 工具完成复制:
Files.copy(srcEventFile.toPath(), targetEventFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
throw new RuntimeException(e);
}
}

假设有一个 DemoClass 类位于 module_util/priv/demo/DemoClass.java 并添加了 @Router 注解,Rebuild 项目发现 DemoClass 确实成功复制到了 module_base/build/generated/source/router/priv/demo/DemoClass.java

3.1.2 导入拷贝的类

通过上文的方式拷贝源文件后却发现 module_base 中拷贝的类无法被任何一个类导入,是因为默认情况下,Library 在 Gradle 中只有一个源文件目录:

对比观察这个 Build 生成的目录结构和正常的目录结构:

Build 下的目录无法识别为包路径

1
2
3
4
5
6
7
8
9
// 如果没有显式指定,则默认配置即为:
sourceSets {
main {
jni.srcDirs = []
jniLibs.srcDir ["libs"]
// Java 源文件默认只包含 module/src/main/java/ 目录下的
java.srcDirs = ['src/main/java/']
}
}

因此只需要在 module_base 的 Gradle 中将生成的类所在目录也添加到 Java 源文件目录中即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 追加源文件目录:
android.sourceSets.main.java.srcDirs += ['build/generated/source/router/']


// 可以考虑封装至 base.gradle 中,并被其他所有 Module 的 Gradle 应用:
// apply from: "${project.rootDir}/xxx/base.gradle"
project.ext.asBaseModule = {
// 添加 Java 源文件目录:
android.sourceSets.main.java.srcDirs += ['build/generated/source/router/']
// 将作为 BaseModule 的 Build 目录绝对路径存入 SystemProperty:
def buildDirPath = "${project.buildDir.absolutePath}"
if (buildDirPath != null && !buildDirPath.trim().isEmpty()) {
System.setProperty("build_dir_path_base", buildDirPath)
}
}

这样如果想让某个 Module 作为 BaseModule,只需要在其 Gradle 中调用这个方法即可:

1
2
apply from: "${project.rootDir}/xxx/base.gradle"
project.ext.asBaseModule()

3.1.3 Javac编译任务异常

(1)Javac 编译不通过

通过上述方法编译后已经能成功将所有添加了 @Router 注解的源文件拷贝至 module_base 中,但假如:

  • 在 module_util 中定义了一个 priv.demo.DemoClass,编译后在 module_base 中生成拷贝的 DemoClass
  • 在 module_app 中导入并使用拷贝的 DemoClass,Clean + Rebuild

Rebuild 时 module_app 的 Javac 任务就会抛出错误:

1
error: package priv.demo does not exist

原因其实也很简单,由于 Clean 时删除了 module_base 的 Build 目录,此时 DemoClass 还未拷贝至 module_base 中,Processer 作为 Javac 的一个工具,执行在 Javac 的语法通过性检测之后,自然会找不到对应 Package 和类。但由于 Gradle 并行执行任务,实际上在 module_app 的 Javac 抛出异常后,其他 Module 构建还在继续运行并成功拷贝 DemoClass,因此如果在编译失败后 不 Clean 直接再次构建,又能成功完成

(2)尝试延迟 Javac 任务

能不能让 module_base 的 Javac 任务运行在所有其他 Module 的 Javac 之前?这样是不是就能在其他 Module Javac 之前将所有需要拷贝的源文件准备好?修改 base.gradle

dependsOn 类似的还有 mustRunAfter,相关区别可以另行查阅。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 判断某个 Task 是否是 Javac 任务
def isJavacTask(Task task) {
return (task.name.startsWith("compile") && task.name.endsWith("JavaWithJavac"))
}

// 将 module_base 中所有 Javac 相关的 Task 保存在一个 Set 中:
def baseModuleJavacTaskSet = new HashSet<Task>()
project(':module_base').tasks.all { task ->
if (isJavacTask(task)) {
baseModuleJavacTaskSet.add(task)
}
}

// 遍历构建时的所有 Task,
// 除了 module_base 以外,其他 Module 的 Javac 都在 module_base 的 Javac 之后执行:
tasks.whenTaskAdded { task ->
if ((task.project == project(':module_base'))) {
return
}
if (isJavacTask(task)) {
for (Task eachBaseModuleJavacTask : baseModuleJavacTaskSet) {
task.dependsOn eachBaseModuleJavacTask
}
}
}

project.ext.asBaseModule = {
// 添加 Java 源文件目录:
android.sourceSets.main.java.srcDirs += ['build/generated/source/router/']
// 将作为 BaseModule 的 Build 目录绝对路径存入 SystemProperty:
def buildDirPath = "${project.buildDir.absolutePath}"
if (buildDirPath != null && !buildDirPath.trim().isEmpty()) {
System.setProperty("build_dir_path_base", buildDirPath)
}
}

(3)延迟 Javac 会导致重复生成 BuildConfig

将其他 Module 的 Javac 都依赖于 module_base 的 Javac 后,重新 Sync、Rebuild,这次 Javac 抛出了另一个异常:

1
2
3
4
5
6
7
8
9
10
/DemoProject/module_base/build/generated/source/router/buildConfig/debug/priv/demo/BuildConfig.java:6:error: duplicate class: priv.demo.BuildConfig
public final class BuildConfig {
^
1 error

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':module_base:compileDebugJavaWithJavac'.
> Compilation failed; see the compiler error output for details.

异常提示 module_base 下存在重复(包名与类名均相同)的类 BuildConfig。这就奇怪了,上文中所有的改动都没有操作 BuildConfig,为什么会导致 BuildConfig 重复呢?根据日志点进对应的 BuildConfig 类中查看 AndroidStudio 的提示:

1
Duplicate class found in the file '/DemoProject/module_base/build/generated/source/buildConfig/release/priv/demo/BuildConfig.java'

错误提示同时生成了 Debug 的 BuildConfig 和 Release 的 BuildConfig,由于包名和类名都相同且在同一个 Module 下所以抛出了该异常。这更让我感到困惑,明明是通过 assembleDebug 任务构建的,为什么会生成 Release 的 BuildConfig 呢?

其实是因为 compileDebugJavaWithJavaccompileReleaseJavaWithJavac 都匹配了 Javac 的任务名,导致 baseModuleJavacTaskSet 中同时保存了两个任务。因此只需要修改判断 Javac 以及 depensOn 的逻辑,区分 Debug 和 Release 即可,将 base.gradle 整理和优化后,大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
// base.gradle

// 分别判断是否 Debug / Release 模式的 Javac 任务:
def isDebugJavacTask(Task task) {
return (isJavacTask(task) && task.name.toLowerCase().contains("debug"))
}
def isReleaseJavacTask(Task task) {
return (isJavacTask(task) && task.name.toLowerCase().contains("release"))
}
def isJavacTask(Task task) {
return (task.name.startsWith("compile") && task.name.endsWith("JavaWithJavac"))
}

// 用于组件化时判断当前壳 Module 是否为正在构建的壳工程的 Module:
def isBuildingCurrentShell() {
return gradle.startParameter.currentDir.toString().contains(project.name)
}

// 记录每一个 Module 的绝对路径,以备后续使用
task setModulePath(type: Exec) {
def modulePathKey = "module_path_${project.name}"
def modulePathValue = "${project.projectDir.absolutePath}"
System.setProperty(modulePathKey, modulePathValue)
}

// module_base 调用此方法即可保存其 Build 目录、以及将该目录添加到 Java 源文件目录中:
project.ext.asBaseModule = {
android.sourceSets.main.java.srcDirs += ['build/generated/source/']
def baseModulePath = "${project.buildDir.absolutePath}"
if (baseModulePath != null && !baseModulePath.trim().isEmpty()) {
def baseModuleGeneratedPath = "${baseModulePath}${File.separator}generated"
System.setProperty("base_generated_path", baseModuleGeneratedPath)
}
}

// module_base 的 Projcet 对象:
def baseModuleProject = project(':base_module')

// 判断一个 Task 是否是 module_base 中的 Task:
def isTaskFromBaseModule(Task task) {
return (task.project == project(':base_module'))
}

// 根据 Debug 和 Release 模式分别保存 module_base 的 Javac 任务:
def baseModuleDebugJavacTaskSet = new HashSet<Task>()
def baseModuleReleaseJavacTaskSet = new HashSet<Task>()
baseModuleProject.tasks.all { task ->
if (isDebugJavacTask(task)) {
baseModuleDebugJavacTaskSet.add(task)
} else if (isReleaseJavacTask(task)) {
baseModuleReleaseJavacTaskSet.add(task)
}
}

// 根据 Debug 和 Release 模式分别延迟其他 Module 的 Javac 任务:
tasks.whenTaskAdded { task ->
if (isTaskFromBaseModule(task)) {
return
}
if (isDebugJavacTask(task)) {
for (Task eachBaseModuleDebugJavacTask : baseModuleDebugJavacTaskSet) {
task.dependsOn eachBaseModuleDebugJavacTask
}
} else if (isReleaseJavacTask(task)) {
for (Task eachBaseModuleReleaseJavacTask : baseModuleReleaseJavacTaskSet) {
task.dependsOn eachBaseModuleReleaseJavacTask
}
}

}

再次重新构建,不再生成重复的 BuildConfig 类了,但是依然无法解决 Clean 后下次编译 Javac 语法检测不通过的问题。

3.1.4 完全依赖APT失败总结

经过实践,想要完全依赖 APT 似乎难以实现理想的效果,主要是因为理解错了 Processor 和 Javac 的关系:

  • Processor 作为 Javac 的一个编译工具,执行在 Javac 检查语法通过性之后。
  • 每个 Module 都会独立 Javac,并且需要自行声明 annotationProcessor 来触发 Processor 处理本 Module 内的注解。
  • 因此提前执行 module_base 的 Javac 只会处理 module_base 的注解,其他 Module 中的注解仍然需要在它们 Javac 之后才处理,因此无法规避 Javac 的语法通过性检测。

3.2 踩坑2:Plugin重新编译

完全依赖 APT 无法完成的主要原因在于 Processor 发生在 Javac 之后,因此就需要考虑一个能更早生效的机制,而 Plugin 可以执行在编译开始前,最早可以执行在 Gradle 同步时期,因此可以满足编译时序的要求。

该思路分为三个步骤:

  • 遍历所有 Java 文件;
  • 编译 Java 文件,判断接口、注解等是否满足条件;
  • 将满足条件的 Java 文件拷贝至 module_base 下;

以拷贝 Service 的接口为例,Plugin 设计如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class RouterSharePlugin implements Plugin<Project> {

private Set<File> srcJavaFileSet;
private Set<File> serviceInterfaceJavaFileSet;

@Override
void apply(Project project) {
}

/**
* 遍历所有 Java 文件。
*/
private void loadJavaFile(Project project) {
}

/**
* 将 Java 文件编译为 Class 文件。
*/
private void compileJavaFile() {
}

/**
* 读取编译后的 Class,判断是否满足条件。
*/
private boolean filterClass() {
}

/**
* 将满足条件的 Class 对应的 Java 文件拷贝至 module_base。
*/
private void copyJavaFile() {
}
}

3.2.1 收集Java类

收集 Java 类比较简单,就是文件遍历和后缀判断,不过有一个小细节:并不是 Project 下的所有 Java 文件都需要收集和判断的,因为可能在 Build 目录、也可能是多渠道打包时其他构建目录下的文件,因此需要通过读取当前构建配置下的 Jara 源文件目录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
private Set<File> srcJavaFileSet;

private void loadJavaFile(Project project) {
TestedExtension extension = getAndroidConfigFromProject(project)
extension.sourceSets.main.java.srcDirs.each { File eachDir ->
iteratorFiles(eachDir {
if (isJavaFile(it)) {
srcJavaFileSet.add(it)
}
})
}
}

/**
* 获取 Project 的 android{} 配置块。
*/
static TestedExtension getAndroidConfigFromProject(Project project) {
// 尝试作为 Application 获取:
AppExtension applicationModuleAndroidConfig = project.getExtensions().findByType(AppExtension.class)
if (applicationModuleAndroidConfig != null) {
return applicationModuleAndroidConfig
}
// 尝试作为 Library 获取:
LibraryExtension libraryModuleAndroidConfig = project.getExtensions().findByType(LibraryExtension.class)
if (libraryModuleAndroidConfig != null) {
return libraryModuleAndroidConfig
}
return null;
}

/**
* 递归遍历目录下的所有文件。
*/
static void iteratorFiles(File srcFile, @ClosureParams(FirstParam) Closure closure) {
if (srcFile.isFile()) {
closure(srcFile)
return
}
if (srcFile.isDirectory()) {
srcFile.listFiles().each {
iteratorFiles(it, closure)
}
}
}

static boolean isJavaFile(File file) {
return (file != null
&& file.name != null
&& file.name.length >= 5
&& file.name.substring(file.name.length - 5).toLowerCase() == ".java")
}

3.2.2 Plugin编译Java类

假设项目中有以下两个接口:

(1)DemoServiceInterface

1
2
3
4
5
6
7
8
package priv.demo;

import androidx.lifecycle.Lifecycle;
import priv.base.BaseServiceInterface;

public interface DemoServiceInterface extends BaseServiceInterface {
void onEventReceived(Lifecycle.Event event);
}

(2)BlankInterface

1
2
3
4
5
6
7
package priv.demo;

/**
* 一个空的接口。
*/
public interface BlankInterface {
}

前一步收集了所有工作路径下的 Java 文件,假设上例两个接口都符合条件(仅为了问题说明,实际项目中设置的条件通常是要求接口继承自 BaseServiceInterface 且实现类添加了 @Route 注解才符合),都需要拷贝至 module_base 中,接下来将 Java 文件编译为 Class,并判断其是否为需要被拷贝的文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
private Set<File> srcJavaFileSet;
private Set<File> serviceInterfaceJavaFileSet;

private void compileJavaFile() {
srcJavaFileSet.each { eachJavaFile ->
// 调用 Compiler 编译本地 Java 文件:
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler()
// 仅当返回值为 0 时表示编译通过。
int compileResulte = compiler.run(null, null, null, eachJavaFile.getAbsolutePath())

// ✅ 如果是 BlankInterface.java 则可以编译通过;
// ❌ 如果是 DemoServiceInterface 则 compileResulte 返回值为 1 且抛出异常;

// 编译成 Class 后,再通过 ClassLoader 加载进内存:
URL[] javaFileUrlArray = new URL[] { eachJavaFile.toURI().toURL() }
ClassLoader classLoader = URLClassLoader.newInstance(javaFileUrlArray)
Class<?> eachJavaClass = Class.forName(getClassPathFromJavaFile(eachJavaFile), true, classLoader)

// 判断加载出来的 Class 是否符合条件,如果符合则记录起来,用于后续拷贝至 module_base:
if (isTargetClass(eachJavaClass)) {
serviceInterfaceJavaFileSet.add(eachJavaFile)
}
}
}

private String getClassPathFromJavaFile(File javaFile) {
......
}

private boolean isTargetClass(Class<?> compiledClass) {
......
}

然而这段代码实际上只有 BlankInterface.java 才能被编译通过,编译 BaseServiceInterface.java 时会抛出以下异常:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/Project/module_demo/src/main/java/priv/demo/DemoServiceInterface.java:3: error: package androidx.lifecycle does not exist
import androidx.lifecycle.Lifecycle;
^

/Project/module_demo/src/main/java/priv/demo/DemoServiceInterface.java:4: error: package priv.base does not exist
import priv.base.BaseServiceInterface;
^

/Project/module_demo/src/main/java/priv/demo/DemoServiceInterface.java:6: error: cannot find symbol
public interface DemoServiceInterface extends BaseServiceInterface {
^
symbol: class BaseServiceInterface

/Project/module_demo/src/main/java/priv/demo/DemoServiceInterface.java:7: error: package Lifecycle does not exist
void onEventReceived(Lifecycle.Event event);
^
4 errors

FAILURE: Build failed with an exception.

这是因为 DemoServiceInterface.java 依赖的类并没有被 Compile,因此其编译时在工作目录下读取不到 import 的内容。

3.2.3 Plugin重编译方案失败总结

  • 由于 Router 是一个通用框架,因此需要被拷贝的类可能会具有各种各样的依赖,针对每个依赖都递归编译是不现实的;
  • 该方案从一开始就不合理,因为其会导致大量类被编译多次,这对一个大型项目的编译速度堪称毁灭性打击。

4. 实现自定义Router

通过上文分析可以明确自定义 Router 需要实现以下目标:

  • 通过 APT 自动收集符合条件的接口及实现类映射。
  • 通过 Transform 自动注册映射。

4.1 APT自动收集接口与实现类的映射

4.2 Transform自动注册映射


参考文献

ARouter:

Gradle:

其他: