📰 科技要闻
• Investing.com中文报道,Aletheia Capital 重申 Circle Internet 集团买入评级,看好其分销协议带来的渠道增量,加密支付与稳定币业务的资本叙事仍在延续。
• Investing.com中文披露,英国以 4.277% 平均收益率发行 10 亿英镑国债,长端利率仍处高位,全球流动性预期对成长股估值的压制并未明显缓解。
• CNBC Markets 引述 Fed 古尔斯比表态,能源通胀持续性超预期,即便油价在中美中东和谈消息下回落,整体水平仍显著高于战前,年内降息节奏仍存争议。
• The Verge 报道,YouTube 推出基于自然语言的 AI 自定义视频信息流,用户可用一句话描述偏好生成专属推荐流,平台内容分发再次走向「Prompt 化」。
• Hacker News 关注 IISc 研究的「Eureka Machine」,研究者尝试用更接近自然演化的方式探索 AI 推理边界,再次引发 LLM 之外路径的讨论。
📚 Android WebView深度探索系列 · 第3/5篇
从内核原理到工程实战,全面掌握WebView开发
✅ 第1篇:WebView内核原理:从Chromium到System WebView的架构全景
✅ 第2篇:WebView白屏检测与解决方案:从原因分析到工程化监控
⏳ 第3篇:WebView代理方案实现:拦截请求、注入资源与离线包架构
⏳ 第4篇:WebView与原生JS交互:JSBridge设计模式与安全实践
⏳ 第5篇:WebView性能优化与稳定性治理:预热、复用池与崩溃防护
我见过的最反常识的 WebView 性能优化,不是把 H5 改成 Native,也不是塞一堆缓存策略,而是一段二十行的 shouldInterceptRequest。
那是一个商品详情页,前端发誓 CDN 已经做到极致,后端坚信接口 RT 稳定在 80ms。客户端在 IO 线程拦下请求,把命中率最高的 6 个 JS、CSS 直接从 APK 里读出来塞回去——首屏从 1100ms 掉到了 480ms,CDN 流量直接减半。这种事不止一次发生过,每次结论都一样:真正决定 WebView 体验的不是 H5,也不是网络,而是「谁来响应这次请求」。
这一篇我把 WebView 代理方案彻底拆开来讲。从 shouldInterceptRequest 的线程模型,到本地资源代理、离线包架构、网络代理、图片复用,再到 Service Worker 的取舍,最后用真实首屏数据收尾。我尽量讲我踩过的坑,而不是 API 文档抄一遍。
一、为什么要做拦截:首屏、流量、可控性
很多团队第一次做 WebView 拦截,是被业务逼出来的。要么是大促首屏太慢,要么是流量成本顶不住,要么是网络抖动一把全军覆没。三件事看起来不一样,本质都是在请求链路上插不进手——浏览器内核自己发请求,自己走 socket,自己缓存,开发者只能看不能动。
代理方案的价值就在这里:把内核原本一手包办的网络请求,让客户端有机会接管。我归纳过它带来的三类收益:
• 首屏提速:核心 JS/CSS/字体走本地,省掉 DNS、TCP、TLS、传输四段时间。
• 流量节省:命中本地的资源完全不走网络,CDN 成本直接下降,弱网环境的失败率也跟着降。
• 可控性:统一的鉴权、Cookie、UA、域名收敛,全部可以在拦截层做,前端不用每个项目重抄一遍。
我的判断:代理不是优化项,是 WebView 容器化的必要基础设施。没有它,H5 永远是「黑盒进黑盒出」,工程师只能祈祷一切顺利。
二、shouldInterceptRequest 的真实样子
先说一个被反复忽略的事实:shouldInterceptRequest 不在主线程。它跑在 WebView 的 IO 线程上,由 Chromium 的 aw_contents_io_thread_client 触发。换句话说,这是一段对内核完全同步的代码——你阻塞多久,网络管线就停多久。
看一下最朴素的一种实现:
class ProxyClient : WebViewClient() {override fun shouldInterceptRequest(
view: WebView,
req: WebResourceRequest
): WebResourceResponse? {
val url = req.url.toString()
val hit = localMap(url)
?: return nullreturn WebResourceResponse(
mime(url),
"UTF-8",
openLocal(hit)
)
}
}
看起来很无害,但里面已经埋了三个坑。第一,localMap 不能查数据库——查一次 SQLite 几十毫秒,IO 线程被你拖垮一段,所有资源都跟着变慢。第二,openLocal 必须返回流式响应,不要一次性把文件读进内存,大资源会爆。第三,MIME 类型必须正确,否则浏览器会按错误类型解析,CSS 当文本、JS 当 HTML 全都见过。
还有一个很多人不知道的限制:POST 请求的 body 拿不到。WebResourceRequest 接口里没有 body 字段,Chromium 出于性能考虑也不会把 body 透出来。如果你要拦截 POST,做请求重写或者鉴权注入,就只能配合 JSBridge,让前端用 fetch 或 XHR 走客户端通道。
三、本地资源代理:URL 映射不是字典查表
本地资源代理是离线包之前的最小可用版本。它的逻辑很简单:把 H5 请求的 URL 翻译成本地路径,找到了就返回本地,找不到就放行走网络。
但这里第一个反直觉的事是——URL 映射不能做成「字典硬匹配」。线上 URL 会带版本戳、query、fragment,硬匹配几乎全部 miss。我推荐的做法是按「资源指纹」匹配:把 URL 经过归一化(去 query、去 hash、保留扩展名)之后,再去本地包里找。
fun normalize(url: String): String {
val u = Uri.parse(url)
val path = u.path ?: ""
val last = path.substringAfterLast('/')
return last.substringBefore('?')
}fun resolve(
url: String,
pkg: OfflinePackage
): File? {
val name = normalize(url)
return pkg.files[name]
}
第二个坑是 MIME 推断。我见过非常多人用 URLConnection.guessContentTypeFromName,结果遇到 .mjs、.wasm、.webmanifest 全部识别错误。建议自己维护一张白名单:
val mimeMap = mapOf(
"js" to "application/javascript",
"mjs" to "application/javascript",
"css" to "text/css",
"wasm" to "application/wasm",
"png" to "image/png",
"webp" to "image/webp",
"json" to "application/json"
)
第三个不容易意识到的细节是 CORS。如果代理回去的是 file:// 或私有 scheme,跨域规则会变得非常迷。我的经验是,WebResourceResponse 在 API 21+ 支持自定义响应头,把 Access-Control-Allow-Origin 显式写上,比寄希望于浏览器猜要靠谱得多。
四、离线包架构:四件事缺一不可
本地资源代理只能解决「APK 内置那几个文件」。一旦你想做到「H5 发版后自动下发」,就绕不开离线包。我把离线包设计抽象成四件事:包结构、版本控制、增量更新、签名校验。少一件,线上都会出问题。
包结构。一个离线包至少要有 manifest 文件(描述资源清单与版本)、资源目录、签名文件。manifest 推荐用 JSON 而不是 XML,可读性高、解析快。每个资源条目要带 path、size、hash 三项。hash 用 SHA-256,别再用 MD5。
{
"pkg": "detail-h5",
"version": "1.20.3",
"baseUrl": "https://h.example.com/detail/",
"files": [
{
"path": "main.js",
"size": 182301,
"hash": "3a91c2..."
}
]
}
版本控制。这里最容易翻车。我见过的真实事故里,最多的就是「APP 启动时下发新包,用户刚打开 H5 一半,资源被替换」。结果就是新 main.js 配旧 chunk,页面挂掉。我的原则是:包只在「无活跃使用」时切换。具体策略可以是「下载完后等下次冷启动」,或者「当前页面销毁后再生效」。
增量更新。如果一个包 5MB,每次发版都全量下,弱网用户基本不用想了。增量方案有两种主流路径。一种是按文件 hash diff,把变化的文件单独打成 patch 包;另一种是 bsdiff 二进制差分。我倾向第一种——实现简单、可读、回滚方便。bsdiff 在 H5 这种小文件多的场景里收益不算大,工程复杂度反而显著上升。
签名校验。这是最容易被跳过的一步。但凡你做过任何端上下发的功能,都应该签名。否则中间人改你的 manifest,注入恶意 JS,你的 WebView 就成了攻击面。我推荐 RSA-2048 + SHA-256 双重签名:包内带签名,下载后先校验整包,再校验单文件 hash。两层之后,攻击者要么改不了,要么必须同时拿到私钥。
容易踩的坑:不要把校验过程放主线程。SHA-256 一个 5MB 包大约 200ms+,主线程做就是 ANR 直通车。把校验丢到后台线程,校验完才标记包可用。
五、网络代理:DNS、Cookie、Header 的统一战场
本地资源代理解决的是「不走网络」,网络代理解决的是「走网络但走得更好」。这一节适用于本地未命中的资源。
最直接的做法,是在 shouldInterceptRequest 里用 OkHttp 接管请求:
override fun shouldInterceptRequest(
view: WebView,
req: WebResourceRequest
): WebResourceResponse? {
if (req.method != "GET") return null
if (!shouldProxy(req.url)) return nullval r = Request.Builder()
.url(req.url.toString())
.headers(toHeaders(req))
.build()val resp = client.newCall(r).execute()
return toWebResp(resp)
}
看起来好像就这么几行。但工程上要解决的事不少:
• DNS 一致性。OkHttp 的 DNS 默认走系统,你完全可以替换成 HTTPDNS,把 WebView 里的请求和 App 主链路绑定到同一套解析。这样 IP 直连、灰度引流、容灾切换都可以共用一套逻辑。
• Cookie 同步。WebView 自己有 CookieManager,OkHttp 自己有 CookieJar,两套并存就会出现「鉴权登录态不一致」。我的做法是 CookieJar 直接读写 CookieManager,让两边共享同一份 store。同步代码要小心线程,CookieManager 在某些版本对子线程支持不好。
• Header 注入。统一注入 trace_id、device_id、token,对全链路日志和监控非常有用。这是中台型 App 做 H5 容器的核心价值之一。前端不用每个项目都写一遍鉴权头。
还有一个真实工程教训值得讲:TLS 失败的兜底。线上偶发 SSL 握手失败,原因可能是系统证书库异常、运营商劫持、特定 CDN 节点配置错误。如果你已经在拦截层用 OkHttp,就可以在拦截层捕获 SSL 异常并切到备用域名,对 H5 完全透明。这种兜底机制系统 WebView 做不到,纯前端做不到,只有客户端拦截层能做到。这也是代理方案在稳定性上的天然优势。
六、图片代理:复用 Native 缓存才是真省
图片代理是个看似简单、实际收益很高的方向。Native 端通常已经有 Glide / Fresco / Coil 缓存了大量商品图、用户头像。WebView 里同一张图,因为 URL 拼参数不同,往往会重新下一次。代理层做的事很简单:把 URL 归一化后,去 Native 缓存里捞图。
fun imageProxy(
url: String
): WebResourceResponse? {
val key = canonicalize(url)
val file = imageCache.getDiskFile(key)
?: return nullreturn WebResourceResponse(
guessImageMime(file),
null,
FileInputStream(file)
)
}
真实业务里我们试过这个方案,命中率能到 30~40%,因为商品列表页和详情页的图片高度重合。流量、首屏、磁盘都赚了。
但有一个非常隐蔽的坑:大图直接走 InputStream 给 WebView,可能会卡死渲染。WebView 内部对图片解码用了多个工作线程,如果你给它一个 30MB 的 4K 图,IO 流读到一半内存爆掉,整个渲染进程可能被 oom_killer 干掉。我们内部的处理是:超过 1MB 的图片,代理层会先在客户端做一次降采样压缩,再返回;这一步在弱机型上能避免大量 webview crash。
七、Service Worker:理论很美,约束很多
写到这里有人会问:现代 Web 不是有 Service Worker 吗?为什么不直接让 H5 自己拦截?
Android WebView 从 API 24 开始确实支持 Service Worker,并提供了 ServiceWorkerController。理论上,H5 自己注册一个 SW,就能拦截 fetch 请求、命中本地缓存、做离线访问。听起来比客户端代理优雅多了。
但实际上,Service Worker 在 WebView 里的体验远比浏览器复杂:
• 持久化不稳。SW 的注册依赖 origin。WebView 在 App 卸载、缓存清理、用户切换、隐私模式下,都可能丢失注册。生产环境里,「我昨天还能离线,今天就不行了」是 SW 用户最常见反馈。
• 作用域受限。SW 只能拦截同 origin 的请求,跨域 CDN、第三方接口它管不了。而客户端代理可以拦截一切。
• 调试链路长。客户端排查问题,要进 chrome://inspect、看 SW 状态、看 Cache Storage、看 fetch event。出问题时排错路径远长于客户端代理。
• 能力不对等。SW 不能用 Cookie、不能访问客户端的 Native 缓存、不能做 HTTPDNS。那些「真正提升体验的事」它做不了。
我的判断是:SW 适合补充,不适合主导。如果你的 H5 同时跑在浏览器和 WebView 上,SW 可以兜浏览器场景;但 WebView 容器内主代理还是放在客户端,能力强、稳定、可观测。两套机制并存时,记得同一资源不要同时出现在 SW Cache 和客户端代理里,否则版本会打架。
八、性能数据:代理到底值不值得做
最后这一节我特别想写。技术决策光讲「我觉得快」不行,要看真实数据。下面是一个真实电商商品详情页的对照测试(中端机型 / 4G / 同一 H5 版本):
方案 | 首屏(FCP) | 可交互(TTI) | 流量 |
|---|---|---|---|
无代理 | 1180 ms | 2150 ms | 820 KB |
本地资源代理 | 760 ms | 1480 ms | 510 KB |
+ 离线包 | 510 ms | 1090 ms | 240 KB |
+ 图片代理 | 480 ms | 980 ms | 160 KB |
几个值得提的结论:
• 本地资源代理是收益最大的一步——首屏直接降 36%,流量降 38%。它的实现成本也是最小的。
• 离线包方案的边际收益依然很可观——继续把首屏往下压 30%。但工程复杂度从「数十行代码」上升到「需要一整个发布系统」。
• 图片代理在首屏上的提升不显著(首屏更多看脚本和样式),但在弱网环境的稳定性、流量、滑动流畅度上贡献很大,是体验型收益。
我会怎么落地:先做本地资源代理,立竿见影。如果团队有发版基建,再上离线包。图片代理放在最后,作为体验优化。Service Worker 仅作浏览器场景兜底,不要作为主路径。
九、写在最后:代理是 WebView 容器化的钥匙
这一篇写到现在,回头看其实有一条暗线贯穿始终:WebView 不应该是黑盒。从 shouldInterceptRequest 到离线包,从 OkHttp 接管到图片复用,本质上都是在把那个原本封闭的浏览器内核,一点点变成「客户端可以介入的容器」。
这件事的价值远不只是首屏快一秒。一旦请求层可控,鉴权可控、监控可控、容灾可控,WebView 就从「H5 跑在 App 里」变成了「H5 跑在我的容器里」。后者意味着 App 团队对体验的话语权,是真实存在的。
浏览器看似强大,但在 App 容器里,它是客人。代理方案,是让客户端从客人变回主人的钥匙。
下一篇我会接着讲 WebView 与原生的 JS 交互,重点聊 JSBridge 的设计模式与安全实践——「能调」很容易,「调得安全」才是真挑战。我们会从最常见的 prompt hook、addJavascriptInterface,一直讲到契约式 Bridge 与权限分级,看看那些线上真实出过问题的设计是怎么演变的。
📚 Android WebView深度探索系列 · 第3/5篇
从内核原理到工程实战,全面掌握WebView开发
✅ 第1篇:WebView内核原理:从Chromium到System WebView的架构全景
✅ 第2篇:WebView白屏检测与解决方案:从原因分析到工程化监控
✅ 第3篇:WebView代理方案实现:拦截请求、注入资源与离线包架构
⏳ 第4篇:WebView与原生JS交互:JSBridge设计模式与安全实践
⏳ 第5篇:WebView性能优化与稳定性治理:预热、复用池与崩溃防护