Native/H5 路由灰度切换:用 RedirectRouterInterceptor 实现零风险页面迁移

移动端页面通常会经历多个实现阶段。早期验证需求时,H5 可以快速上线;当页面访问量变大、交互复杂或需要更多端能力时,Native 改造能提供更好的性能。但 Native 和 H5 往往不会一次性完成切换,而是需要长期共存。一个活动入口可能先由 H5 承接,后续逐步迁移到 Native;一个新 Native 页面上线后,也需要先放少量用户灰度,观察崩溃率、加载耗时、转化指标,再逐步扩大范围。

如果这些切换逻辑散落在各个入口处,维护成本会很高。每个页面都写一套判断,会导致规则重复、口径不一致、回滚不及时。更好的方式是在路由链路里放置统一拦截器:所有路由请求先进入标准解析流程,再由 RedirectRouterInterceptor 查询配置并决定是否改写目标。

路由拦截器作为业务迁移网关

在实际项目中,路由治理集中在 business/router 目录下。RouterHub.java 负责把 App、Module、Detail、Home、User、WebContainer 等模块的 host/path 统一收口;BusinessHandlerImpl.kt 负责处理国家化 scheme、H5 channel 判断、URL 补全、登录态参数附加;RedirectRouterInterceptor.java 则是 Native/H5 灰度切换的关键落点。

这个拦截器里有几类非常有代表性的迁移场景:旧首页路由重定向到新首页;旧详情页路由重定向到新详情页;类目页、活动页根据远程配置决定走 Native 还是 Web 容器;新详情页在实验开关关闭时还能回退到旧 H5 链路。它不是在页面内部做选择,而是在路由阶段完成落点决策,这样所有入口——push、短链、H5 回跳、内容卡片点击、活动入口——都能走同一套规则。

更重要的是,它保留了 originUrl、Bundle 参数、afterAction,并能继续交给 RouterManager.navigate。这让灰度切换同时具备三个能力:URL 参数不丢、Native/H5 可双向回退、远程配置能即时止损。

核心设计:从拦截到执行

路由链路可以抽象为多个拦截器串行处理。RedirectRouterInterceptor 只负责一件事:在路由真正执行前,根据规则判断是否需要改写目标。

data class RouteRequest(
    val source: String,
    val path: String,
    val params: Map<String, String>,
    val extras: Map<String, Any> = emptyMap()
)

data class RouteTarget(
    val type: TargetType,  // NATIVE 或 H5
    val path: String,
    val params: Map<String, String>
)

远程配置用规则列表表达,每条规则包含匹配条件和重定向目标:

{
  "rules": [
    {
      "id": "record_detail_native_gray",
      "matchPath": "/record/detail",
      "enabled": true,
      "target": "native",
      "targetPath": "/native/record/detail",
      "grayPercent": 20,
      "minAppVersion": "5.0.0",
      "fallback": "h5"
    }
  ]
}

灰度命中使用稳定散列,而不是每次随机。否则同一个用户可能这次进 Native、下次进 H5,体验和问题排查都会变差:

class StableGrayMatcher {
    fun hit(rule: GrayRule, request: RouteRequest): Boolean {
        val key = request.params["userKey"] ?: request.source
        val bucket = hash("${rule.id}:$key") % 100
        return bucket < rule.grayPercent
    }
}

参数映射是另一个核心点。Native 和 H5 的参数要求不同,不能简单把原始 query 全量透传。可以为每个可灰度路由定义参数 schema,只允许白名单参数通过:

class RouteParamMapper {
    fun map(request: RouteRequest, rule: GrayRule): RouteTarget {
        val allowed = rule.allowedParams
        val mapped = allowed.associateWith { key ->
            request.params[key] ?: rule.defaultParams[key].orEmpty()
        }
        return RouteTarget(type = rule.targetType, path = rule.targetPath, params = mapped)
    }
}

兜底必须避免递归循环

当 Native 打开失败时,执行器应根据规则兜底到 H5 或原始路由。但兜底容易产生循环:Native 失败后回 H5,H5 规则又命中回 Native。可以在 request extras 中记录 redirect depth 或 consumed rule id,超过阈值直接走默认落点。

class RouteExecutor(
    private val nativeLauncher: NativeLauncher,
    private val h5Launcher: H5Launcher
) {
    fun open(target: RouteTarget, fallback: RouteTarget?): RouteResult {
        if (target.redirectDepth > MAX_REDIRECT) {
            return fallback?.let { open(it, null) } ?: RouteResult.Failed
        }
        val result = when (target.type) {
            TargetType.NATIVE -> nativeLauncher.open(target)
            TargetType.H5 -> h5Launcher.open(target)
        }
        if (result.success) return result
        return fallback?.let { open(it.copy(redirectDepth = target.redirectDepth + 1), null) }
            ?: result
    }
}

三端能力对等后的职责变化

还有一个更深的背景:业务不是单端形态,而是三端长期并行且能力逐步对等。Android、iOS、Web/H5 都能承接同一类页面能力,都能接入统一配置、统一埋点、统一登录态。能力对等以后,路由层的职责就发生了变化:它不再只是”打开一个 Activity 或一个 WebView”,而是在多个等价承载端之间做动态调度。

这也是为什么灰度逻辑不能散落在页面里。路由层变成”端能力选择器”,它需要理解页面能力编号、端能力版本、远程开关、实验分组、原始 URL 和结构化参数之间的映射关系。业务入口只表达”我要打开某个能力”,而不关心最终由哪个端实现承接。

落地中的关键约束

远程配置要有本地默认值。首次安装、弱网启动、配置服务异常时,路由层不能依赖实时拉取。默认值通常应选择最稳定落点,并支持强制关闭灰度。

参数白名单非常重要。不要把原始参数无脑透传给 H5,也不要让 H5 专用参数污染 Native。对于跳转 URL、回调地址、脚本片段等高风险字段,应进行协议、域名、长度和编码校验。

兜底不等于静默吞错。用户可以被平滑带到稳定页面,但监控必须记录原始失败。否则灰度看似正常,实际大量请求已经在兜底,问题会被掩盖。


Native/H5 路由灰度切换的核心,是把发布选择放到统一路由层,而不是散落在各个业务入口。一个可靠的方案不仅要能”切”,还要能”稳”:稳定散列保证用户体验一致,参数 schema 保证数据边界清晰,兜底策略保证异常时可用,结构化监控保证灰度过程可评估。只有这些能力同时存在,Native/H5 共存才不会变成长期混乱,而会成为可治理的发布机制。

启动框架分阶段初始化:background/activity 两类 StartType 的设计与实践

App 启动阶段承载大量初始化逻辑,如果全部堆在 Application 中,冷启动耗时不可控。本文介绍一种分阶段初始化框架,将任务按 background 和 activity 两类 StartType 拆分,配合依赖声明、线程调度、异常降级和耗时监控,让初始化在正确时间完成必要工作。

图片加载统一门面:用 ImageUrlProcessor 动态裁剪与门面模式告别混乱的图片代码

图片加载是移动端体验的基础能力,但如果每个页面都直接调用底层库,URL 拼接规则、尺寸参数、预加载逻辑就会散落全项目。本文介绍一种图片加载统一门面设计,通过 ImageUrlProcessor 集中处理动态裁剪,配合门面接口、预加载调度和监控,让业务只关心展示意图。

网络诊断体系设计:让每一次失败都可追溯、可解释

移动端网络问题最难处理的地方不是失败本身,而是失败之后很难还原现场。本文拆解一种通用的网络诊断体系设计:如何定义诊断分层、采集请求上下文、编排诊断任务、输出可读报告,同时控制隐私和性能成本。

自研缓存策略体系:用 FirstCache/FirstNet/OnlyNet/Timeout 终结混乱的缓存代码

缓存策略看起来只是"先读缓存还是先请求网络"的选择,实际落地却会影响页面速度、弱网体验、数据一致性和代码复杂度。本文介绍一种自研缓存策略体系,用统一策略枚举、CacheManager 读写和 Flow 数据流封装,让页面只订阅状态,不拼装缓存细节。