WebView代理方案实现:拦截请求、注入资源与离线包架构

Android WebView深度探索系列 · 第3/5篇

从内核原理到工程实战,全面掌握WebView开发

第1篇:[WebView内核原理:从Chromium到System WebView的架构全景](#WebView内核原理:从Chromium到System WebView的架构全景 "#")

第2篇:WebView白屏检测与解决方案:从原因分析到工程化监控

⏳ 第3篇:WebView代理方案实现:拦截请求、注入资源与离线包架构

⏳ 第4篇:WebView与原生JS交互:JSBridge设计模式与安全实践

⏳ 第5篇:WebView性能优化与稳定性治理:预热、复用池与崩溃防护

我第一次意识到 shouldInterceptRequest 有多强,是在做一个 H5 秒开需求的时候。

那时候业务要求 H5 首屏要在 500ms 内出现,否则就得用纯 Native 实现。网络请求路径长、资源大,不动手脚根本做不到。产品经理指着竞品说:「你看人家的 H5,打开根本没有等待感。」

后来拆包研究竞品,发现它走的就是离线包方案------把 H5 资源预下载到本地,WebView 发出请求时拦截掉,直接返回本地文件。理论上可以把网络耗时从几百毫秒压到几乎为零。

但真正落地的时候,坑比想象的多得多。这篇就把我踩过的坑和最终跑通的方案完整写出来。

shouldInterceptRequest 的工作原理

先把基础搞清楚,再谈实现。

WebViewClient.shouldInterceptRequest 是 WebView 提供的请求拦截钩子。每当 WebView 发出一个网络请求,系统就会先问你:「这个请求你要自己处理吗?」

如果你返回 null,WebView 走正常网络;如果你返回一个 WebResourceResponse,WebView 就直接用这个响应,不走网络了。

有几个关键细节必须知道:

线程:非主线程调用。 这个方法在子线程执行,不能在里面做 UI 操作,但可以做同步 IO。

覆盖范围:几乎所有请求。 包括主文档、CSS、JS、图片、Ajax(部分)------ 注意是「部分」,这个后面细说。

API 21 前后有区别。 API 21 开始有带请求头的重载版本,支持读取原始请求的 Headers。之前的版本只有 URL。

接口签名如下:

java 复制代码
// API 21+ 推荐使用这个版本
@Override
public WebResourceResponse
    shouldInterceptRequest(
        WebView view,
        WebResourceRequest request
    ) {
    val url = request.url.toString()
    val localRes = tryGetLocal(url)
    return localRes
        ?: super.shouldInterceptRequest(
            view, request
        )
}

Ajax 请求能不能拦截?

这是最常见的误解。很多人以为所有请求都能拦到,结果 Ajax 跑丢了。

实际情况是:shouldInterceptRequest 只能拦截同源请求和跨域的普通资源请求。对于带 application/json 的 XHR/Fetch 请求,能不能拦到取决于 Android 版本和 Chromium 版本,行为不稳定。

更可靠的方案是用 JSBridge 层统一接管 H5 的 Ajax,把网络请求转给 Native 去发,这部分留到第4篇详细讲。

离线包方案设计

明白了原理,来看完整的离线包架构。

核心思路很简单:把 H5 的静态资源(HTML/JS/CSS/图片)打包成 zip,提前下载到客户端。用户打开 H5 时,WebView 请求这些资源,我们拦截后从本地读取,完全不走网络。

但工程化落地要考虑的东西多了:

• 包怎么管理?多个业务方的包互不干扰

• 版本怎么控制?本地旧包和服务端新包如何对比

• 增量更新?每次全量下载几 MB 太浪费

• 包的安全性?能不能被篡改

离线包的目录结构

我们线上跑的结构如下,每个业务方有独立命名空间:

bash 复制代码
/data/data/com.example/files/offline/
├── manifest.json    # 全局包清单
├── business_a/
│   ├── meta.json    # 包元数据
│   └── assets/
│       ├── index.html
│       ├── main.js
│       └── style.css
└── business_b/
    ├── meta.json
    └── assets/
        └── ...

meta.json 里包含版本号、包哈希、资源映射表:

json 复制代码
{
  "bizId": "business_a",
  "version": "1.2.3",
  "hash": "sha256:abc123...",
  "urlPrefix":
    "https://h5.example.com/a/",
  "resources": {
    "index.html":
      "sha256:xxx",
    "main.js":
      "sha256:yyy"
  }
}

版本管理与增量更新

全量更新思路简单,但每次下载完整包太浪费流量。线上用的是 bsdiff/bspatch 方案做增量:服务端对比新旧版本生成 patch 包,客户端下载 patch 后在本地合并。

版本对比的逻辑在 App 启动时触发(或首次打开 WebView 时):

scss 复制代码
suspend fun checkAndUpdate(
    bizId: String
) {
  val local = getLocalMeta(bizId)
  val remote = fetchRemoteMeta(bizId)

  if (remote.version == local?.version)
    return // 无需更新

  val hasPatch = remote
    .hasPatchFrom(local?.version)

  if (hasPatch) {
    downloadAndApplyPatch(
        bizId, local!!.version,
        remote.version
    )
  } else {
    downloadFullPack(
        bizId, remote.version
    )
  }
  verifyAndInstall(bizId)
}

签名校验,别省

这个我见过不少团队省掉,理由是「内网分发比较安全」。省了之后踩的坑是:离线包在某些 ROM 上解压后文件被系统安全软件「修复」了(是的,真有这事),导致 JS 执行报错。

每个资源文件下载后都要做 SHA256 校验,和 meta.json 里的 hash 对比。校验失败则回退到网络请求,同时上报到监控系统:

kotlin 复制代码
fun verifyFile(
    file: File,
    expectedHash: String
): Boolean {
  val actual = file
    .sha256Hex()
  if (actual != expectedHash) {
    reportIntegrityError(
        file.name, actual,
        expectedHash
    )
    return false
  }
  return true
}

拦截逻辑的核心实现

包管理好了,接下来是拦截逻辑。核心问题是:怎么从一个 URL 映射到本地文件?

思路是根据 URL 的 host+path 前缀匹配到对应业务包,再从 meta.json 的资源映射表里查找具体文件:

kotlin 复制代码
fun intercept(
    request: WebResourceRequest
): WebResourceResponse? {
  val url = request.url.toString()

  // 找到匹配的业务包
  val pack = findMatchingPack(url)
      ?: return null

  // URL转相对路径
  val relPath = url
    .removePrefix(
        pack.meta.urlPrefix
    )

  val localFile =
    pack.getFile(relPath)
    ?: return null

  if (!verifyFile(
      localFile,
      pack.meta.resources[relPath]!!
  )) return null

  return buildResponse(
      localFile, url
  )
}

构建 WebResourceResponse 时注意几个细节:

kotlin 复制代码
fun buildResponse(
    file: File,
    url: String
): WebResourceResponse {
  val mimeType =
    getMimeType(file.name)
  val headers = mapOf(
    // 关键!允许跨域
    "Access-Control-Allow-Origin"
        to "*",
    "Cache-Control"
        to "no-cache"
  )
  return WebResourceResponse(
    mimeType,
    "utf-8",
    200,
    "OK",
    headers,
    file.inputStream()
  )
}

重要:Access-Control-Allow-Origin 如果 H5 内有跨域资源请求(比如从 CDN 加载字体),拦截后本地响应如果不带 CORS 头,Chromium 会拒绝。一定要在本地响应里加上 Access-Control-Allow-Origin: *

资源注入:不只是拦截

shouldInterceptRequest 除了拦截替换,还可以做资源注入------让 H5 在运行时获得额外的 Native 能力。

注入公共基础库

一个常见场景:多个 H5 业务都需要引入公司的基础 JS SDK(比如埋点、鉴权、JSBridge 初始化)。与其每个包都打进去,不如在 Native 侧统一注入,各业务包里只需要 <script src="//common/sdk.js">,Native 拦截后返回 App Assets 里的 sdk.js。

这样有个好处:SDK 升级不需要各业务重新打包,Native 这边改一下 Assets 文件就搞定了。

kotlin 复制代码
// 拦截公共资源请求
val COMMON_PREFIX =
  "https://h5common.example.com/"

if (url.startsWith(COMMON_PREFIX)) {
  val assetPath = "h5_common/" +
    url.removePrefix(COMMON_PREFIX)
  val stream = context.assets
    .open(assetPath)
  return WebResourceResponse(
    "text/javascript",
    "utf-8",
    stream
  )
}

图片代理:复用 Native 缓存

这个是个容易被忽视的优化点。

H5 里的图片,很多时候在 Native 层已经加载过了(同一张商品图)。如果 WebView 再走一次网络,明显浪费。可以在拦截层检查 Native 图片缓存(Glide/Coil 的磁盘缓存),命中直接返回。

kotlin 复制代码
fun tryImageCache(
    url: String
): WebResourceResponse? {
  if (!isImageUrl(url))
    return null

  val cacheFile =
    GlideUtils.getDiskCache(url)
    ?: return null

  val mimeType =
    guessMimeFromUrl(url)
  return WebResourceResponse(
    mimeType,
    null,
    cacheFile.inputStream()
  )
}

网络代理:统一请求的控制层

除了离线资源替换,还有一类需求是对网络请求做「中间处理」------修改请求头、统一注入鉴权 token、做自定义 DNS 解析。

H5 发出的请求需要携带 App 的登录态。一种方案是 H5 侧从 JSBridge 获取 token 然后自己加头;更干净的方案是在 Native 拦截层统一注入,H5 完全不需要感知 App 的鉴权机制。

kotlin 复制代码
fun proxyRequest(
    request: WebResourceRequest
): WebResourceResponse? {
  if (!needsProxy(
      request.url.host
  )) return null

  val newHeaders =
    request.requestHeaders
    .toMutableMap()
  // 注入鉴权信息
  newHeaders["Authorization"] =
    "Bearer ${getToken()}"
  newHeaders["X-App-Version"] =
    BuildConfig.VERSION_NAME

  return executeRequest(
    request.url.toString(),
    request.method,
    newHeaders
  )
}

注意 POST body 问题 WebResourceRequest 只提供请求头,拿不到 POST body。这是 Android API 的限制。如果要拦截 POST 请求并修改 body,只能通过 JSBridge 层替换掉 H5 的 fetch/XHR,在 JS 层捕获 body 再传给 Native 代发。

自定义 DNS 解析

在某些网络环境下,运营商 DNS 可能被污染,或者你有内网域名解析需求。可以在代理层做自定义 DNS:把目标域名解析为正确 IP,然后用 IP 直连发请求,同时在请求头里带上 Host:

kotlin 复制代码
val host = uri.host ?: return null
val ip = DnsResolver
  .resolve(host)
  ?: return null

val ipUrl = uri
  .buildUpon()
  .authority(ip)
  .build()
  .toString()

headers["Host"] = host
return executeRequest(
  ipUrl, method, headers
)

Service Worker vs shouldInterceptRequest

做 H5 离线方案时,经常有人问:为什么不用 Service Worker?浏览器端的 PWA 方案不是更标准吗?

我的看法是:两者解决的不是同一个问题。

对比维度 Service Worker shouldInterceptRequest
控制侧 H5(前端) Native(App)
离线包管理 H5自己管理缓存 Native统一下发管理
跨业务共享 不支持 Native可统一处理
POST body访问 完整支持 不支持
鉴权注入 需JSBridge配合 Native直接操作
兼容性 需HTTPS,低版本WebView不支持 API 21+全支持

在 App 内嵌 H5 的场景,Native 有完整的控制权,离线包统一由 App 管理,鉴权也在 Native 侧------shouldInterceptRequest 明显更合适。Service Worker 更适合独立 PWA 应用(没有 Native 层的场景)。

性能数据:离线包方案效果

来一组实测数据(业务 A,首页 JS+CSS+HTML 约 800KB):

方案 首屏时间(P50) 首屏时间(P90)
纯网络(无优化) 1850ms 3200ms
HTTP缓存 980ms 1800ms
离线包方案 320ms 580ms
离线包 + Native图片缓存 240ms 420ms

P50 从 1850ms 降到 240ms,下降了 87%。这个数字在业务侧反映出来就是「感觉根本没有加载」。

但要注意,这个数字的前提是离线包已经下载完毕(命中率)。如果是新用户首次打开或者包更新期间,还是要走网络,这时候就需要「边用边更新」的策略------先用旧包,后台静默更新新包,下次打开生效。

整体架构总览

复制代码
WebView 发出请求

复制代码
shouldInterceptRequest 拦截

csharp 复制代码
 命中公共基础库 → 返回 Assets 中的 SDK


 命中离线包 → 校验哈希后返回本地文件


 命中图片缓存 → 返回 Glide 磁盘缓存


 需要代理 → 注入鉴权头/自定义DNS后代发


⬇ 全部未命中 → 返回 null,WebView 正常走网络

这套架构的好处是每一层都是独立的,可以按需接入。如果你的业务只需要鉴权注入,就只加代理层;如果要做离线包,就接入包管理层。各层之间职责清晰,不会互相干扰。

几个实际踩过的坑

1. 流被消费导致白屏

拦截返回 WebResourceResponse 时,InputStream 只能被读取一次。如果你在拦截层做了日志,把 stream 读了一遍,再传给 WebResourceResponse,WebView 就会拿到一个空流,直接白屏。

解决方案:要么先 readBytes 存到内存,然后每次都用 ByteArrayInputStream 包一层;要么只对文件做元信息日志,不读流内容。

2. MIME 类型必须正确

我见过有人偷懒,所有本地响应都写 application/octet-stream。结果 JS 文件被 Chromium 当二进制处理,直接报 MIME type 错误不执行。

常用的 MIME 映射:

.jsapplication/javascript

.csstext/css

.htmltext/html

.woff2font/woff2

3. 线程死锁

shouldInterceptRequest 在子线程调用,如果你的包管理器用了主线程锁,这里很容易死锁。特别是「主线程正在更新包版本,子线程等锁」这种场景。

建议包管理器的读操作用读写锁(ReentrantReadWriteLock),读多写少的场景下可以并发读,更新包时才阻塞写。

小结

shouldInterceptRequest 是 WebView 里被低估的一个能力。大多数人拿它做简单的请求过滤,但工程化之后,它可以撑起整套离线包体系和请求代理层,把 H5 的加载体验做到接近 Native。

这套方案的核心是控制权在 Native 侧:资源什么时候下载、用哪个版本、怎么校验,都由 App 来决定,H5 只管写业务逻辑,不需要关心底层的资源管理。这种分层在大型 App 的多业务线场景下特别有价值。

接下来第4篇会深入 JSBridge 的设计------这是离线包之外,H5 和 Native 之间另一条关键通道。如何设计一套安全、可扩展的 JSBridge,如何防止 H5 调用被恶意利用,以及同步/异步调用模型的取舍,都会展开讲。

下一篇预告:《WebView与原生JS交互:JSBridge设计模式与安全实践》 内容包括:addJavascriptInterface 的安全风险 → URL Scheme 方案的局限 → 双向通信的完整 JSBridge 设计 → 白名单鉴权机制 → 调用链路的调试与追踪。

Android WebView深度探索系列 · 第3/5篇

从内核原理到工程实战,全面掌握WebView开发

第1篇:[WebView内核原理:从Chromium到System WebView的架构全景](#WebView内核原理:从Chromium到System WebView的架构全景 "#")

第2篇:WebView白屏检测与解决方案:从原因分析到工程化监控

⏳ 第3篇:WebView代理方案实现:拦截请求、注入资源与离线包架构

⏳ 第4篇:WebView与原生JS交互:JSBridge设计模式与安全实践

⏳ 第5篇:WebView性能优化与稳定性治理:预热、复用池与崩溃防护

相关推荐
好好风格15 小时前
把一台 Root 安卓机交给 AI 智能体,会发生什么?
android·人工智能·开源
赏金术士16 小时前
企业级 Jetpack Compose 项目(入门版)最佳结构
android·kotlin·compose
码云骑士16 小时前
Android init启动过程
android
张小潇17 小时前
AOSP15 WMS/AMS系统开发 - Activity 生命周期源码详细分析
android
风别鹤17 小时前
windows android studio 工程gradlew.bat不是64位程序
android·ide·windows·android studio
韩曙亮17 小时前
【错误记录】Flutter 编译 Android APK 文件安装包报错 ( 国内镜像源设置 )
android·flutter
问心无愧051317 小时前
ctf show web入门260
android·前端·笔记
何乐乐17 小时前
【Taro 5.0 技术与实践】 - 高性能 iOS 渲染层与 TaroUI 跨端框架介绍
android·前端·ios