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 解析。
统一注入 Cookie 和鉴权头
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 映射:
• .js → application/javascript
• .css → text/css
• .html → text/html
• .woff2 → font/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性能优化与稳定性治理:预热、复用池与崩溃防护