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 | // module_splash 目录下的 ADSActivity 会生成以下路由表: |
(2)将所有路由信息生成主路径对应每个实际路由表 atlas
的分段表:
1 | public class EaseRouter_Root_app implements IRouteRoot { |
实际上,不论是具体的路由信息表还是主路径索引的分段表,都实现了 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)组件的实现
每个模块都可以根据其功能定义自己的子接口,以 Setting 模块为例:
1 | /** |
2.2 灵活访问
用一个公共的 module_base 存放所有 Module 的 Service 或 Event 等的接口,而对应的实现类则闭环在各个 Module 内,Module 间借助 module_base 内定义的公开的接口通信。
例如上文的 SettingServiceInterface
则存放在 module_base 中,而 SettingServiceImpl
则位于 module_setting 中,当其他模块想要访问 Setting 模块时,即可通过接口访问:
1 | // 此为示意代码,还需考虑如何建立接口与实现类的映射关系。 |
这样最大的好处就是:不再需要预先定义很多不同的路由表参数,直接通过接口调用近似等同于直接访问其他类,但实际上类的实现是隐藏且隔离的。
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 | private void generate(Element classElement) { |
(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 | // 在 module_base 的 Gradle 中存入 Build 路径: |
(3)从源文件拷贝至目标路径
通过上述方式 Processor 已经可以获取到 module_base 的 Build 目录,接下来就是将源文件拷贝至目标目录下。拷贝时还需要注意创建与源文件一致的的 Package 路径:
1 | // 省略具体的源文件类型判断,假设此方法输入的均为已被过滤后需要拷贝的源文件。 |
假设有一个 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 生成的目录结构和正常的目录结构:
1 | // 如果没有显式指定,则默认配置即为: |
因此只需要在 module_base 的 Gradle 中将生成的类所在目录也添加到 Java 源文件目录中即可:
1 | // 追加源文件目录: |
这样如果想让某个 Module 作为 BaseModule,只需要在其 Gradle 中调用这个方法即可:
1 | apply from: "${project.rootDir}/xxx/base.gradle" |
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 | // 判断某个 Task 是否是 Javac 任务 |
(3)延迟 Javac 会导致重复生成 BuildConfig
将其他 Module 的 Javac 都依赖于 module_base 的 Javac 后,重新 Sync、Rebuild,这次 Javac 抛出了另一个异常:
1 | /DemoProject/module_base/build/generated/source/router/buildConfig/debug/priv/demo/BuildConfig.java:6:error: duplicate class: priv.demo.BuildConfig |
异常提示 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 呢?
其实是因为 compileDebugJavaWithJavac
和 compileReleaseJavaWithJavac
都匹配了 Javac 的任务名,导致 baseModuleJavacTaskSet
中同时保存了两个任务。因此只需要修改判断 Javac 以及 depensOn
的逻辑,区分 Debug 和 Release 即可,将 base.gradle
整理和优化后,大致如下:
1 | // base.gradle |
再次重新构建,不再生成重复的 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 | class RouterSharePlugin implements Plugin<Project> { |
3.2.1 收集Java类
收集 Java 类比较简单,就是文件遍历和后缀判断,不过有一个小细节:并不是 Project 下的所有 Java 文件都需要收集和判断的,因为可能在 Build 目录、也可能是多渠道打包时其他构建目录下的文件,因此需要通过读取当前构建配置下的 Jara 源文件目录:
1 | private Set<File> srcJavaFileSet; |
3.2.2 Plugin编译Java类
假设项目中有以下两个接口:
(1)DemoServiceInterface
:
1 | package priv.demo; |
(2)BlankInterface
:
1 | package priv.demo; |
前一步收集了所有工作路径下的 Java 文件,假设上例两个接口都符合条件(仅为了问题说明,实际项目中设置的条件通常是要求接口继承自 BaseServiceInterface
且实现类添加了 @Route
注解才符合),都需要拷贝至 module_base 中,接下来将 Java 文件编译为 Class,并判断其是否为需要被拷贝的文件:
1 | private Set<File> srcJavaFileSet; |
然而这段代码实际上只有 BlankInterface.java
才能被编译通过,编译 BaseServiceInterface.java
时会抛出以下异常:
1 | /Project/module_demo/src/main/java/priv/demo/DemoServiceInterface.java:3: error: package androidx.lifecycle does not exist |
这是因为 DemoServiceInterface.java
依赖的类并没有被 Compile,因此其编译时在工作目录下读取不到 import
的内容。
3.2.3 Plugin重编译方案失败总结
- 由于 Router 是一个通用框架,因此需要被拷贝的类可能会具有各种各样的依赖,针对每个依赖都递归编译是不现实的;
- 该方案从一开始就不合理,因为其会导致大量类被编译多次,这对一个大型项目的编译速度堪称毁灭性打击。
4. 实现自定义Router
通过上文分析可以明确自定义 Router 需要实现以下目标:
- 通过 APT 自动收集符合条件的接口及实现类映射。
- 通过 Transform 自动注册映射。
4.1 APT自动收集接口与实现类的映射
4.2 Transform自动注册映射
参考文献
ARouter:
Gradle:
- ADDING PARAMETERS TO A GRADLE PLUGIN
- Gradle plugin with reflections
- Maven Publish Plugin
- How to build some java files to one jar using gradle java-plugin?
其他: