“任意门”:一行配置实现页面跳转重定向。
背景 & 痛点 & 价值
动态路由组件,处理的是 App 中最最常见的一种行为的问题,那就是:跳转。
随着 App 技术栈的扩展,从原本最最简单的原生到原生的跳转,扩展到目前同一个 App 中包含原生页面、H5 页面、Weex 页面、Flutter 页面之间的跳转。
随之而来的问题就是:随着 App 的版本迭代,很多原本原生实现的页面,需要通过新的 H5 或者 Weex 页面进行升级/降级。而这些原本都是硬编码的跳转逻辑,可能需要随着版本不停改动。总结下来,现有的,各个技术栈隔离的页面跳转逻辑面临的直接问题有:
- 跳转逻辑跟着版本走,无法统一进行改动
- 跨技术栈跳转的实现成本比较高,必须在桥接模块中进行特殊适配
- 在一些 H5 需要使用专门 WebView 页面打开的场景下,很难去适配,也必须通过各个 Web 跳转的拦截做特殊处理
为了解决以上硬编码以及灵活性差的问题,我们决定梳理现有的各技术栈跳转逻辑,将这些跳转整合,能够满足动态性、可配置的需求。
得益于项目中原有的路由跳转组件,各种页面之间的页面都可以通过 URL 的方式进行路由,于是我们基于 URL 跳转,开发了一套动态路由组件,它完成的工作有 :
- 承担 App 内所有跳转逻辑
- 通过配置中心组件,支持获取/配置路由替换规则
- 匹配所有的路由跳转规则,命中规则的,替换成新的目标路由地址
- 将实际跳转目标地址传递给路由组件执行实际的跳转行为
实现方案
路由拦截+替换
微商城客户端目前已经有一套稳固的组件化实现方案,组件之间的页面跳转通过路由的方式进行解耦,这是一种比较常见的方式。
在微商城项目中,负责实现的路由组件为 ZanURLRouter
,它的职责很简单:
- 启动时注册路由和页面
- 找寻正确的页面进行跳转
在不影响外部接口的前提下,我们在目标路由解析这一步,引入了动态路由
对于移动端的路由重定向,实际上就是将一个路由转换为另一个路由,如:
youzan://orderlist?type=1&status=2
转换为:
wsc://orderlist/v2?type=1&status=2
跳转规则配置
路由的拦截和替换中的一个关键节点就是“配置”,我们需要一个路由规则列表来记录和下发匹配规则。为了方便下发路由规则表,我们将这份配置表存放在有赞移动配置中心,根据客户端的版本进行区分,动态地下发给不同版本的客户端。
一条路由规则,分为一个 Key 和对应的 Value,Key 为匹配方式,使用正则表达式进行匹配,Value 为替换方式,使用 JSON 格式定义。
实际代码实现中,我们将“路由规则”和“路由替换行为”分别抽象成实体类和接口方法。
抽象实体类
关于替换路由跳转的规则,我们可以这样配置:
Key: ^youzan://orderlist\?type=(\d+)&status=(\d+)$
Value: {"template": "wsc://orderlist/v2?type=$1&status=$2"}
即:一条匹配规则 + 一条替换模板。我们将之抽象为一个实体类, Rule
:
class Rule {
// url 匹配规则(正则表达式)
String pattern;
// url 匹配规则(正则表达式)
String template;
}
抽象接口
有了规则配置之后,就需要对动态路由的行为进行抽象,核心就是初始化规则、匹配规则和替换路由三个方法:
// 注册替换规则
fun initWithPattern(Rule rule)
// 校验是否命中已经注册的路由配置的 pattern 正则
fun testWithRoute(String routeUrl): Boolean
// 获取替换后的跳转地址
fun appliedWithRoute(String routeUrl): String
动态路由器会在应用启动阶段拉取正确的规则表,解析并记录下来:
在 ZanURLRouter
解析目标路由的时候,对每一个规则进行匹配测试,命中则应用匹配的规则,返回替换后的路由,再继续接下来的工作。
路由替换
实体类、接口类都抽象完成之后,就是动态路由的核心实现了,这里依赖到一个的核心工具就是:正则表达式。这里用到正则的场景有两个: - 正则验证是否命中规则 - 正则替换url文本
在 Android 和 iOS 开发中,字符串正则相关的 API 都是自带的,开箱即用:
/* ------------ Android ------------ */
// 正则匹配校验方法
Pattern.matcher(String text)
// 正则匹配校验方法
Regex.replace(String input, String replacement)
/* ------------ iOS ------------ */
(NSString *)stringByReplacingMatchesInString:(NSString *)string options:(NSMatchingOptions)options range:(NSRange)range withTemplate:(NSString *)templ;
疑难问题:参数处理
大部分情况下,跳转本身都是带参数的,那么动态替换跳转的 URL 之后,参数的获取就成了一个问题,尤其是原生和其他页面页面的跳转。
我们主要以 Android 为例,Android 原生跳转都是通过一个关键类:Intent 来实现参数的存取。这里需要注意的是,由于 Intent 传值存在多种复杂的数据接口,包括 Parcelable 这种复杂参数的场景,由于降级之后都是以 URL 的形式传值,所以我们目前约定动态路由的参数只支持基本数据类型,复杂参数类型的需要接入方来做兼容。
参数处理我们分两个典型的场景来讨论: - 原生跳转 H5 参数传递 - H5 跳转原生的参数传递
原生跳转H5
这里的方式主要是将 Intent 中的基本数值类型参数取出来,拼接成带参数的 URL 来实现将 Intent 里面的参数传递给 H5,主要实现代码如下:
fun appendBundleParams(strBuilder: StringBuilder, bundle: Bundle) {
val ketSet = bundle.keySet()
for (key in ketSet) {
bundle[key]?.let { value ->
when (value) {
is Bundle -> appendBundleParams(strBuilder, value)
is String, is Int, is Long, is Double, is Float, is Char, is Boolean, is Short
-> {
if (strBuilder.isNotEmpty()) {
strBuilder.append("&")
}
strBuilder.append("$key=$value")
}
else -> {
// do nothing
}
}
}
}
}
H5跳转原生
同理的,H5 跳转原生做的就是将 URL 中携带的参数塞到 Intent 中来进行。
这里比较关键的一个问题是:Intent 的取值都是带类型的,而 URL 的参数都是字符串。我们目前解决方案也很简单,就是封装 Intent 的取值方法,由于目前有赞 Android 主要使用 Kotlin 来开发,可以使用 Kotlin 的扩展函数特性来实现(Java 可以使用工具类的方式):
fun Intent.getIntFromRouter(key: String, defaultValue: Int): Int {
val extras = this.extras;
if (extras == null || !this.hasExtra(key)) {
return defaultValue
}
return extras.get(key) as? Int ?: (this.getStringExtra(key)?.toInt() ?: defaultValue)
}
碰到的坑:UrlEncode
在匹配和替换 URL 规则的场景中,我们经常会碰到这么一种情况,URL 是被 UrlEncode 过的。由于字符串的正则匹配和正则替换是不会判断字符串是否被 UrlEncode 过,所以这里的逻辑需要由路由组件来实现。
UrlEncode 字符串的正则匹配逻辑实现比较简单,即直接将字符串 Decode 之后进行匹配。
比较复杂的是 UrlEncode 字符串的正则替换,有些情况下,路由中的url是必须进行 UrlEncode 的,如果直接 Decode 进行替换,那么可能会导致实际跳转的目标 URL 被错误地截断,导致无法跳转,所以这里的替换必须保留 UrlEncode 的字符。
我们的解决思路是:记录 URLEncode 前后被 encode 字符的下标,然后再手动实现 replace 方法去挨个替换字符串中的字符,核心代码如下:
private fun getEncodeCharMap(url: String, encodeUrl: String): Map<Int, IntRange> {
if (Uri.decode(encodeUrl) != url) {
return mapOf()
}
val urlChars = url.toCharArray()
val urlEncodeChars = encodeUrl.toCharArray()
var i = 0
var j = 0
val encodeMap = mutableMapOf<Int, IntRange>()
while (i < urlChars.size && j < urlEncodeChars.size) {
// text: [www:] => [www%23]
// length: [0123] => [012345]
if (urlChars[i] != urlEncodeChars[j]) {
val s = Uri.encode(urlChars[i].toString())
val range = IntRange(j, j + s.length - 1)
encodeMap[i] = range
j += s.length
} else {
j++
}
i++
}
return encodeMap
}
实际应用案例
应用中心
微商城App应用中心,应该是应用动态路由的最佳场景,应用中心存在大量跳转的场景。
先来说下使用动态路由的背景,应用中心中应用列表都是由服务端统一下发的,后端为每个应用配置的跳转地址是统一的,而 Android 和 iOS 本地路由配置的 URL 是不一致的,如果直接下发配置的话,会存在有一端无法跳转的问题。以店铺管理应用跳转为例:
- iOS中店铺管理的路由 URL:wsc://shop/management
- Android 中的路由URL:wsc://team/management
- 服务端下发的URL:wsc://team/management
那么解决同一套配置跳转不同 URL 的这个问题,就交给动态路由来完成了,我只需要在iOS的动态路由添加一个规则,将 wsc://shop/management 动态替换成 wsc://team/management 就可以搞定!
订单项目
在微商城客户端的订单模块重构项目中,考虑到订单是使用频次很高的核心场景之一,且代码历史较久,所以新的模块上线后与旧订单列表模块共存,直到灰度完全结束。
由于微商城已经是组件化拆分,业务组件之间的跳转使用路由完成,我们在设计灰度方案时,利用动态路由来实时进行目标路由的映射:
具体可见 《 微商城订单模块重构实践》一文。
总结
“上线只是开始”,随着业务迭代,历史业务也越来越多,为了保证不同平台版本的用户能够平滑过渡到新的功能上去,动态路由组件扮演了一个客户端的 URL 重定向服务的角色,避免因服务下线、功能更新、平台差异、项目重构等原因导致的功能不可用。
动态路由组件,核心就是非常简单的正则匹配和正则替换,而这个非常简单和核心代码逻辑,实现了业务场景下非常重要的路由重定向。这整套解决方案,也是有赞移动端在应用组件化、动态化的一个重要组成部分,我们也希望这个技术方案能够抛砖引玉,启发更多优秀的移动端动态化解决思路。