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

2020 年 8 月 25 日

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

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


背景 & 痛点 & 价值


动态路由组件,处理的是 App 中最最常见的一种行为的问题,那就是:跳转。


随着 App 技术栈的扩展,从原本最最简单的原生到原生的跳转,扩展到目前同一个 App 中包含原生页面、H5 页面、Weex 页面、Flutter 页面之间的跳转。



随之而来的问题就是:随着 App 的版本迭代,很多原本原生实现的页面,需要通过新的 H5 或者 Weex 页面进行升级/降级。而这些原本都是硬编码的跳转逻辑,可能需要随着版本不停改动。总结下来,现有的,各个技术栈隔离的页面跳转逻辑面临的直接问题有:


  • 跳转逻辑跟着版本走,无法统一进行改动

  • 跨技术栈跳转的实现成本比较高,必须在桥接模块中进行特殊适配

  • 在一些 H5 需要使用专门 WebView 页面打开的场景下,很难去适配,也必须通过各个 Web 跳转的拦截做特殊处理


为了解决以上硬编码以及灵活性差的问题,我们决定梳理现有的各技术栈跳转逻辑,将这些跳转整合,能够满足动态性、可配置的需求。


得益于项目中原有的路由跳转组件,各种页面之间的页面都可以通过 URL 的方式进行路由,于是我们基于 URL 跳转,开发了一套动态路由组件,它完成的工作有 :


  • 承担 App 内所有跳转逻辑

  • 通过配置中心组件,支持获取/配置路由替换规则

  • 匹配所有的路由跳转规则,命中规则的,替换成新的目标路由地址

  • 将实际跳转目标地址传递给路由组件执行实际的跳转行为



一、实现方案


1.1 路由拦截+替换


微商城客户端目前已经有一套稳固的组件化实现方案,组件之间的页面跳转通过路由的方式进行解耦,这是一种比较常见的方式。


在微商城项目中,负责实现的路由组件为 ZanURLRouter ,它的职责很简单:


  • 启动时注册路由和页面

  • 找寻正确的页面进行跳转



在不影响外部接口的前提下,我们在目标路由解析这一步,引入了动态路由



对于移动端的路由重定向,实际上就是将一个路由转换为另一个路由,如:


youzan://orderlist?type=1&status=2


转换为:


wsc://orderlist/v2?type=1&status=2


1.2 跳转规则配置


路由的拦截和替换中的一个关键节点就是“配置”,我们需要一个路由规则列表来记录和下发匹配规则。为了方便下发路由规则表,我们将这份配置表存放在有赞移动配置中心,根据客户端的版本进行区分,动态地下发给不同版本的客户端。


一条路由规则,分为一个 Key 和对应的 Value,Key 为匹配方式,使用正则表达式进行匹配,Value 为替换方式,使用 JSON 格式定义。


实际代码实现中,我们将“路由规则”和“路由替换行为”分别抽象成实体类和接口方法。


1.2.1 抽象实体类


关于替换路由跳转的规则,我们可以这样配置:


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;}
复制代码


1.2.2 抽象接口


有了规则配置之后,就需要对动态路由的行为进行抽象,核心就是初始化规则、匹配规则和替换路由三个方法:


// 注册替换规则fun initWithPattern(Rule rule)// 校验是否命中已经注册的路由配置的 pattern 正则fun testWithRoute(String routeUrl): Boolean// 获取替换后的跳转地址fun appliedWithRoute(String routeUrl): String
复制代码


动态路由器会在应用启动阶段拉取正确的规则表,解析并记录下来:



ZanURLRouter 解析目标路由的时候,对每一个规则进行匹配测试,命中则应用匹配的规则,返回替换后的路由,再继续接下来的工作。


1.3 路由替换


实体类、接口类都抽象完成之后,就是动态路由的核心实现了,这里依赖到一个的核心工具就是:正则表达式。这里用到正则的场景有两个:


  • 正则验证是否命中规则

  • 正则替换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;
复制代码


1.4 疑难问题:参数处理


大部分情况下,跳转本身都是带参数的,那么动态替换跳转的 URL 之后,参数的获取就成了一个问题,尤其是原生和其他页面页面的跳转。


我们主要以 Android 为例,Android 原生跳转都是通过一个关键类:Intent 来实现参数的存取。这里需要注意的是,由于 Intent 传值存在多种复杂的数据接口,包括 Parcelable 这种复杂参数的场景,由于降级之后都是以 URL 的形式传值,所以我们目前约定动态路由的参数只支持基本数据类型,复杂参数类型的需要接入方来做兼容。


参数处理我们分两个典型的场景来讨论:


  • 原生跳转 H5 参数传递

  • H5 跳转原生的参数传递


1.4.1 原生跳转 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                }            }        }    }}
复制代码


1.4.2 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)}
复制代码


1.5 碰到的坑: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}
复制代码


二、实际应用案例


2.1 应用中心


微商城 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 就可以搞定!


2.2 订单项目


在微商城客户端的订单模块重构项目中,考虑到订单是使用频次很高的核心场景之一,且代码历史较久,所以新的模块上线后与旧订单列表模块共存,直到灰度完全结束。


由于微商城已经是组件化拆分,业务组件之间的跳转使用路由完成,我们在设计灰度方案时,利用动态路由来实时进行目标路由的映射:



具体可见 《 微商城订单模块重构实践》一文。


三、总结


“上线只是开始”,随着业务迭代,历史业务也越来越多,为了保证不同平台版本的用户能够平滑过渡到新的功能上去,动态路由组件扮演了一个客户端的 URL 重定向服务的角色,避免因服务下线、功能更新、平台差异、项目重构等原因导致的功能不可用。


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


本文转载自公众号有赞 coder(ID:youzan_coder)。


原文链接


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


2020 年 8 月 25 日 14:05706

评论

发布
暂无评论
发现更多内容

5万字、97 张图总结操作系统核心知识点

cxuan

操作系统 计算机

昨天、今天、明天

escray

Malagu 框架的认证与授权【借鉴 Spring Security 和 aws iam 的设计】

木香丘

身份认证 权限系统

实战技巧,Vue原来还可以这样写

前端有的玩

Java Vue 前端 技巧

Docker网络学习第一篇:Linux虚拟网络

Lazy

Docker Linux 网络

30岁+程序员职场攻略:找到自己的“职业锚”乘风破浪

华为云开发者社区

程序员 AI 开发者 职场 程序员成长

Malagu 框架开发 React 应用新体验

木香丘

Serverless React 微前端 微应用 Malagu

架构师训练营作业 -- Week 6

吴炳华

极客大学架构师训练营

一张PDF了解JDK10 GC调优秘籍-附PDF下载

程序那些事

Java jdk JVM GC JDK10

Web经典B/S快速开发框架,强大后台+简洁UI一体化开发工具

力软.net/java开发平台

C# .net 软件开发 web开发

【进收藏夹吃灰系列】——Java基础快速扫盲

Noneplus

Java

用Report Builder 创建报表

JackWangGeek

SharePoint

配置 SharePoint Server for Reporting Services

JackWangGeek

SharePoint

关于如何判断一个list是否为空的思考

Leetao

Python Python基础知识 列表

纯CSS实现自定义单选框和复选框

爱嘤嘤嘤斯坦

CSS Java 编程语言 标签

6种快速统计代码执行时间的方法,真香!

王磊

Java

CAP原理

李白

Java8——方法引用

Java旅途

java8 方法引用

猿灯塔:spring Boot Starter开发及源码刨析(五)

猿灯塔

spring 猿灯塔

【面试题系列】——Java基础

Noneplus

Java

开发框架文档体系化的思考

vivo互联网技术

框架开发

Docker网络学习第二篇-认识iptables

Lazy

Docker Linux 网络

MySQL性能优化(一):MySQL架构与核心问题

xcbeyond

MySQL MySQL性能优化

Doris 临时失效处理过程

石印掌纹

从0开始设计Flutter独立APP | 第三篇: 一劳永逸解决全局BuildContext问题

渔子长

flutter 前端 跨平台 React

第六周总结

石印掌纹

文档写作利器:Markdown

xcbeyond

markdown

秒懂云通信:通信圈黑话大盘点

巨侠说

云通信 通信云

那些年,我在阿里当数据开发

DeeperMan

大数据

“Python的单例模式有四种写法,你知道么?”——孔乙己

Young先生

Python 设计模式 单例模式

SQL Server 报表服务

JackWangGeek

SharePoint

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