货拉拉开源模块化路由框架(TheRouter)

TheRouter 是一个 Kotlin 编写,用于 Android 模块化开发的一整套解决方案框架。
Github 项目地址与使用文档详见 https://github.com/HuolalaTech/hll-wp-therouter-android。
TheRouter 核心功能具备如下能力:

  • 页面导航跳转能力(Navigator)
  • 跨模块依赖注入能力(ServiceProvider)
  • 单模块自动初始化能力(FlowTaskExecutor)
  • 动态化能力(ActionManager)
  • 模块AAR/源码依赖一键切换脚本
一、为什么要使用 TheRouter
路由是现如今移动端开发中必不可少的功能,尤其是企业级APP,可以用于将Intent页面跳转的强依赖关系解耦,同时减少跨团队开发的互相依赖问题。
对于大型 APP 开发,基本都会选用模块化(或组件化)方式开发,对于模块间解耦要求更高。 TheRouter 是一整套完全面向模块化开发的解决方案,不仅能支持常规的模块依赖解耦、页面跳转,同时提供了模块化过程中常见问题的解决办法。例如:完美解决了模块化开发后由于组件内无法获取 Application 生命周期与业务流程,造成每次初始化与关联依赖调用都需要跨模块修改代码的问题。
1.1 TheRouter 四大能力 Navigator:
  • 支持 ActivityFragment
  • 支持Path与页面多对一关系或一对一关系,可用于解决多端path统一问题
  • 页面Path支持正则表达式声明
  • 支持 json 格式路由表导出
  • 支持动态下发 json 路由表,降级任意页面为H5
  • 支持任意object跨模块传递(无需序列化,且能保证对象类型)
  • 支持页面跳转拦截处理
  • 支持自定义页面参数解析方式(例如将json解析为对象)
  • 支持使用路由跳转到第三方 SDK 中的Activity(Fragment)
ServiceProvider:
  • 支持跨模块依赖注入
  • 支持自定义注入项的创建规则,依赖注入可自定义参数
  • 支持自定义服务拦截,单模块mock调试
  • 支持注入对象缓存,多次注入 只会new一次对象
FlowTaskExecutor:
  • 支持单模块独立初始化
  • 支持懒加载初始化
  • 独立初始化允许多任务依赖(参考Gradle Task)
  • 支持编译期循环引用检测
  • 支持自定义业务初始化时机,可以用于解决隐私合规问题
ActionManager:
  • 支持全局回调配置
  • 支持优先级响应与中断响应
  • 支持记录调用路径,解决调试期观察者模式无法追踪Observable的问题
注: FlowTaskExecutorActionManager 后续会作为可选能力,提供可插拔单独使用的选项(预计10月份提供)。
二、路由方案
目前现有的路由基本上集中于两种能力的实现:页面跳转、跨模块调用,核心技术方案大体上如图:
货拉拉开源模块化路由框架(TheRouter)
文章图片

  1. 开发阶段,对要使用路由的落地页或被调用方法添加注解标识。
  2. 编译期解析注解,生成一系列中间代码,待调用。
  3. 应用启动后调用中间代码完成路由的准备动作。大部分路由会额外通过 Gradle Transform,在编译期做一次聚合,以提升运行时准备路由表的效率。
  4. 发起路由跳转时,本质上就是一次路由表遍历,通过uri获取到对应的落地页或方法对象,进行调用。
TheRouter 的页面跳转、跨模块调用也是如此,但是在设计上会有一些细节处理。
货拉拉开源模块化路由框架(TheRouter)
文章图片

TheRouter 会在编译期根据注解生成 RouteMap__开头的类,这些类中记录了当前模块的所有路由信息,也就是当前模块的路由表。
在最顶层的app模块中,通过Gradle插件,将所有aar、源码中的RouteMap__开头的类统一集中到TheRouterServiceProvideInjecter类中。
后续应用启动后,初始化路由时只需要执行TheRouterServiceProvideInjecter类的方法,就能没有任何反射的加载到全部的路由表了。
加载以后的路由表会被保存到一个支持正则匹配的 Map 中,这也是TheRouter允许多个path对应同一个落地页的原因。每当发生页面跳转时,通过跳转时的path,去Map中获取到对应的落地页信息,再正常调用startActivity()即可。
三、使用 TheRouter 页面跳转
3.1 声明路由项 如果一个页面(支持 Activity、Fragment)允许被路由打开,则需要使用注解 @Route 声明路由项,每个页面允许声明多个路由项,也就是一对多的能力,极大降低多端路由统一时的业务影响面。
参数释义
  • path: 路由path 【必传】。
    建议是一个url。path内支持使用正则表达式(为了匹配效率,正则必须包含反双斜杠\),允许多个path对应同一个Activity(Fragment)。
  • action: 自定义事件【可选】。
    一般用来打开目标页面后做一个执行动作,例如自定义页面弹出广告弹窗。
  • description: 页面描述【可选】。
    会被记录到路由表中,方便后期排查的时候知道每个path或Activity是什么业务。
  • params: 页面参数【可选】。
    自动写入intent中,允许写在路由表中动态下发修改默认值,或通过路由跳转时代码传入。
    @Route(path = "http://therouter.com/home", action = "action://scheme.com", description = "第二个页面", params = {"hello", "world"}) public class HomeActivity extends AppCompatActivity { }

3.2 发起页面跳转 传入的参数可以是 String 和8种基本数据类型、也可以是BundleSerializable
Parcelable对象,跟 Intent 传值规则一致。
同时也支持为本次跳转的 Intent 添加Flag/Uri/ClipData/identifier等业务特殊参数。
// 传入参数可以通过注解 @Autowired 解析成任意类型,如果是对象建议传json // context 参数如果不传或传 null,会自动使用 application 替换 TheRouter.build("http://therouter.com/home") .withInt("key1", 12345678) .withString("key2", "参数") .withBoolean("key3", false) .withSerializable("key4", object) .withObject("object", any) // 这个方法可以传递任意对象,但是接收的地方对象类型需自行保证一致,否则会强转异常 .navigation(context); // 如果传入 requestCode,默认使用startActivityForResult启动Activity .navigation(context, 123); // 如果要打开的是fragment,需要使用 .createFragment();

3.3 路由表生成规则 如果两条路由的path、目标className完全相同,则认为是同一条路由,不会考虑参数是否相同。
路由表生成规则:编译期按照如下顺序取并集。
覆盖规则:
根据如下顺序,如果相同,后者可以覆盖前者的路由表规则。
  1. 编译期解析注解生成路由表
  2. 首先取 业务模块 aar 中的路由表
  3. 再取 主app module 代码中的路由表
  4. 最后取 assets/RouteMap.json 文件中声明的路由表。
    • 如果编译期没有这个文件,会生成一份默认路由表放在这个目录内;如果有,会将路由表合并。
    • 路由表生成时可配置是否启用检查路由合法性,判断目标页面是否存在,(warning/error)级别。
  5. 运行时线上动态下发的路由表
    • 路由表允许线上动态下发,将覆盖本地路由表,详见 【3.4 动态路由表的设计与使用】
如果编译期没有这个文件,会生成一份默认路由表放在这个目录内;如果有,会将路由表合并,因此,对于没办法修改代码的第三方SDK内部,如果希望通过路由打开,只需要手动在RouteMap.json文件中声明,就能通过路由打开了。
3.4 动态路由表的设计与使用 TheRouter 的路由表是动态添加的,项目每次编译后,会在 apk 内生成一份当前 APP 的全量路由表,默认路径为:/assets/therouter/routeMap.json。这个路由表也可以后续通过远程下发的方式使用,例如远端可以针对不同的APP版本,下发不同的路由表达到配置目的。这样如果将来线上某些页面发生Crash,可以通过将这个页面的落地页替换为H5的方式,临时解决这类问题。
有两种推荐的远程下发方式可供使用方选择:
  1. 将打包系统与配置系统打通,每次新版本APP打包后自动将assets/目录中的配置文件上传到配置系统,下发给对应版本APP 。优点在于全自动不会出错。
  2. 配置系统无法打通,线上手动下发需要修改的路由项,因为 TheRouter 会自动用最新下发的路由项覆盖包内的路由项。优点在于精确,且流量资源占用小。
注:一旦你设置了自定义的InitTask,原框架内路由表初始化任务将不再执行,你需要自己处理找不到路由表时的兜底逻辑,一种建议的处理方式见如下代码。
// 此代码 必须 在 Application.super.onCreate() 之前调用 RouteMap.setInitTask(new RouterMapInitTask() { /** * 此方法执行在异步 */ @Override public void asyncInitRouteMap() { // 此处为纯业务逻辑,每家公司远端配置方案可能都不一样 // 不建议每次都请求网络,否则请求网络的过程中,路由表是空的,可能造成APP无法跳转页面 // 最好是优先加载本地,然后开异步线程加载远端配置 String json = Connfig.doHttp("routeMap"); // 建议加一个判断,如果远端配置拉取失败,使用包内配置做兜底方案,否则可能造成路由表异常 if (!TextUtils.isEmpty(json)) { List list = new Gson().fromJson(json, new TypeToken>() { }.getType()); // 建议远端下发路由表差异部分,用远端包覆盖本地更合理 RouteMap.addRouteMap(list); } else { // 在异步执行TheRouter内部兜底路由表 initRouteMap() } } });

3.5 高级用法 TheRouter同时支持更多页面跳转能力,详情可参考项目文档【https://github.com/HuolalaTech/hll-wp-therouter-android/wiki/Navigator】:
  • 为第三方库里面的页面添加路由表,达到对某些页面降级替换的目的;
  • 延迟路由跳转(从Android 8开始,不能在后台启动页面);
  • 跳转过程拦截器(总共四层,可根据实际需求使用);
  • 跳转结果回调;
四、跨模块依赖注入 ServiceProvider 的设计
对于模块化开发中跨模块的调用,我们推荐采用 SOA(面向服务架构) 的设计方式,服务调用方与使用方完全隔离,调用模块外的能力不需要关注能力的提供者是谁。
ServiceProvider 的核心设计思想也是这样的,目前服务间的调用协议采用接口的方式。当然,也可以兼容不通过接口下沉而是直接调用的情况。
货拉拉开源模块化路由框架(TheRouter)
文章图片

具体到 Android 侧就是 AIDL 类似的设计,只是要比AIDL开发简单很多:
  • 服务提供方负责提供服务,不需要关心调用方是谁会在何时调用自己。
  • 服务的使用方只关注服务本身,不需要关心这个服务是谁提供的,只需要只能服务能提供哪些能力即可。
例如上面的图片:拉拉需要使用录音的服务,小货则向外提供一个录音的服务,由TheRouterServiceProvider负责撮合。
4.1 服务使用方:拉拉 她无需关心,IRecordService这个接口服务是谁提供的,他只需要知道自己需要使用这样的一个服务就行了。
注:如果没有提供服务的提供方,TheRouter.get()可能返回null
TheRouter.get(IRecordService::class.java)?.doRecord()

4.2 服务提供方:小货 【货拉拉开源模块化路由框架(TheRouter)】服务提供方需要声明一个提供服务的方法,用@ServiceProvider注解标记。
  • 如果是 java,必须是 public static 修饰
  • 如果是 kotlin,建议写成 top level 的函数
  • 方法名不限
/** * 方法名不限定,任意名字都行 * 返回值必须是服务接口名,如果是实现了服务的子类,需要加上returnType限定(例如下面代码) * 方法必须加上 public static 修饰,否则编译期就会报错 */ @ServiceProvider public static IRecordService test() { return new IRecordService() { @Override public void doRecord() { String str = "执行录制逻辑"; } }; }// 也可以直接返回对象,然后标注这个方法的服名是什么 @ServiceProvider(returnType = IRecordService.class) public static RecordServiceImpl test() { // xxx }

五、单模块自动初始化能力 FlowTaskExecutor 的设计
前面讲过,TheRouter是完全面向模块化开发提供的一套解决方案。在模块化开发时,可能每个模块都有自己需要初始化的一些代码。以前的做法是把这些代码都在Application里声明,但是这样可能随着业务变动每次都需要修改Application所在模块。TheRouter 的单模块自动初始化能力就是为了解决这样的情况,可以只在当前模块声明初始化方法后,将会在业务场景时自动被调用。
每个希望被自动初始化的方法,必须使用public static修饰,主要原因是这样子就能通过类名直接调用了。另外很多初始化代码都需要获取Context对象,所以我们将Context作为初始化方法的默认参数,会自动传入Application。其他的所在类名、方法名都没有限制,反正只要加上了 @FlowTask 注解,在编译期都能通过 APT 获取到。
5.1 FlowTaskExecutor 使用介绍 可以在当前模块中,任意类中声明一个任意方法名的方法,给方法添加上@FlowTask 的注解即可。
@FlowTask 注解参数说明:
  • taskName:当前初始化任务的任务名,必须全局唯一,建议格式为:moduleName_taskName
  • dependsOn:参考Gradle Task,任务与任务之间可能会有依赖关系。如果当前任务需要依赖其他任务先初始化,则在这里声明依赖的任务名。可以同时依赖多个任务,用英文逗号分隔,空格可选,会被过滤:dependsOn = "mmkv, config, login",默认为空,应用启动就被调用
  • async:是否要在异步执行此任务,默认false。
/** * 将会在异步执行 */ @FlowTask(taskName = "mmkv_init", dependsOn = TheRouterFlowTask.APP_ONCREATE, async = true) public static void test2(Context context) { System.out.println("异步=========Application onCreate后执行"); }@FlowTask(taskName = "app1") public static void test3(Context context) { System.out.println("main线程=========应用启动就会执行"); }/** * 将会在主线程初始化 */ @FlowTask(taskName = "test", dependsOn = "mmkv,app1") public static void test3(Context context) { System.out.println("main线程=========在app1和mmkv两个任务都执行以后才会被执行"); }

5.2内置初始化节点 使用这个能力,在路由内部默认支持了两个生命周期类任务,可在使用时直接引用
  • TheRouterFlowTask.APP_ONCREATE:当Application的onCreate()执行后初始化
  • TheRouterFlowTask.APP_ONSPLASH:当应用的首个Activity.onCreate()执行后初始化
同时,使用TheRouter的自动初始化依赖,也无需担心循环依赖造成的问题,框架会在编译期构建有向无环图,监测循环依赖情况,如果发现会在编译期直接报错,并且还会将发生循环引用的任务显示出来,用于排错。
5.3 实现原理 每个加了 @FlowTask 注解的方法,都会在编译期被解析,生成一个对应的 Task 对象,这个对象包含了初始化方法的相关信息,比如:是否异步执行、任务名、是否依赖其他任务先执行。
当所有aar都编译完成,生成好全部的 Task 以后,会在主 app 中通过Gradle插件进行聚合,在这时会将所有的 Task 做一次检查,通过构建有向无环图来防止 Task 发生循环引用的情况。
每次应用启动后,会在路由初始化时,将有向图中的全部Task,按照依赖关系按顺序加载。
货拉拉开源模块化路由框架(TheRouter)
文章图片

六、动态化能力 ActionManager 的设计
Action 本质是一个全局的系统回调,主要用于预埋的一系列操作,例如:弹窗、上传日志、清理缓存。
与 Android 系统自带的广播通知类似,你可以在任何地方声明动作与处理方式。并且所有Action都是可以被跟踪的,只要你愿意,可以在日志中将所有的动作调用栈输出,以方便调试使用,这样在一定程度上可以解决观察者模式带来的通病:无法追踪Observable的问题。
6.1 Action 使用 声明一个 Action:
// action建议遵循一定的格式 const val ACTION = "therouter://action/xxx"@FlowTask(taskName="action_demo") fun init(context: Context) = TheRouter.addActionInterceptor(ACTION, object: ActionInterceptor() { override fun handle(context: Context, args: Bundle): Boolean { // do something return false } })

执行一个 Action:
// action建议遵循一定的格式 const val ACTION = "therouter://action/xxx"// 如果执行了一个没有被声明的Action,则不会有任何动作 TheRouter.build(ACTION).action()

6.2 高级用法 每个Action 允许关联多个 ActionInterceptor进行处理,多个ActionInterceptor之间可以自定义拦截器优先级,同时允许终止接下来的低优先级拦截器的执行。
最典型应用场景:首页可能会有多个弹窗,不同业务之间的弹窗是有优先级之分的,为了体验优化我们肯定不会在首页一次把所有弹窗全部弹出,可以通过ActionInterceptor为每个弹窗声明好优先级关系,假设需求是首页只能弹出3个弹窗,那么第三个弹窗处理完毕即可关闭当前事件,接下来的拦截器将不会被响应。
abstract class ActionInterceptor {abstract fun handle(context: Context, args: Bundle): Booleanfun onFinish() {}/** * 数字越大,优先级越高 */ open val priority: Int get() = 5 }

6.3 客户端动态响应使用场景 如果仅客户端使用,常用的场景可能是:当用户执行某些操作(打开某个页面、H5点击某个按钮、动态页面配置的点击事件)时,将会自动触发,执行预埋的 Action 逻辑。
如果与服务端链路打通,这个能力其实是需要整个公司的配合,比如有一套类似智慧大脑的方案,可以基于客户端过去的一些埋点数据,智能推断出用户下一步要做的事情,然后通过长连接直接向客户端下发指令做某些事情。那么通过客户端预埋的页面跳转、弹窗、清缓存、退出登录等等操作,就可以通过服务端指令进行操作,则就是一套完整的动态化方案。
货拉拉开源模块化路由框架(TheRouter)
文章图片

七、一键切换源码与 AAR
7.1 模块化支持的 Gradle 脚本 在模块化开发过程中,如果没有采用分仓,或采用了分仓但依然使用 git-submodule 的方式开发,应该都会遇到一个问题。如果集成包采用源码编译,构建时间实在太久,大大降低开发调试效率;如果采用aar依赖编译,对于底层模块修改了代码,每次都要重新构建aar,在上层模块修改版本号以后,才能继续整包构建编译,也极大影响开发效率。
TheRouter 中提供了一个 Gradle 脚本,只需要在开发本地的local.properties文件中声明要参与编译的module,其他未声明的默认使用aar编译,这样就能灵活切换源码与aar,并且不会影响其他人,如下节选代码可供参考使用:
/** * 如果工程中有源码,则依赖源码,否则依赖aar */ def moduleApi(String compileStr, Closure configureClosure) { String[] temp = compileStr.split(":") String group = temp[0] String artifactid = temp[1] String version = temp[2]Set includeModule = new HashSet<>() rootProject.getAllprojects().each { if (it != rootProject) includeModule.add(it.name) }if (includeModule.contains(artifactid)) { println(project.name + "源码依赖:===project(\":$artifactid\")") projects.project.dependencies.add("api", project(':' + artifactid), configureClosure) //projects.project.configurations { compile.exclude group: group, module: artifactid } } else { println(project.name + "依赖:=======$group:$artifactid:$version") projects.project.dependencies.add("api", "$group:$artifactid:$version", configureClosure) } }

在实际使用时,可以完全使用moduleApi 替换掉原有的api。当然, implementation也可以有一个对应的moduleImplementation,这样只需要注释或解注释setting.gradle文件内的include语句就可以达到切换源码、aar的目的了。
八、从其他路由迁移至 TheRouter
8.1 迁移工具一键迁移 TheRouter提供了图形化界面的迁移工具,可以一键从其他路由迁移到TheRouter,目前仅支持ARouter,其他路由框架迁移也在开发中(GitHub下载,70M左右,请耐心等待):
  • Mac OS 迁移工具下载:https://github.com/HuolalaTech/hll-wp-therouter-android/wiki/uploads/file/TheRouterTransfer-Mac.zip
  • Windows 迁移工具下载:https://github.com/HuolalaTech/hll-wp-therouter-android/wiki/uploads/file/TheRouterTransfer-Windows.zip
如果项目中使用了ARouter的IProvider.init()方法,可能需要手动处理初始化逻辑。
如下图:
货拉拉开源模块化路由框架(TheRouter)
文章图片

8.2 与其他路由对比
功能 TheRouter ARouter WMRouter
Fragment路由 ?? ?? ??
支持依赖注入 ?? ?? ??
加载路由表 无运行时扫描
无反射
运行时扫描dex
反射实例类
性能损耗大
运行时读文件
反射实例类
性能损耗中
注解正则表达式 ?? ?? ??
Activity指定拦截器 ??(四大拦截器可根据业务定制) ?? ??
导出路由文档 ??(路由文档支持添加注释描述) ?? ??
动态注册路由信息 ?? ?? ??
APT支持增量编译 ?? ??(开启文档生成则无法增量编译) ??
plugin支持增量编译 ?? ?? ??
多 Path 对应同一页面(低成本实现双端path统一) ?? ?? ??
远端路由表下发 ?? ?? ??
支持单模块独立初始化 ?? ?? ??
支持使用路由打开第三方库页面 ?? ?? ??
支持使用路由打开第三方库页面 ?? ?? ??
对热修复支持(例如tinker) ??(未改变的代码多次构建无变动) ??(多次构建apt产物会发生变化,生成无意义补丁) ??(多次构建apt产物会发生变化,生成无意义补丁)
九、总结
TheRouter 并不仅仅是一个小巧灵活的路由库,而是一整套完整的 Android 模块化解决方案,能够解决几乎全部的模块化过程中会遇到的问题。
对于现有的路由框架,我们也在最大限度支持平滑迁移,目前已完成ARouter的一键迁移工具,其他框架的迁移仍在开发中。你也可以在Github issue中提出需求,我们评估后会尽快支持,也欢迎任何人提供 Pull Requests
更多问题请访问:详细沟通

    推荐阅读