WMRouter:美团外卖 Androiid 开源路由框架
WMRouter 是一款 Android路由框架,基于组件化的设计思路,功能灵活,使用也比较简单。
WMRouter 最初用于解决美团外卖 C 端 App 在业务演进过程中的实际问题,之后逐步推广到了美团其他 App,因此我们决定将其开源,希望更多技术同行一起开发,应用到更广泛的场景里去。Github 项目地址与使用文档详见 https://github.com/meituan/WMRouter。
本文先简单介绍 WMRouter 的功能和适用场景,然后详细介绍 WMRouter 的发展背景和过程。
功能简介
WMRouter 主要提供 URI 分发、ServiceLoader 两大功能。
URI 分发功能可用于多工程之间的页面跳转、动态下发 URI 链接的跳转等场景,特点如下:
支持多 scheme、host、path
支持 URI 正则匹配
页面配置支持 Java 代码动态注册,或注解配置自动注册
支持配置全局和局部拦截器,可在跳转前执行同步/异步操作,例如定位、登录等
支持单次跳转特殊操作:Intent 设置 Extra/Flags、设置跳转动画、自定义 StartActivity 操作等
支持页面 Exported 控制,特定页面不允许外部跳转
支持配置全局和局部降级策略
支持配置单次和全局跳转监听
完全组件化设计,核心组件均可扩展、按需组合,实现灵活强大的功能
基于SPI (Service Provider Interfaces) 的设计思想,WMRouter 提供了ServiceLoader 模块,类似 Java 中的 java.util.ServiceLoader ,但功能更加完善。通过 ServiceLoader 可以在一个 App 的多个模块之间通过接口调用代码,实现模块解耦,便于实现组件化、模块间通信,以及和依赖注入类似的功能等。其特点如下:
使用注解自动配置
支持获取接口的所有实现,或根据 Key 获取特定实现
支持获取 Class 或获取实例
支持无参构造、Context 构造,或自定义 Factory、Provider 构造
支持单例管理
支持方法调用
其他特性:
优化的 Gradle 插件,对编译耗时影响很小
编译期和运行时配置检查,避免配置冲突和错误
编译期自动添加 Proguard 混淆规则,免去手动配置的繁琐
完善的调试功能,帮助及时发现问题
适用场景
WMRouter 适用但不限于以下场景:
Native+H5 混合开发模式,需要进行页面之间的互相跳转,或进行灵活的运营跳转链接下发。可以利用 WMRouter 统一页面跳转逻辑,根据不同的协议(HTTP、HTTPS、用于 Native 页面的自定义协议)跳转对应页面,且在跳转过程中可以使用 UriInterceptor 对跳转链接进行修改,例如跳转 H5 页面时在 URL 中加参数。
统一管理来自 App 外部的 URI 跳转。来自 App 外部的 URI 跳转,如果使用 Android 原生的 Manifest 配置,会直接启动匹配的 Activity,而很多时候希望先正常启动 App 打开首页,完成常规初始化流程(例如登录、定位等)后再跳转目标页面。此时可以使用统一的 Activity 接收所有外部 URI 跳转,到首页时再用 WMRouter 启动目标页面。
页面跳转有复杂判断逻辑的场景。例如多个页面都需要先登录、先定位后才允许打开,如果使用常规方案,这些页面都需要处理相同的业务逻辑;而利用 WMRouter,只需要开发好 UriInterceptor 并配置到各个页面即可。
多工程、组件化、平台化开发。多工程开发要求各个工程之间能互相通信,也可能遇到和外卖 App 类似的代码复用、依赖注入、编译等问题,这些问题都可以利用 WMRouter 的 URI 分发和 ServiceLoader 模块解决。
对业务埋点需求较强的场景。页面跳转作为最常见的业务逻辑之一,常常需要埋点。给每个页面配置好 URI,使用 WMRouter 统一进行页面跳转,并在全局的 OnCompleteListener 中埋点即可。
对 App 可用性要求较高的场景。一方面,可以对页面跳转失败进行埋点监控上报,及时发现线上问题;另一方面,页面跳转时可以执行判断逻辑,发现异常(例如服务端异常、客户端崩溃等)则自动打开降级后的页面,保证关键功能的正常工作,或给用户友好的提示。
页面 A/B 测试、动态配置等场景。在 WMRouter 提供的接口基础上进行少量开发配置,就可以实现:根据下发的 A/B 测试策略跳转不同的页面实现;根据不同的需要动态下发一组路由表,相同的 URI 跳转到不同的一组页面(实现方面可以自定义 UriInterceptor,对匹配的 URI 返回 301 的 UriResult 使跳转重定向)。
基本概念解释
下面开始介绍 WMRouter 的发展背景和过程。为了方便后文的理解,我们先简单了解和回顾几个基本概念。
路由
根据维基百科的解释,路由(routing)可以理解成在互联的网络通过特定的协议把信息从源地址传输到目的地址的过程。一个典型的例子就是在互联网中,路由器可以根据 IP 协议将数据发送到特定的计算机。
URI
URI(Uniform Resource Identifier,统一资源标识符)是一个用于标识某一互联网资源名称的字符串。URI 的组成如下图所示。
一些常见的 URI 举例如下,包括平时经常用到的网址、IP 地址、FTP 地址、文件、打电话、发邮件的协议等。
file:///Users/
tel:863-1234
在 Android 中也提供了android.net.Uri 工具类用于处理URI,Android 中 URI 常用的几个部分主要是 scheme、host、path 和 query。
Android 中的 Intent 跳转
在 Android 中的 Intent 跳转,分为显式跳转和隐式跳转两种。
显式跳转即指定 ComponentName(类名)的 Intent 跳转,一般通过 Bundle 传参,示例代码如下:
隐式跳转即不指定 ComponentName 的 Intent 跳转,通过 IntentFilter 找到匹配的组件,IntentFilter 支持 action、category 和 data 的匹配,其中 data 就是 URI。例如下面的代码,会启动系统默认的浏览器打开网页:
Activity 通过 Manifest 配置 IntentFilter,例如下面的配置可以匹配所有形如 demo_scheme://demo_host/*** 的 URI。
URI 跳转
在美团外卖 C 端早期开发过程中,产品希望通过后台下发 URI 控制客户端跳转指定页面,从而实现灵活的运营配置。外卖 App 采用了Native+H5 的混合开发模式,Native 页面定义了专用的 URI,而 H5 页面则使用 HTTP/HTTPS 链接在专门的 WebView 容器中加载,两种链接的跳转逻辑不同,实现起来比较繁琐。
Native 页面的 URI 跳转最开始使用的是 Android 原生的 IntentFilter,通过隐式跳转启动,但是这种方式存在灵活性差、功能缺失、Bug 多等问题。
例如:
从外部(浏览器、微信等)跳转外卖的 URI 时,系统会直接打开相应的 Activity,而没有经过欢迎页的正常启动流程,一些代码逻辑可能没有执行,例如定位逻辑。
有很多页面在打开前需要确保用户先登录或先定位,每个页面都写一遍判断登录、定位的逻辑非常麻烦,提高了开发维护成本。
运营人员可能会配错 URI,页面跳转失败,有些跳转的地方没有做 try-catch 处理,会产生 Crash;有些地方虽然加了try-catch,但跳转失败后没有任何响应,用户体验差;跳转失败没有监控,不能及时发现和解决线上业务异常。
为了解决上述问题,我们希望有一个 Android 的 URI 分发组件,可以根据 URI 中不同的 scheme、host、path,进行不同的处理,同时能够在页面跳转过程中进行更灵活的干预。调研发现,现有的一些 Android路由组件主要都是在解决多工程之间解耦的问题,而 URI 往往只支持通过 path 分发,页面跳转的配置也不够灵活,难以满足实际需要。于是我们决定自行设计实现。
核心设计思路
下图展示了WMRouter 中 URI 分发机制的核心设计思路。借鉴网络请求的机制,WMRouter 中的每次 URI 跳转视为发起一个 UriRequest;URI 跳转请求被 WMRouter 逐层分发给一系列的 UriHandler 进行处理;每个 UriHandler 处理之前可以被 UriInterceptor 拦截,并插入一些特殊操作。
页面跳转来源
常见的页面跳转来源如下:
来自 App 内部 Native 页面的跳转
来自 App 内 Web 容器的跳转,即 H5 页面发起的跳转
从 App 外通过 URI 唤起 App 的跳转,例如来自浏览器、微信等
从通知中心 Push 唤起 App 的跳转
对于来自 App 内部和 Web 容器的跳转,我们把所有跳转代码统一改成调用 WMRouter 处理,而来自外部和 Push 通知的跳转则全部使用一个独立的中转 Activity 接收,再调用 WMRouter 处理。
UriRequest
UriRequest 中包含 Context、URI 和 Fields,其中 Fields 为 HashMap,可以通过 Key 存放任意数据。简单起见,UriRequest 类同时承担了Response 的功能,跳转请求的结果,也会被保存到 Fields 中。Fields 可以根据需要自定义,其中一些常见字段举例如下:
Intent 的 Extra 参数,Bundle 类型
用于 startActivityForResult 的 RequestCode,int 类型
用于 overridePendingTransition 方法的页面切换动画资源,int[]类型
本次跳转结果的监听器,OnCompleteListener 类型
每次 URI 跳转请求会有一个 ResultCode(类似 HTTP 请求的 ResponseCode),表示跳转结果,也存放在 Fields 中。常见 Code 如下,用户也可以自定义 Code:
200:跳转成功
301:重定向到其他 URI,会再次跳转
400:请求错误,通常是 Context 或 URI 为空
403:禁止跳转,例如跳转白名单以外的 HTTP 链接、Activity 的 exported 为 false 等
404:找不到目标(Activity 或 UriHandler)
500:发生错误
总结来说,UriRequest 用于实现一次 URI 跳转中所有组件之间的通信功能。
UriHandler
UriHandler 用于处理URI 跳转请求,可以嵌套从而逐层分发和处理请求。UriHandler 是异步结构,接收到 UriRequest 后处理(例如跳转 Activity 等),如果处理完成,则调用 callback.onComplete() 并传入 ResultCode;如果没有处理,则调用 callback.onNext() 继续分发。下面的示例代码展示了一个只处理HTTP 链接的 UriHandler 的实现:
UriInterceptor
UriInterceptor 为拦截器,不做最终的 URI 跳转操作,但可以在最终的跳转前进行各种同步/异步操作,常见操作举例如下:
URI 跳转拦截,禁止特定的 URI 跳转,直接返回 403(例如禁止跳转非 meituan 域名的 HTTP 链接)
URI 参数修改(例如在 HTTP 链接末尾添加 query 参数)
各种中间处理(例如打开登录页登录、获取定位、发网络请求)
……
每个 UriHandler 都可以添加若干 UriInterceptor。在 UriHandler 基类中,handle()方法先调用抽象方法 shouldHandle() 判断是否要处理UriRequest,如果需要处理,则逐个执行Interceptor,最后再调用 handleInternal() 方法进行跳转操作。
URII 的分发与降级
在外卖 C 端 App 中的 URI 分发示意如下图。所有 URI 跳转都会分发到 RootUriHandler,然后根据不同的 scheme 分发到不同的子 Handler。例如 waimai 协议分发到 WmUriHandler,然后进一步根据不同的 path 分发到子 Handler,从而启动相应的 Activity;HTTP/HTTPS 协议分发到 HttpHandler,启动 WebView 容器;对于其他类型 URI(tel、mailto 等),前面的几个 Handler 都无法处理,则会分发到 StartUriHandler,尝试使用 Android 原生的隐式跳转启动系统应用。
每个 UriHandler 都可以根据实际需要实现降级策略,也可以不作处理继续分发给其他 UriHandler。RootUriHandler 中提供了一个全局的分发完成事件监听器,当 UriHandler 处理失败返回异常 ResultCode 或所有子 UriHandler 都没有处理时,执行全局降级策略。
平台化与两端复用
随着外卖 C 端业务的演进,团队成员扩充了数倍,商超生鲜等垂直品类的拆分,以及异地研发团队的建立,客户端的平台化被提上日程。关于外卖平台化更详细的内容,可参考团队之前已经发布的文章 美团外卖Android平台化架构演进实践。
为了满足实际开发需要,在长时间的探索后,逐步形成了如图所示的三层工程结构。
原有的单个工程拆分成多个工程,就不可避免的涉及到多工程之间的耦合问题,主要包括通信问题、复用问题、依赖注入、编译问题,下面详细介绍。
通信问题
当原先的一个工程拆分到各个业务库后,业务库之间的页面需要进行通信,最主要的场景就是页面跳转并通过 Intent 传递参数。
原先的页面跳转使用显式跳转,Activity 之间存在强引用,当 Activity 被拆分到不同的业务库,业务库不能直接互相依赖,因此需要进行解耦。
利用 WMRouter 的 URI 分发机制,刚好可以很容易的解决这个问题。将将所有业务库的 Activity 注册到 WMRouter,各个业务库之间就可以进行页面跳转了。
此时 WMRouter 已经承载了两项功能:
后台下发的运营 URI 跳转 ( waimai://* )
内部页面跳转,用于代替原有的显式跳转,实现工程解耦 ( wm_router://page/* )
由于后台下发的 URI 是和产品、运营、H5、iOS 等各端统一制定的协议,支持的页面、格式、参数等都不能随意改动,而内部页面跳转使用的 URI,则需要根据实际开发需要进行配置,两套 URI 协议不能兼容,因此使用了不同的 scheme。
复用问题与 ServiiceLoader 模块业务库之间经常需要复用代码。
一些通用代码逻辑可以下沉到平台层从而复用,例如业务无关的通用 View 组件;而有些代码不适合下沉到平台层,例如业务库 A 要使用业务库 B 中的某个界面模块,而这个界面模块和业务库 B 的耦合很紧密。具体到外卖实际业务场景中,商家页在商家休息时会展示推荐商家列表,其样式和首页相同(如图),而两个页面不在一个工程中,商家页希望能直接从首页业务库中获取商家列表的实现。
为了解决上述问题,我们调研了解到 Java 中SPI (Service Provider Interfaces) 的设计思想与 java.util.ServiceLoader 工具类,还学习到美团平台为了解决类似问题而开发的 ServiceLoader 组件。
考虑到实际需求差异,我们重新开发了自己的 ServiceLoader 实现。相比 Java 中的实现,WMRouter 的实现借鉴了美团平台的设计思路,不仅支持通过接口获取所有实现类,还支持通过接口和唯一的 Key 获取特定的实现类。另外 WMRouter 的实现还支持直接加载实现类的 Class、用 Factory 和 Provider 创建对象、单例管理、方法调用等功能。在 Gradle 插件的实现思路上,借鉴了美团平台的 ServiceLoader 并做了性能优化,给平台提出了改进建议。
业务库之间代码复用的需求示意如图,业务库 A 需要复用业务库 B 中的 ServiceImpl 但又不能直接引用,因此通过 WMRouter 加载:
抽取接口(或父类,后面不再重复说明)下沉到平台层,实现类 ServiceImpl 实现该接口,并声明一个 Key(字符串类型)。
调用方通过接口和 Key,由 ServiceLoader 加载实现类,通过接口访问实现类。
URI 跳转和 ServiceLoader 看起来似乎没有关联,但通信和复用需求的本质都可以理解成路由,页面通过 URI 分发跳转时的协议是 Activity+URI,在这里ServiceLoader 的协议是 Interface+Key。
依赖注入
为了兼容外卖独立 App 和美团 App 外卖频道的两端差异,平台层的一些接口要在两个主工程分别实现,并注入到底层。常规 Java 代码注入的方式写起来很繁琐,而使用 WMRouter 的 ServiceLoader 功能可以更简单的实现和依赖注入类似的效果。
对于 WMRouter 来说,所有依赖它的工程(包括主工程、业务库、平台库)都是一样的,任何一个库想要调用其他库中的代码,都可以通过 WMRouter路由转发。前面的通信和复用问题,是同级的业务库之间通过 WMRouter 调用,而依赖注入则是底层库通过 WMRouter 调用上层库,其本质和实现都是相同的。
ServiceLoader 模块在加载实现类时提供了单例管理功能,可用于管理一些全局的 Service/Manager,例如用户账户管理类 UserManager 。
编译问题
由于历史原因,主工程作为一个没有业务逻辑的壳工程,对业务库却有较多依赖,特别是对业务库的初始化配置繁琐,和各业务库耦合紧密。另一方面,WMRouter 跳转的页面、加载的实现类,需要在 Application 初始化时注册到 WMRouter 中,也会增加主工程和业务库的耦合。开发过程中各业务库频繁更新,经常出现无法编译的问题,严重影响开发。
为了解决这个问题,一方面 WMRouter 增加了注解支持,在 Activity 类、ServiceLoader 实现类上配置注解,就可以在编译期间自动生成代码注册到 WMRouter 中。
另一方面,ServiceLoader 还支持指定接口加载所有实现类,主工程可以通过统一接口,加载各个业务库中所有实现类并进行初始化,最终实现和业务库的彻底隔离。
开发过程中,各个业务库可以像插件一样按需集成到主工程,能大幅减少不能编译的问题,同时由于编译时可以跳过不需要的业务库,编译速度也能得到提高。
WMRouter 的推广
在 WMRouter 解决了外卖 C 端 App 的各种问题后,发现公司内甚至公司外的其他 App 也遇到了相似的问题和需求,于是决定对 WMRouter 进行推广和开源。
由于 WMRouter 是一个开放式组件化框架,UriRequest 可以存放任意数据,UriHandler、UriInterceptor 可以完全自定义,不同的 UriHandler 可以任意组合,具有很大的灵活性。但过于灵活容易导致易用性的下降,即使对于最常规最简单的应用,也需要复杂的配置才能完成功能。
为了在灵活性与易用性之间平衡,一方面,WMRouter 对包结构进行了合理的划分,核心接口和实现类提供基础通用能力,尽可能保留最大的灵活性;另一方面,封装了一系列通用实现类,并组合成一套默认实现,从而满足绝大多数常规使用场景,尽可能降低其他 App 的接入成本,方便推广。
总结
目前业界已有的一些 Android路由框架,不能满足外卖 C 端 App 在开发过程中的实际需要,因此我们开发了WMRouter路由框架。借鉴网络请求的思想,设计了基于 UriRequest、UriHandler、UriInterceptor 的 URI 分发机制,在保证功能灵活强大的同时,又尽可能的降低了使用难度;另一方面,借鉴 SPI 的设计思想、Java 和美团平台的 ServiceLoader 实现,开发了自己的 ServiceLoader 模块,解决外卖平台化过程中的四个问题(通信问题、复用问题、依赖注入、编译问题)。在经过了近一年的不断迭代完善后,WMRouter 已经成为美团多个 App 中的核心基础组件之一。
参考资料
作者简介
子健,美团高级工程师,2015年加入美团,先后负责外卖客户端首页、商家容器、评价等业务模块的开发维护,以及平台化、性能优化等技术工作。
渊博,美团高级工程师,2016年加入美团,目前作为外卖商家端 Android App 主力开发,主要负责商家端和蜜蜂端业务技术需求开发。
云驰,美团高级工程师,2016年加入美团,目前负责外卖客户端搜索、IM 等业务库,及外卖多端统一工作。
评论