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<String, Object>,可以通过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的实现:
public interface UriCallback {
/**
- 处理完成,继续后续流程。
*/
void onNext();
/**
- 处理完成,终止分发流程。
- @param resultCode 结果
*/
void onComplete(int resultCode);
}
public class DemoUriHandler extends UriHandler {
public void handle(@NonNull final UriRequest request, @NonNull final UriCallback callback) {
Uri uri = request.getUri();
// 处理HTTP链接
if (“http”.equalsIgnoreCase(uri.getScheme())) {
try {
// 调用系统浏览器
Intent intent = new Intent();
intent.setAction(Intent.ACTION_VIEW);
intent.setData(uri);
request.getContext().startActivity(intent);
// 跳转成功
callback.onComplete(UriResult.CODE_SUCCESS);
} catch (Exception e) {
// 跳转失败
callback.onComplete(UriResult.CODE_ERROR);
}
} else {
// 非HTTP链接不处理,继续分发
callback.onNext();
}
}
// …
}
UriInterceptor
UriInterceptor为拦截器,不做最终的URI跳转操作,但可以在最终的跳转前进行各种同步/异步操作,常见操作举例如下:
- URI跳转拦截,禁止特定的URI跳转,直接返回403(例如禁止跳转非meituan域名的HTTP链接)
- URI参数修改(例如在HTTP链接末尾添加query参数)
- 各种中间处理(例如打开登录页登录、获取定位、发网络请求)
- ……
每个UriHandler都可以添加若干UriInterceptor。在UriHandler基类中,handle()方法先调用抽象方法shouldHandle()
判断是否要处理UriRequest,如果需要处理,则逐个执行Interceptor,最后再调用handleInternal()
方法进行跳转操作。
public abstract class UriHandler {
// ChainedInterceptor将多个UriInterceptor合并成一个
protected ChainedInterceptor mInterceptor;
public UriHandler addInterceptor(@NonNull UriInterceptor interceptor) {
if (interceptor != null) {
if (mInterceptor == null) {
mInterceptor = new ChainedInterceptor();
}
mInterceptor.addInterceptor(interceptor);
}
return this;
}
public void handle(@NonNull final UriRequest request, @NonNull final UriCallback callback) {
if (shouldHandle(request)) {
if (mInterceptor != null) {
mInterceptor.intercept(request, new UriCallback() {
@Override
public void onNext() {
handleInternal(request, callback);
}
@Override
public void onComplete(int result) {
callback.onComplete(result);
}
});
} else {
handleInternal(request, callback);
}
} else {
callback.onNext();
}
}
/**
- 是否要处理给定的uri。在Interceptor之前调用。
*/
protected abstract boolean shouldHandle(@NonNull UriRequest request);
/**
- 处理uri。在Interceptor之后调用。
*/
protected abstract void handleInternal(@NonNull UriRequest request, @NonNull UriCallback callback);
}
URI的分发与降级
在外卖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。
复用问题与ServiceLoader模块
业务库之间经常需要复用代码。一些通用代码逻辑可以下沉到平台层从而复用,例如业务无关的通用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中。
// 没有注解时,需要在Application初始化时代码注册各个页面,耦合严重
register(“/home”, HomeActivity.class);
register(“/order”, OrderListActivity.class);
register(“/shop”, ShopActivity.class)
register(“/account”, MyAccountActivity.class);
register(“/address”, MyAddressActivity.class);
// …
// 增加注解后,只需要在各个Activity上通过注解配置即可
@RouterUri(path = “/shop”)
public class ShopActivity extends BaseActivity {
}
另一方面,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中的核心基础组件之一。
参考资料
- Routing - Wikipedia
- 统一资源标志符 - 维基百科
- RFC 3966 - The tel URI for Telephone Numbers
- RFC 6068 - The ‘mailto’ URI Scheme
- Intent 和 Intent 过滤器
最后
我坚信,坚持学习,每天进步一点,滴水穿石,我们离成功都很近!
以下是总结出来的字节经典面试题目,包含:计算机网络,Kotlin,数据结构与算法,Framework源码,微信小程序,NDK音视频开发,计算机网络等。
字节高级Android经典面试题和答案
《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》,点击传送门,即可获取!
以下是总结出来的字节经典面试题目,包含:计算机网络,Kotlin,数据结构与算法,Framework源码,微信小程序,NDK音视频开发,计算机网络等。
字节高级Android经典面试题和答案
[外链图片转存中…(img-isTFy3Bj-1715694651349)]
[外链图片转存中…(img-P9KYGUBo-1715694651351)]
《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》,点击传送门,即可获取!