有赞移动应用如何给页面安上“任意门”

“任意门”:一行配置实现页面跳转重定向。

背景 & 痛点 & 价值

动态路由组件,处理的是 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 重定向服务的角色,避免因服务下线、功能更新、平台差异、项目重构等原因导致的功能不可用。

动态路由组件,核心就是非常简单的正则匹配和正则替换,而这个非常简单和核心代码逻辑,实现了业务场景下非常重要的路由重定向。这整套解决方案,也是有赞移动端在应用组件化、动态化的一个重要组成部分,我们也希望这个技术方案能够抛砖引玉,启发更多优秀的移动端动态化解决思路。

欢迎关注我们的公众号