路由预取与请求合并:让首屏数据"提前跑"且"不重复跑"

用户点击入口、路由开始解析、页面实例创建、首屏组件挂载、再触发数据请求——这条链路里存在不少空窗期。如果等详情页的 ViewModel 初始化后再请求,可能平白损失几十到几百毫秒。状态页、搜索结果页、Web 容器首屏也类似:跳转参数通常已经包含查询词、落地地址或业务标识,具备提前准备数据的条件。

路由层天然处在”用户意图”与”页面执行”之间。它既能看到跳转目标,也能看到参数,还不会侵入具体页面实现。把预取挂在路由拦截器上,可以获得几个好处:入口统一,避免每个业务页散落一套提前请求逻辑;容易做灰度、开关和埋点;可以复用路由参数校验,降低无效预取;能把预取与真正导航解耦,导航失败时可以取消或自然过期。

但预取会带来一个新问题:页面自己也会发请求,其他组件也可能同时发请求,如果没有请求合并,预取反而可能增加并发压力。所以路由预取和请求合并应该作为一条链路来看:一个解决”发得早”,一个解决”别重复发”。

在路由阶段拿到”提前量”

在实际项目中,预取不是散落在每个 Activity 里,而是放在 PrefetchRouterInterceptor 中。这个拦截器覆盖了几类高收益页面:详情页、活动/类目列表、状态页、文本搜索、Web 容器。

详情页预取会从路由参数里提取内容 ID、预览时间、实验状态等,再根据实验开关选择不同的领域接口;如果路由里携带视频预加载地址,还会提前预热视频资源。状态页预取更克制:如果页面已存在就不重复预取,否则只预取首段数据。搜索页和类目页分别交给专门的 helper,Web 容器交给通用预热工具。拦截器本身不承载复杂业务,而是做”路由识别 + 策略分发”。

data class RouteContext(
    val target: String,
    val params: Map<String, String>,
    val userKey: String?,
    val network: NetworkState,
    val routeSessionId: String
)

interface PrefetchRule {
    fun match(context: RouteContext): Boolean
    fun buildTask(context: RouteContext): PrefetchTask?
}

class PrefetchRouterInterceptor(
    private val rules: List<PrefetchRule>,
    private val scheduler: PrefetchScheduler
) : RouterInterceptor {

    override fun intercept(request: RouteRequest, chain: RouterChain): RouteResult {
        val context = request.toRouteContext()
        rules.firstOrNull { rule -> rule.match(context) }
            ?.buildTask(context)
            ?.let { task ->
                scheduler.submit(
                    task = task,
                    priority = Priority.LOW,
                    owner = context.routeSessionId
                )
            }
        return chain.proceed(request)
    }
}

预取规则:克制比激进更重要

预取不是越多越好,错误的预取会浪费流量、电量和服务端资源。规则设计要满足三个条件:跳转参数早且稳定、首屏接口占比高、用户取消概率低。

以详情页为例,规则只关心最小必要参数:

class DetailPrefetchRule : PrefetchRule {
    override fun match(context: RouteContext): Boolean {
        return context.target == "product_detail" &&
            context.params["item_id"].isNullOrBlank().not() &&
            context.network.canPrefetch
    }

    override fun buildTask(context: RouteContext): PrefetchTask {
        val itemId = context.params.getValue("item_id")
        val cacheKey = PrefetchKey(
            scene = "detail_first_screen",
            identity = itemId,
            userKey = context.userKey
        )
        return PrefetchTask(
            key = cacheKey,
            ttlMillis = 15_000,
            request = { DataSource.fetchDetailFirstScreen(itemId) }
        )
    }
}

Web 容器预取则需要额外过滤敏感参数,避免把一次性凭证或授权 URL 作为可复用缓存对象:

class WebContainerPrefetchRule : PrefetchRule {
    override fun match(context: RouteContext): Boolean {
        val url = context.params["target_url"] ?: return false
        return context.target == "web_container" &&
            UrlPolicy.isPublicCacheable(url) &&
            !UrlPolicy.containsOneTimeCredential(url)
    }
}

请求合并:让预取真正成为透明加速

预取请求、页面首屏请求、组件刷新请求可能在极短时间内命中同一个接口。如果它们各自独立发出,服务端压力和客户端队列都会增加;如果被粗暴合并,又可能把不该共享的错误或用户态数据传给其他调用方。

合并层必须同时判断 HTTP 成功、业务 code 成功、请求上下文一致、接口幂等、参数完整。我们实现了一个 ResponseJudge,把”能不能共享”变成统一规则:

class RequestMergeInterceptor(
    private val inFlight: InFlightRequestPool,
    private val responseJudge: ResponseJudge
) : NetworkInterceptor {

    override fun intercept(chain: NetworkChain): NetworkResponse {
        val request = chain.request()
        if (!request.isIdempotent || request.hasSideEffect) {
            return chain.proceed(request)
        }
        val key = request.toMergeKey()
        return inFlight.awaitOrStart(key) {
            val response = chain.proceed(request)
            if (responseJudge.canShare(response)) response
            else throw NonShareableResponse(response)
        }
    }
}

class ResponseJudge {
    fun canShare(response: NetworkResponse): Boolean {
        if (!response.httpSuccess) return false
        val body = response.parseAsEnvelopeOrNull() ?: return response.bodyIsEmpty
        return body.businessSuccess
    }
}

HTTP 成功只是第一层,业务成功才是第二层。只有两层都通过,预取请求和页面请求共享结果才是安全的。

落地页如何消费预取结果

页面数据层只需要增加一个优先读取预取结果的入口,不关心数据来自预取还是自己发起:

class DetailRepository(
    private val prefetchStore: PrefetchStore,
    private val remote: DetailRemoteDataSource
) {
    suspend fun loadFirstScreen(itemId: String, userKey: String?): DetailData {
        val key = PrefetchKey("detail_first_screen", itemId, userKey)
        prefetchStore.takeIfFresh<DetailData>(key)?.let { cached ->
            Metrics.markPrefetchHit(key)
            return cached
        }
        Metrics.markPrefetchMiss(key)
        return remote.fetchFirstScreen(itemId)
    }
}

这样页面逻辑保持一致,预取只是一个透明加速路径。预取层负责制造”更早的请求”,合并层负责吸收”后来重复出现的请求”。如果页面请求比预取更早到达网络层,预取也可以反过来复用页面请求。只要请求签名一致,谁先发起并不重要。

落地中的几个关键约束

缓存 key 必须足够完整。只用页面名作为 key 是危险的,至少要包含目标类型、核心参数、用户上下文、请求版本。宁可 miss,也不要错 hit。

TTL 要短。路由预取面对的是”马上可能使用”的数据,不是通用缓存。多数场景的 TTL 可以按秒级设计,页面消费后从预取缓存中移除,避免被后续页面误用。

页面不能依赖预取成功。预取只是加速路径,不是数据正确性的来源。页面仍要保留完整加载、错误、重试和空态处理。这样预取策略开关、灰度或异常降级时,不会改变页面功能。


PrefetchRouterInterceptor 的本质,是把路由层看到的确定性意图转化为提前启动的网络任务。它不应该替代页面数据加载,也不应该承载复杂业务判断,而是作为一个靠近入口的性能优化协调点。工程上最重要的不是把所有页面都预取,而是建立一套可控机制:规则可配置、任务可取消、结果可隔离、缓存可过期、收益可观测。预取优化的成熟标志,不是”请求越早越好”,而是”只在确定值得提前时提前,并且失败时完全不影响用户”。

深入 Android ConnectivityManager 全链路:从 NetworkCallback 实时监听到网络切换自适应架构

从 NetworkInfo 缺陷到 NetworkCapabilities 能力模型,解析 ConnectivityManager 实时网络监控与自适应切换架构。

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

同一入口存在 Native 和 H5 两种实现时,如何在路由层安全地灰度切换?本文介绍 RedirectRouterInterceptor 的通用设计,通过远程配置控制落点,配合稳定散列、参数映射、兜底策略和结构化监控,让 Native 新页面平滑上线,异常时快速回滚。

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

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

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

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