本文作者——张涛「货拉拉」
这篇文章是我在 2022【GIAC 全球互联网架构大会】分享时所讲内容的文字版本,修改删减了演讲时的冗余言语,现开放给大家阅读,希望能给买不到票参加分享的 开源实验室 读者带来帮助。
大家好,今天跟大家分享的是一个开源路由TheRouter
的设计。
代码地址:https://github.com/HuolalaTech/hll-wp-therouter-android
先来看一下目录 我们从三点,来讲述今天的主题:
这里有三张我手机上APP的截图,分别是:货拉拉、今日头条、美团
他们基本上可以代表了如今市面上大部分APP的一个形态,在这四五年里,互联网公司大幅增加,而APP的业务功能也不断增多。
从技术角度再看一下:
这是我列出来的一个APP的通用架构,这张图基本可以覆盖现如今百分之八九十的 APP 架构。
然后在侧面还有一些贯穿整个APP的能力 像CICD 国际化 端智能 热修复等等。
从这张图我们也能看出现如今的APP是越来越复杂 功能也越来越多
对于功能越来越多,越来越复杂的APP架构,我们最直接能想到的就是通过模块化,将不同的功能、不同的业务做独立拆分,分而治之,降低整个系统的复杂度。毕竟越简单,逻辑越少的代码块,BUG就越少。
所以大型 APP 的开发,基本都会选用模块化开发,同时对于模块间解耦要求更高。 而说到模块化,我们一定需要一个路由去承载不同模块之间的通信。路由是现如今 Android 开发中必不可少的功能,尤其是企业级APP,可以用于将原生页面跳转的强依赖解耦,同时减少跨团队开发的互相依赖问题。
比如UI层级的跳转、功能模块的联动调用,这是做模块化绕不开的两点。
实现这两点最常用的办法也就是:分别将我们当前的一个UI页面与一个uri关联,用Uri替代我们的页面,
这个样子在跳转的时候就不需要强依赖UI页面去做匹配,而只需要通过一段字符串去匹配就行
那另一种就是通过接口下沉,将模块依赖改为协议依赖,这样 我们在不同的模块之间调度的时候 只需要依赖一个最基础的协议或者说是接口 去实现就可以了
做完模块化以后,一个APP
的复杂度已经被降低很多了。
但是有一个最大的问题,我们通过模块化是没办法解决的。
也就是APP
依赖用户去主动的更新升级,用户不更新,那就是永远在用旧版本, 当年,也是为了解决这个问题,催生出了很多黑科技,比如Android
的插件化、热修复这种黑科技,最终这些科技最终也被验证是点歪了的技能树。
今天我跟大家讲讲另一种解决办法:
回到我们今天的主题:动态化路由
前些天我们开源了一套,在安卓上面的动态化路由叫 TheRouter
他是一整套我们实现APP
动态化的设计方案。包括模块化、包括远端路由下发、包括前面刚才我列出来的几个依赖用户升级而造成的一些问题,我们都是通过他来解决的。
之所以叫TheRouter
因为 The
代表了一种唯一性,我们在设计的时候就参考了全部现有的开源方案,吸取了大量优秀实现,同时补齐了各个方案的缺点。我们认为做移动端的模块化,只需要看这一个就够了。
首先我们来看一下行业内路由的设计方案,不管是页面跳转,还是跨模块调用,基本上都是
Gradle
Transform
,在编译期做一次聚合,以提升运行时准备路由表的效率。跨模块调用也是类似,在开发时做标记,编译时生成中间代码,运行时通过中间代码调用跨模块方法。
TheRouter
的整体实现逻辑也是按照这个思路去做的,不过我们对于各个细节的处理,有更好的解决办法。
这是另一个角度,跟行业路由的一些对比数据。
大家可以主要关注这几个点:
TheRouter
是完全无运行时扫描,没有任何反射代码的框架。
当然因为引用了Gson
做json
解析,他里面应该是用了反射的,但这不在我们讨论的范围内,如果你愿意我们允许自定义json
解析框架,你可以换成其他的解析。TheRouter
对增量编译支持非常好,APT
、plugin
都能做到增量编译。
同时我们内部也有一套基于最新KSP
的注解处理代码,KSP是kotlin
专门用于处理注解做的一套实现,我们之前用的都是kapt
,但是kapt
只能处理Kotlin
类的注解,如果是Kotlin
跟Java
混合的工程,他还没办法处理,所以在他内部还包了一层Java
的apt
,碰到他解析不了的文件,就调用apt
去解析,所以他的处理速度是非常慢的。
而KSP是基于语法树分析去做的,我们知道,所有的代码在编译之前,都会先经过语法树分析,他就是在这一步顺带把分析出来的词法返回,让我们做一些自己的定制逻辑。所以KSP其实不仅仅可以做注解处理,还可以做一些定制的语法分析规则,类似lint
那种。TheRouter
应该是现如今所有路由里,唯一一个支持AGP8的。Gradle从7.X开始,内置了编译过程处理的相关方法,所以AGP直接在8.0删除了相同功能的方法,这就造成大量基于TransformAPI
的库,在AGP8都没办法使用了。tinker
这类热修复框架的时候,由于路由编译的产物代码是无序的,所以每次编译都有可能发生改变,就造成我们的补丁包非常大。TheRouter
对这一点也做了特殊支持,只要你没有新增或改动路由相关的代码,编译产物代码就不会有任何变动。接下来需要大家一起思考一下,一个路由 他真正需要具备的核心能力是哪些。我前面PPT列了一下,参考现在业内的一些通用的路由解决方案 它真正核心需要解决的问题就两个点:
我们把这两个目标分别拆开。 在跳转方面,除了业界常用的通过路由字符串映射页面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开发简单很多:
例如上面的图片:服务使用方需要使用录音的服务,服务提供方则向外提供一个录音的服务,由TheRouter
的ServiceProvider
负责撮合。
无需关心,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
注解参数说明:
moduleName_taskName
Gradle
Task,任务与任务之间可能会有依赖关系。如果当前任务需要依赖其他任务先初始化,则在这里声明依赖的任务名。可以同时依赖多个任务,用英文逗号分隔,空格可选,会被过滤:dependsOn = “mmkv, config, login”,默认为空,应用启动就被调用最后一个,APP动态响应的实现。
还是回到之前的例子:假设一个女性、夜里12点、KTV上车、偏僻地点停车,那么我们就可以根据这样的一系列先决条件,交由后端的智慧大脑分析,然后下发给客户端一个动作:比如打开视频或语音,让客服介入。
而把这个例子抽象一下,所有用户的操作,比如点击、曝光、页面跳转等等埋点数据,都可以作为分析数据交给服务端分析,然后让客户端执行:跳转页面、弹窗、优惠券、或者其他本地方法。
这样的一个流程做完了以后,只要我们有一个可靠的行为分析模型,我们是大概率可以预测用户接下来的行为是要做什么的。
当然,即便我们没有这样一个用户行为分析的大脑,纯客户端的方案,也是能够支持的,这就是离线端智能方案了。
最后我们再来看一下前面提到的几个 APP 的弊端,在 TheRouter 中是怎么解决的呢?
最后我们来看今天的第三部分,今年的情况大家都能感受,各种人员优化,大家都很忙,那如何将这种大的技术重构成本降到最低呢,我们为TheRouter
开发了很多周边能力:
TheRouter
提供了图形化界面的一键迁移工具,可以一键从其他路由迁移到TheRouter
,整个迁移过程都是基于字符串匹配完成的,不涉及任何黑科技,所有的替换点也都会展示出来,非常安全。在替换完成后,自动输出改动页面与测试点,大幅减少了开发与测试的工作量。
还有一个用于自动跳转的高效IDE
辅助插件,可以直接从路由的声明处查看到哪些地方跳转到本路由,再也不用怕路由字符串满天飞了。
只需要点一下左边的图标,就能自动跳转到落地页了。假设我们有多个跳转,跳转到同一个落地页的,点击落地页左侧的图标,也会展示出对应的代码,选择以后也可以自动跳转过去。
另外还有一个很好的特性,就是如果你写了没有落地页的跳转,会在IDE
左侧有个黄色的警告,提示你是不是因为手抖或其他原因,写错了path
。
总的来说,TheRouter
并不仅仅是一个小巧灵活的路由库,而是一整套完整的 Android
模块化解决方案,能够解决几乎全部的模块化过程中会遇到的问题。对于现有的路由框架,我们也在最大限度支持平滑迁移。你也可以在Github
issue
中提出需求,我们评估后会尽快支持,也欢迎任何人提供 Pull Requests
。