深入 Android WebView 资源加载优化全链路:从 shouldInterceptRequest 资源拦截到离线包缓存策略的加载性能工程实践

去年接手一个混合开发项目,WebView 加载活动页的首屏白屏时间高达 2.8 秒。Leader 甩了一句”优化到 1 秒内”就走了。当时我的思路是:把线上资源搬到本地,用拦截 API 做分流。

WebView 加载到底慢在哪

H5 页面在 WebView 中的加载链路比 Native 长得多。用户点击到首屏渲染,中间要经历 DNS 解析、TCP 连接、SSL 握手、HTML 下载、CSS/JS/图片加载,每一步都有网络延迟。

抓了 100 次线上 Trace,发现最大瓶颈不是 HTML 本身——而是串行依赖链:HTML 下载完成后,浏览器解析 DOM 发现引用了 8 个 CSS 和 12 个 JS,再逐个发起请求。在移动弱网下,单个请求平均延迟 200ms,20 个资源叠加的理论上限就是 4 秒。

资源拦截的思路直截了当:既然资源在本地,为什么不直接从磁盘读?shouldInterceptRequest 就是干这个的。

shouldInterceptRequest 的拦截机制

WebViewClient 提供了这个回调,允许接管每次资源请求:

webView.webViewClient = object : WebViewClient() {
    override fun shouldInterceptRequest(
        view: WebView,
        request: WebResourceRequest
    ): WebResourceResponse? {
        val url = request.url.toString()
        // 尝试从离线包加载
        val local = loadFromOfflinePackage(url)
            ?: return null // 返回 null 走网络降级
        return WebResourceResponse(
            local.mimeType,
            "UTF-8",
            ByteArrayInputStream(local.bytes)
        )
    }
}

这里有两个行为需要吃透。

一是拦截发生在 DNS 解析之前。当回调返回非 null 的 WebResourceResponse 时,WebView 引擎会完全跳过网络栈,直接使用你提供的数据流。性能提升的根因就在这——网络开销被整段砍掉了。

二是拦截范围覆盖所有资源类型。CSS、JS、图片、字体、fetch/XHR 请求都会经过这个回调。换句话说,这个入口不仅能做离线加载,还能做请求监控、内容改写、Mock 数据注入。

Android 5.0 以上推荐用 shouldInterceptRequest(WebView, WebResourceRequest) 这个重载,它能拿到完整的请求头和 HTTP 方法,比旧版 API 精确得多。

离线包架构设计

整体架构拆三层:离线包管理、资源拦截器、网络降级兜底。

离线包的分发与版本管理

离线包本质上是一个 ZIP 包,包含所有前端静态资源,按版本号管理。分发走 CDN,下载后解压到应用的内部存储。

data class OfflinePackage(
    val version: Long,       // 时间戳做版本号
    val url: String,         // CDN 下载地址
    val filePath: String     // 解压后的本地路径
)

版本更新策略用了静默更新 + 增量生效:App 启动时异步检查新版本,下载解压到临时目录,下次冷启动切到新版本。这样不会阻塞首页加载,也避免了运行中替换文件导致的并发问题。

踩过一个坑:ZIP 包的 MD5 校验必须做。线上出过一次离线包下载不完整导致解压失败,白屏率飙升 15%。加了一层 MD5 校验后问题消失。

资源拦截器的路径映射

离线包解压后的文件结构要能直接映射到 URL 路径。用 URL 的 path 部分做 key:

fun loadFromOfflinePackage(url: String): LocalResource? {
    val uri = Uri.parse(url)
    // url: https://cdn.example.com/static/js/app.abc123.js
    // localPath: /offline_root/static/js/app.abc123.js
    val relativePath = uri.path ?: return null
    val localFile = File(offlineRoot, relativePath)
    if (!localFile.exists()) return null
    
    return LocalResource(
        bytes = localFile.readBytes(),
        mimeType = MimeTypeMap.getFileExtensionFromUrl(url)
            ?.let { MimeTypeMap.getSingleton().getMimeTypeFromExtension(it) }
            ?: "application/octet-stream"
    )
}

MIME 类型映射这一步容易被忽略。如果 MIME 类型传错——比如把 CSS 标成 text/html——浏览器不会把它当样式表解析,页面直接炸。不要偷懒写死 MIME 类型。

网络降级的兜底逻辑

离线包不可能 100% 命中,动态加载的图片、用户生成的资源都得走网络。需要优雅降级:

override fun shouldInterceptRequest(
    view: WebView,
    request: WebResourceRequest
): WebResourceResponse? {
    val url = request.url.toString()
    
    // 非关键资源直接放过
    if (isExcludedDomain(url)) return null
    
    // 先查离线包
    val local = loadFromOfflinePackage(url)
    if (local != null) {
        reportHit("offline")
        return local.toResponse()
    }
    
    // 没命中,走网络 + 写入缓存供下次使用
    reportHit("network")
    return null
}

返回 null 时 WebView 走正常网络请求,完全不影响页面功能。这就是离线优先、网络兜底的混合策略核心。

踩过的坑:从功能跑通到线上稳定

坑一:同步 IO 阻塞 WebView 线程

shouldInterceptRequest 的回调运行在 WebView 的请求线程上。如果在这里做同步文件 IO——尤其是 readBytes() 读取大图片——会直接卡死整个 WebView 的渲染管线。

解决方式是用内存缓存做一层缓冲:

private val cache = LruCache<String, LocalResource>(maxSize = 50 * 1024 * 1024)

fun loadFromOfflinePackage(url: String): LocalResource? {
    cache.get(url)?.let { return it }
    // 首次加载走磁盘 IO
    val resource = doLoadFromDisk(url) ?: return null
    cache.put(url, resource)
    return resource
}

LruCache 容量设到 50M,缓存 200+ 个页面后命中率稳定在 92%,IO 次数从每页 50+ 降到个位数。

坑二:WebView 对资源并发数的隐形限制

Chromium 内核对同一域名的并发请求上限是 6 个。当拦截器返回大量本地资源时,这个并发度限制仍然生效——内核不知道你的资源来自磁盘,仍在按网络资源的调度逻辑排队。

在资源数超过 20 的页面上这个问题很明显:资源都在磁盘上,加载却有可见的”逐个出现”感。

解决思路是在构建离线包时做资源合并:多个小 CSS 合并成一个大文件,图标用 SVG Sprite 或 inline base64。这一步在前端构建流水线完成,客户端只负责加载。

坑三:Cookie 和认证信息的同步

shouldInterceptRequest 返回自定义响应时,WebView 不会自动携带该 URL 的 Cookie。如果静态资源放在需要鉴权的域名下,离线包拦截会导致权限失效。

我的做法是:拦截层判断域名类型,鉴权域名直接放行走网络,让 WebView 自己处理 Cookie;非鉴权 CDN 域走离线。逻辑很简单,但线上跑了半年没出过问题。

效果与取舍

优化上线后,首屏加载 P50 从 2.8s 降到 0.6s,P90 从 4.2s 降到 1.1s。4G 弱网下的数据。

回头看有不满意的地方:离线包占用 30M 存储,对低端机不够友好。如果重做,初始化时只下载核心资源(首屏必需的 CSS 和框架 JS),其他资源延迟到首次使用时按需加载。

增量更新还是全量替换?全量替换实现简单,不会出现文件版本不一致的问题,代价是每次更新都要下载完整包。我们的场景是包大小 6M、月更 2 次,全量替换完全够用。如果页面资源变动频繁,建议用 bsdiff 做增量差分,客户端按 patch 合并,能省 70% 以上的下载流量。