路由预取与请求合并:让首屏数据"提前跑"且"不重复跑"
用户点击入口、路由开始解析、页面实例创建、首屏组件挂载、再触发数据请求——这条链路里存在不少空窗期。如果等详情页的 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 的本质,是把路由层看到的确定性意图转化为提前启动的网络任务。它不应该替代页面数据加载,也不应该承载复杂业务判断,而是作为一个靠近入口的性能优化协调点。工程上最重要的不是把所有页面都预取,而是建立一套可控机制:规则可配置、任务可取消、结果可隔离、缓存可过期、收益可观测。预取优化的成熟标志,不是”请求越早越好”,而是”只在确定值得提前时提前,并且失败时完全不影响用户”。