动态路由 TheRouter 的设计与实践



对本文有任何问题,可加我的个人微信询问:kymjs666

这篇文章是我在 2022【GIAC 全球互联网架构大会】分享时所讲内容的文字版本,修改删减了演讲时的冗余言语,现开放给大家阅读,希望能给买不到票参加分享的 开源实验室 读者带来帮助。

大家好,今天跟大家分享的是一个开源路由TheRouter的设计。
代码地址: https://github.com/HuolalaTech/hll-wp-therouter-android

先来看一下目录 我们从三点,来讲述今天的主题:

  • 分别是模块化的开始,如何通过路由去实现一个模块化。
  • 然后再根据目标,去设计一个动态化的路由解决我们的问题,以及在我们的项目中,是如何实践的。
  • 最后,今年的大环境大家应该都知道,考虑一下如何在资源有些的情况下,推动工程的重构。

这里有三张我手机上APP的截图,分别是:货拉拉、今日头条、美团

他们基本上可以代表了如今市面上大部分APP的一个形态,在这四五年里,互联网公司大幅增加,而APP的业务功能也不断增多。

从技术角度再看一下:

这是我列出来的一个APP的通用架构,这张图基本可以覆盖现如今百分之八九十的 APP 架构。

  1. 首先最上层是各个业务层 比方说是像货拉拉的搬家、拉货、运大件这种。
  2. 接下来是各个业务模块 比如常见的像用户账户体系、然后可能有一些直播、音视频、支付这样的场景模块。
  3. 再往下就是一些功能性的组件:他们可能跟具体的业务功能相关,比如推送、IM、广告控件、这样的一系列功能组件。
  4. 最底层就是基础设施了: 就像数据上报 异常统计等等一系列的必要基础能力。

然后在侧面还有一些贯穿整个APP的能力 像CICD 国际化 端智能 热修复等等。

从这张图我们也能看出现如今的APP是越来越复杂 功能也越来越多

对于功能越来越多,越来越复杂的APP架构,我们最直接能想到的就是通过模块化,将不同的功能、不同的业务做独立拆分,分而治之,降低整个系统的复杂度。毕竟越简单,逻辑越少的代码块,BUG就越少。

所以大型 APP 的开发,基本都会选用模块化开发,同时对于模块间解耦要求更高。
而说到模块化,我们一定需要一个路由去承载不同模块之间的通信。路由是现如今 Android 开发中必不可少的功能,尤其是企业级APP,可以用于将原生页面跳转的强依赖解耦,同时减少跨团队开发的互相依赖问题。

比如UI层级的跳转、功能模块的联动调用,这是做模块化绕不开的两点。

实现这两点最常用的办法也就是:分别将我们当前的一个UI页面与一个uri关联,用Uri替代我们的页面,

这个样子在跳转的时候就不需要强依赖UI页面去做匹配,而只需要通过一段字符串去匹配就行

那另一种就是通过接口下沉,将模块依赖改为协议依赖,这样 我们在不同的模块之间调度的时候 只需要依赖一个最基础的协议或者说是接口 去实现就可以了

做完模块化以后,一个APP的复杂度已经被降低很多了。
但是有一个最大的问题,我们通过模块化是没办法解决的。

也就是APP依赖用户去主动的更新升级,用户不更新,那就是永远在用旧版本, 当年,也是为了解决这个问题,催生出了很多黑科技,比如Android的插件化、热修复这种黑科技,最终这些科技最终也被验证是点歪了的技能树。

今天我跟大家讲讲另一种解决办法:

回到我们今天的主题:动态化路由

前些天我们开源了一套,在安卓上面的动态化路由叫 TheRouter 他是一整套我们实现APP动态化的设计方案。包括模块化、包括远端路由下发、包括前面刚才我列出来的几个依赖用户升级而造成的一些问题,我们都是通过他来解决的。

之所以叫TheRouter 因为 The 代表了一种唯一性,我们在设计的时候就参考了全部现有的开源方案,吸取了大量优秀实现,同时补齐了各个方案的缺点。我们认为做移动端的模块化,只需要看这一个就够了。

首先我们来看一下行业内路由的设计方案,不管是页面跳转,还是跨模块调用,基本上都是

  1. 开发阶段,对要使用路由的落地页或被调用方法添加注解标识。
  2. 在编译期解析注解,生成一系列中间代码,等待调用。
  3. 应用启动后调用中间代码完成路由的准备动作。大部分路由会额外通过 Gradle Transform,在编译期做一次聚合,以提升运行时准备路由表的效率。
  4. 发起路由跳转时,本质上就是一次路由表遍历,通过uri获取到对应的落地页或方法对象,进行调用。

跨模块调用也是类似,在开发时做标记,编译时生成中间代码,运行时通过中间代码调用跨模块方法。

TheRouter 的整体实现逻辑也是按照这个思路去做的,不过我们对于各个细节的处理,有更好的解决办法。

这是另一个角度,跟行业路由的一些对比数据。

大家可以主要关注这几个点:

  • 第一个点: TheRouter是完全无运行时扫描,没有任何反射代码的框架。
    当然因为引用了Gsonjson解析,他里面应该是用了反射的,但这不在我们讨论的范围内,如果你愿意我们允许自定义json解析框架,你可以换成其他的解析。

  • 第二点是TheRouter对增量编译支持非常好,APTplugin都能做到增量编译。
    同时我们内部也有一套基于最新KSP的注解处理代码,KSP是kotlin专门用于处理注解做的一套实现,我们之前用的都是kapt,但是kapt只能处理Kotlin类的注解,如果是KotlinJava混合的工程,他还没办法处理,所以在他内部还包了一层Javaapt,碰到他解析不了的文件,就调用apt去解析,所以他的处理速度是非常慢的。
    而KSP是基于语法树分析去做的,我们知道,所有的代码在编译之前,都会先经过语法树分析,他就是在这一步顺带把分析出来的词法返回,让我们做一些自己的定制逻辑。所以KSP其实不仅仅可以做注解处理,还可以做一些定制的语法分析规则,类似lint那种。

  • 第三点:TheRouter应该是现如今所有路由里,唯一一个支持AGP8的。Gradle从7.X开始,内置了编译过程处理的相关方法,所以AGP直接在8.0删除了相同功能的方法,这就造成大量基于TransformAPI的库,在AGP8都没办法使用了。

  • 最后一点也是我们之前碰到的坑,在用tinker这类热修复框架的时候,由于路由编译的产物代码是无序的,所以每次编译都有可能发生改变,就造成我们的补丁包非常大。TheRouter对这一点也做了特殊支持,只要你没有新增或改动路由相关的代码,编译产物代码就不会有任何变动。

接下来需要大家一起思考一下,一个路由 他真正需要具备的核心能力是哪些。我前面PPT列了一下,参考现在业内的一些通用的路由解决方案 它真正核心需要解决的问题就两个点:

  • 一个是解耦UI跳转
  • 一个是降低系统依赖

我们把这两个目标分别拆开。
在跳转方面,除了业界常用的通过路由字符串映射页面UI之外,我们还加入了动态参数注入。
也就是一个UI页面需要的默认参数可以通过路由表提前声明好,而路由表可以是远端下发的,那这些默认参数也可以是远端下发的,这就做到了线上默认字段的及时更新。

另一部分,降低依赖,除了常用的SPI接口下沉,将模块功能依赖改为接口协议依赖之外,我们还提供了业务节点的hook,所有模块可以反向订阅所需的业务节点,并在业务发生时做自己的逻辑处理。

这一个能力最常用的地方,比如我们在做隐私合规的时候,要求用户同意隐私协议以后,才能做一些敏感API的调用。在以前的开发,这些调用都得要放到隐私弹窗所在的模块内,当用户点同意按钮以后,再调用其他模块初始化方法。这种逻辑对模块化是非常难受的,因为增大了跨模块的沟通,如果团队特别大,不同团队负责不同模块的时候,这种沟通就很累了,假设初始化方法需要增加一个参数,还得额外处理。哪些能力是要一启动就调用的,哪些API是必须用户同意以后才能调用的,都得沟通清楚。

而我们做了业务节点订阅以后,就把这种依赖某个业务节点的功能,做成了订阅发布模式,你只需要声明初始化方法依赖用户同意隐私协议就行了,在用户同意以后就会自动调用初始化方法。

另外,我们还允许客户端创建一套基于规则引擎的触发与响应,可以全局动态智能处理用户操作。假设客户端此刻碰到什么意外情况,比如一个女性用户,在夜里十一二点打车,路上又在某些偏僻点发生异常停留,客户端可以主动做一些我们预置的事件,比如自动报警、语音或者视频自动联系我们的客服。比如像今年iPhone14的新功能,有个车祸检测,如果车翻了或者撞车了,自动帮你打救援电话。而我们这一系列规则,都可以是动态响应的。

接下来看一下路由的设计细节

TheRouter 会在编译期根据注解生成 RouteMap__开头的类,这些类中记录了当前模块的所有路由信息,也就是当前模块的路由表。

在最顶层的app模块中,通过Gradle插件,将所有aar、源码中的RouteMap__开头的类统一集中到TheRouterServiceProvideInjecter类中。

后续应用启动后,初始化路由时只需要执行TheRouterServiceProvideInjecter类的方法,就能没有任何反射的加载到全部的路由表了。

加载以后的路由表会被保存到一个支持正则匹配的 Map 中,这也是TheRouter允许多个path对应同一个落地页的原因。每当发生页面跳转时,通过跳转时的path,去Map中获取到对应的落地页信息,再正常调用startActivity()即可。

对于模块化开发中跨模块的调用,我们推荐采用 SOA(面向服务架构) 的设计方式,服务调用方与使用方完全隔离,调用模块外的能力不需要关注能力的提供者是谁。 ServiceProvider 的核心设计思想也是这样的,目前服务间的调用协议采用接口的方式。当然,也可以兼容不通过接口下沉而是直接调用的情况。

具体到 Android 侧就是 AIDL 类似的设计,只是要比AIDL开发简单很多:

  • 服务提供方负责提供服务,不需要关心调用方是谁会在何时调用自己。
  • 服务的使用方只关注服务本身,不需要关心这个服务是谁提供的,只需要只能服务能提供哪些能力即可。

例如上面的图片:服务使用方需要使用录音的服务,服务提供方则向外提供一个录音的服务,由TheRouterServiceProvider负责撮合。

服务使用方:

无需关心,IRecordService这个接口服务是谁提供的,他只需要知道自己需要使用这样的一个服务就行了。 注:如果没有提供服务的提供方,TheRouter.get()可能返回null

TheRouter.get(IRecordService::class.java)?.doRecord()

服务提供方:

服务提供方需要声明一个提供服务的方法,用@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 
}

前面讲过,TheRouter是完全面向模块化开发提供的一套解决方案。

在模块化开发时,可能每个模块都有自己需要初始化的一些代码。以前的做法是把这些代码都在Application里声明,但是这样可能随着业务变动每次都需要修改Application所在模块。TheRouter 的单模块自动初始化能力就是为了解决这样的情况,可以只在当前模块声明初始化方法后,将会在业务场景时自动被调用。

每个希望被自动初始化的方法,必须使用public static修饰,主要原因是这样子就能通过类名直接调用了。另外很多初始化代码都需要获取Context对象,所以我们将Context作为初始化方法的默认参数,会自动传入Application。其他的所在类名、方法名都没有限制,反正只要加上了 @FlowTask 注解,在编译期都能通过 APT 获取到。

或者隐私合规的时候,有一些功能需要同意隐私协议才能调用。

跨模块依赖的时候,需要另一个模块初始化以后,才能调用当前模块的初始化,等等业务都可以用业务节点自主订阅的方式去解耦。

每个加了 @FlowTask 注解的方法,都会在编译期被解析,生成一个对应的 Task 对象,这个对象包含了初始化方法的相关信息,比如:是否异步执行、任务名、是否依赖其他任务先执行。

当所有aar都编译完成,生成好全部的 Task 以后,会在主 app 中通过Gradle插件进行聚合,在这时会将所有的 Task 做一次检查,通过构建有向无环图来防止 Task 发生循环引用的情况。

每次应用启动后,会在路由初始化时,将有向图中的全部Task,按照依赖关系按顺序加载。

可以在当前模块中,任意类中声明一个任意方法名的方法,给方法添加上@FlowTask 的注解即可。

@FlowTask 注解参数说明:

  • taskName:当前初始化任务的任务名,必须全局唯一,建议格式为:moduleName_taskName
  • dependsOn:参考Gradle Task,任务与任务之间可能会有依赖关系。如果当前任务需要依赖其他任务先初始化,则在这里声明依赖的任务名。可以同时依赖多个任务,用英文逗号分隔,空格可选,会被过滤:dependsOn = “mmkv, config, login”,默认为空,应用启动就被调用
  • async:是否要在异步执行此任务,默认false。

最后一个,APP动态响应的实现。

还是回到之前的例子:假设一个女性、夜里12点、KTV上车、偏僻地点停车,那么我们就可以根据这样的一系列先决条件,交由后端的智慧大脑分析,然后下发给客户端一个动作:比如打开视频或语音,让客服介入。

而把这个例子抽象一下,所有用户的操作,比如点击、曝光、页面跳转等等埋点数据,都可以作为分析数据交给服务端分析,然后让客户端执行:跳转页面、弹窗、优惠券、或者其他本地方法。

这样的一个流程做完了以后,只要我们有一个可靠的行为分析模型,我们是大概率可以预测用户接下来的行为是要做什么的。

当然,即便我们没有这样一个用户行为分析的大脑,纯客户端的方案,也是能够支持的,这就是离线端智能方案了。

最后我们再来看一下前面提到的几个 APP 的弊端,在 TheRouter 中是怎么解决的呢?

  • 第一个:页面Crash,我们可以通过去修改路由表,然后我们把某一些页面的 Crash 给它降级,降级成 H5 或者说是小程序。当假设我们这个页面没办法访问的时候,我们可以让用户先暂时地去访问 H5 页面或者说小程序页面。同样的,如果某个页面白屏很久,我们也可以通过降级,直接通过H5或小程序的方式兼容打开。

  • 第二个:对于一些接口字段,老版本的兼容问题,我们也是能够去下发默认参数的方式。如果老版本它强制要求有某一个参数,那其实我们可以把这个参数给下发成一个默认参数。如果我们做了千人千面的话,那每一个用户都可以达到不同参数不同展示的效果。

  • 第三个:新功能透传及时性。假设我们当前有某一个直播的页面,新版本已经有一个可以让用户打赏或者说是让用户发礼物这样的功能了。那老版本它还没有这样的一个功能的话,我们可以通过点击礼物图标后,修改落地页把它给他提示升级弹窗。这样的升级弹窗对用户是影响最小的,它只在使用到这个功能的时候才需要做某一些升级。

  • 第四个意外事件处理:就是我前面讲到的云端大脑或端智能这样的应用场景了。

最后我们来看今天的第三部分,今年的情况大家都能感受,各种人员优化,大家都很忙,那如何将这种大的技术重构成本降到最低呢,我们为TheRouter开发了很多周边能力:

TheRouter提供了图形化界面的一键迁移工具,可以一键从其他路由迁移到TheRouter,整个迁移过程都是基于字符串匹配完成的,不涉及任何黑科技,所有的替换点也都会展示出来,非常安全。在替换完成后,自动输出改动页面与测试点,大幅减少了开发与测试的工作量。

还有一个用于自动跳转的高效IDE辅助插件,可以直接从路由的声明处查看到哪些地方跳转到本路由,再也不用怕路由字符串满天飞了。

只需要点一下左边的图标,就能自动跳转到落地页了。假设我们有多个跳转,跳转到同一个落地页的,点击落地页左侧的图标,也会展示出对应的代码,选择以后也可以自动跳转过去。

另外还有一个很好的特性,就是如果你写了没有落地页的跳转,会在IDE左侧有个黄色的警告,提示你是不是因为手抖或其他原因,写错了path

另外TheRouter还提供了官网和微信群,官网有大量的技术文档和指导教程,有不懂的问题还可以加入微信群寻求帮助。

官网:https://therouter.cn

微信群:https://therouter.cn/wx/

总的来说,TheRouter 并不仅仅是一个小巧灵活的路由库,而是一整套完整的 Android 模块化解决方案,能够解决几乎全部的模块化过程中会遇到的问题。 对于现有的路由框架,我们也在最大限度支持平滑迁移。你也可以在Github issue中提出需求,我们评估后会尽快支持,也欢迎任何人提供 Pull Requests