WebSettings 的缓存模式,H5 混合应用的加载策略
WebSettings 的缓存模式,H5 混合应用的加载策略
去年维护一个存量混合应用时,遇到一个让我困惑了很久的问题:同样的 H5 页面,在 iOS 上二次打开几乎是瞬时的,Android 上却总是要从头加载。排查了一圈 CDN、HTTP 缓存头、服务端配置,最后发现问题出在 WebSettings.setCacheMode 这个看似简单的 API 上。这个经历让我意识到,Android WebView 的缓存机制远比文档上那几行说明要复杂,而国内技术社区里关于这块的深度讨论其实很少。
问题现场:一个"秒开"需求的挫败
项目背景是一个电商类的混合应用,核心交易链路是原生实现的,但商品详情、活动页这些长尾页面用 H5 承载。产品给了一个明确指标:已访问过的 H5 页面,二次打开要在 300ms 内完成首屏渲染。这个指标在 iOS WKWebView 上基本没费什么力气就达到了,Android 这边却卡了很久。
当时我的 WebView 配置是这样的常规写法:
WebSettings settings = webView.getSettings();
settings.setCacheMode(WebSettings.LOAD_DEFAULT);
settings.setDomStorageEnabled(true);
settings.setDatabaseEnabled(true);页面加载流程是:先请求一个 HTML 骨架,里面引用了 CSS、JS 和图片资源。服务端对所有静态资源都配了 Cache-Control: max-age=31536000,HTML 本身是不缓存的。理论上第二次打开时,HTML 走网络,静态资源走本地缓存,应该很快。
但实际测试下来(小米 12,Android 13,Chrome 107 内核),二次打开还是要 1.5 秒左右。抓包发现,那些明明应该命中缓存的 JS 文件,WebView 居然在发条件请求(If-None-Match / If-Modified-Since),等服务器返回 304 后才继续。更离谱的是,有些资源直接走了完整下载,200 响应,耗时几百毫秒。
这就是 LOAD_DEFAULT 的真实行为,和我想象的"默认就是智能缓存"完全不同。
LOAD_DEFAULT 到底在做什么
查 Chromium 源码才知道,WebView 的缓存逻辑并不是 Android 框架层独立实现的,而是复用了 Chromium 的网络栈。LOAD_DEFAULT 对应的实际上是 Chromium 的 HttpCacheMode::DEFAULT,它的行为规则是:
但问题出在"验证请求"这个环节。Chromium 的网络栈在处理缓存验证时,会受到多个因素影响:磁盘缓存的完整性、缓存项的优先级、以及一个很容易踩坑的点——如果请求带有 Vary 头,或者之前缓存时存储的响应头信息不完整,Chromium 可能会选择直接重新请求而不是用 304 验证。
我在抓包中看到的那些 304 请求,实际上已经是最优路径了。更糟的是某些 CDN 节点对 304 响应处理慢,或者偶发的网络抖动,导致 304 也要等 200-500ms。这和 iOS 上 WKWebView 直接读本地缓存、零网络延迟的体验差距巨大。
还有一个细节:WebView 的磁盘缓存分两种,一种是 HTTP 缓存(存在 app_webview/Cache 目录下),另一种是资源加载的内存缓存。LOAD_DEFAULT 下,Chromium 会尝试用内存缓存,但内存缓存的容量有限,而且 WebView 进程被杀后全部失效。对于混合应用这种用户可能隔几天再打开的场景,内存缓存的命中率并不理想。
LOAD_CACHE_ELSE_NETWORK 的陷阱
既然 LOAD_DEFAULT 会发验证请求,那换成 LOAD_CACHE_ELSE_NETWORK 是不是就能解决问题?这个模式的文档说明是:"不使用网络,只读取本地缓存,如果没有缓存则返回错误。"
我试了一下,效果确实立竿见影:二次打开时所有资源都直接走本地,没有任何网络请求,加载时间降到 100ms 以内。但很快发现了致命问题。
第一个坑是缓存的"完整性"要求。Chromium 的缓存是按请求 URL 作为 key 存储的,但一个缓存项要能被使用,必须满足:存储的响应头完整、响应体完整、缓存元数据没有损坏。WebView 在缓存写入时如果进程崩溃、或者存储空间不足、或者用户清除了应用数据,缓存项可能处于不完整状态。LOAD_CACHE_ELSE_NETWORK 遇到这种不完整缓存时,不会尝试网络请求,而是直接失败,页面显示空白或者资源加载错误。
我在测试中就遇到了一种情况:某个 JS 文件缓存时恰好遇到 Activity 被系统回收,文件只写了一半。下次打开这个页面时,这个 JS 加载失败,页面功能直接崩掉。更麻烦的是,这种损坏的缓存项不会被自动清理,除非缓存达到容量上限被 LRU 淘汰,或者用户手动清数据。
第二个坑是 HTML 本身的缓存。如果 HTML 也被缓存了,那服务端更新页面后,客户端永远看不到新版本。这对于需要频繁发版的 H5 业务是不可接受的。
我当时的折中方案是:对 HTML 请求用 LOAD_DEFAULT,对静态资源用 LOAD_CACHE_ELSE_NETWORK。实现方式是通过 WebViewClient.shouldInterceptRequest 来判断 URL 类型:
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
String url = request.getUrl().toString();
if (isHtmlRequest(url)) {
// 不拦截,走默认逻辑
return null;
}
// 静态资源尝试走本地缓存
// ...
}但这个方案也有问题。shouldInterceptRequest 是在 WebView 的 IO 线程同步调用的,如果在这里做复杂的缓存查找逻辑,会阻塞资源加载。而且自己实现缓存查找,相当于绕过了 Chromium 的 HTTP 缓存层,需要处理缓存过期、内容编码(gzip/br)、MIME 类型匹配等一系列细节。我试过用 OkHttp 的 Cache 来做这个中间层,但 WebResourceResponse 的构造和 OkHttp 的 Response 转换有很多边界情况,比如 Content-Type 和 charset 的处理不一致,导致 CSS 解析出错。
深入 Chromium 缓存:容量与清理机制
为了搞清楚缓存为什么不命中,我花时间研究了 WebView 的缓存目录结构。在 Android 10+ 的设备上,路径通常是 /data/data/<package>/app_webview/Default/HTTP Cache/,里面不是简单的文件映射,而是 Chromium 的 Blockfile Cache 或 Simple Cache 格式(取决于版本)。
Blockfile Cache 是较老的格式,把数据存在若干个大文件里,用索引管理。Simple Cache 是新版默认格式,每个缓存项一个独立文件,对闪存更友好。WebView 从某个版本开始(大概是 Chrome 80 对应的 WebView 版本)默认用 Simple Cache,但升级过程中可能残留 Blockfile 格式的旧缓存。
缓存的容量上限由 Chromium 内部计算,考虑因素包括磁盘总空间、可用空间、以及一个硬编码的默认值。我查到的源码显示,WebView 的最大缓存容量通常是磁盘总空间的 2% 或固定 300MB 中的较小值。但实际观察下来,很多设备的 WebView 缓存目录只有几十 MB,对于现代 H5 应用来说很容易打满。
缓存打满后的淘汰策略是 LRU,但这个 LRU 是 Chromium 实现的,不是简单的文件访问时间排序。它考虑的是缓存项的优先级、访问频率、以及一个"评分"机制。问题是,HTML 页面通常被标记为高优先级(因为它是主文档),而 CSS/JS 是子资源。如果缓存空间紧张,子资源可能先被淘汰,导致下次打开时 HTML 还在缓存中,但引用的资源没了,触发大量重新下载。
我还遇到过一个诡异现象:某些用户的设备上,WebView 缓存目录会无限增长,直到占满所有存储空间。后来定位到是 Chromium 的一个 bug,在特定条件下(缓存项写入失败后的重试逻辑有缺陷)会导致垃圾缓存文件累积。这个 bug 在 Chrome 90 左右修复,但国内厂商的 WebView 更新滞后,很多用户长期受影响。
离线包方案:绕过 WebView 缓存的另一种思路
既然 WebView 自带的缓存机制不可靠,很多团队转向了离线包方案:把 H5 资源提前打包下载,存在应用私有目录,通过 shouldInterceptRequest 拦截请求返回本地内容。这个方案在阿里、美团等大厂的混合应用框架中很常见。
我实现过一个简化版的离线包系统,核心逻辑是:
// 预下载的资源存在 /data/data/<package>/webapp/<packageId>/
// 映射规则由服务端下发的配置文件维护
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
String url = request.getUrl().toString();
LocalResource resource = resourceManager.find(url);
if (resource != null && resource.isValid()) {
return new WebResourceResponse(
resource.getMimeType(),
resource.getEncoding(),
new FileInputStream(resource.getFile())
);
}
return null;
}这个方案的优势是可控性强:更新时机由应用自己决定,缓存容量自己管理,不受 WebView 清理策略影响。但代价也很明显:
首先是同步拦截的性能问题。shouldInterceptRequest 必须在子线程完成文件 IO,否则主线程卡顿。但 WebResourceResponse 的 InputStream 是在后台线程读取的,如果文件在慢速存储(比如低端机的 eMMC)上,或者同时加载很多资源,还是会感受到延迟。我测过在红米 9A 上,同时拦截 20 个资源请求,首次打开离线包页面时总耗时比网络加载还慢,因为磁盘随机读取成了瓶颈。
其次是缓存一致性的复杂度。离线包需要处理版本更新、差量下载、回滚机制、签名验证。一个简单的场景:用户打开了页面 A,触发了离线包更新,更新过程中用户又打开了页面 B(属于同一个离线包),这时应该用什么版本?如果强制等更新完成,体验受损;如果允许混用新旧版本,可能出现 API 不兼容。这些边界情况在 WebView 原生缓存中由 Chromium 处理,自己实现就要全部考虑。
还有一个我踩过的坑:WebResourceResponse 的 mimeType 参数必须准确,否则 WebView 可能拒绝解析。比如 JavaScript 文件必须是 "application/javascript" 或 "text/javascript",不能是 "application/x-javascript"。CSS 文件必须是 "text/css"。如果离线包配置里的 MIME 类型写错了,或者文件扩展名和实际内容不匹配,资源加载会静默失败,调试很困难。
setAppCacheEnabled 的消亡与 Service Worker 的尴尬
在搜索缓存方案时,很多人会看到 setAppCacheEnabled 和 setAppCachePath 这两个 API。这是 HTML5 Application Cache 的 Android 接口,但我要明确说:这个 API 从 Android API 30(Android 11)开始已经废弃,底层 Chromium 也早已移除支持。如果你的项目还在用,需要尽快迁移。
替代方案是 Service Worker + Cache Storage,这是 PWA 的标准方案。我尝试过在 WebView 中启用 Service Worker 来实现更精细的缓存控制:
WebSettings settings = webView.getSettings();
// Service Worker 在 WebView 中默认是启用的,但需要确保 DomStorage 开启
settings.setDomStorageEnabled(true);然后在 H5 页面中注册 Service Worker,用 Cache API 预缓存核心资源。这个方案理论上很美好:缓存逻辑用标准 Web API 实现,跨平台复用,更新策略灵活。
但实际在 WebView 中跑起来问题很多。首先是 Service Worker 的注册和激活有延迟,首次访问页面时 Service Worker 还没准备好,这次访问享受不到缓存。其次是 WebView 对 Service Worker 的支持不如 Chrome 浏览器完整,某些 API 行为有差异。最严重的是,国内 Android 生态中,很多厂商的 WebView 实现(特别是老版本)对 Service Worker 的支持不稳定,偶发出现 Service Worker 不响应、或者缓存不生效的情况。
我在华为某款老机型上遇到过:Service Worker 注册成功,但 fetch 事件不触发,所有请求直接走网络。查日志发现是 WebView 内部的一个权限检查失败,但没有任何错误抛给上层。这种平台兼容性问题让 Service Worker 方案在国内混合应用中很难作为唯一依赖。
一个我最终采用的混合策略
经过上面这些折腾,我现在的项目采用了一套分层缓存策略,根据不同资源类型和场景选择不同模式:
对于 HTML 主文档,始终走网络,但用 LOAD_DEFAULT 让 Chromium 正常处理 HTTP 缓存头。如果服务端支持,HTML 响应带上 ETag,这样二次请求至少是 304 而不是完整下载。关键是 HTML 的响应时间要足够快,这就要求服务端渲染优化、CDN 边缘缓存、或者把 HTML 也做到足够轻量(骨架屏 + 异步加载数据)。
对于 CSS/JS 这些版本化的静态资源,URL 中带有内容哈希(如 app.a3f2b1c.js),理论上永远不会变更,所以用长期缓存。我在 WebView 层面不拦截这些请求,而是依赖 CDN 的缓存和 Chromium 的 HTTP 缓存。但额外做了一件事:在应用启动时,预加载核心资源的子集到内存。实现方式是用一个隐藏的 WebView 实例访问一个特殊的预加载页面,这个页面只引用核心资源,不渲染任何内容。这样这些资源会进入 Chromium 的内存缓存,后续真实页面加载时直接命中。
// 预加载 WebView,不添加到视图层级
WebView preloadWebView = new WebView(context);
preloadWebView.loadUrl("https://example.com/preload.html");这个技巧来自 Chromium 社区的讨论,利用的是同进程 WebView 实例共享部分缓存的特性。但要注意,这个共享不是完全的,内存缓存的键包含很多因素(URL、请求头、缓存模式等),而且 WebView 版本升级后行为可能变化。我测过在 Chrome 108 内核上有效,但不能保证所有设备。
对于图片资源,采用完全不同的策略。WebView 中的图片加载我倾向于不依赖 HTTP 缓存,而是让 H5 侧用懒加载 + 占位图,原生侧用图片库(Glide/Coil)来加载和缓存。实现方式是通过 shouldInterceptRequest 拦截图片 URL,转交给图片库处理,返回 WebResourceResponse。
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
String url = request.getUrl().toString();
if (isImageUrl(url)) {
// 交给 Glide 异步加载,同步返回一个占位 Stream
// 实际实现更复杂,需要处理回调和重试
return imageInterceptor.intercept(url);
}
return null;
}这个方案的好处是利用了原生图片库的成熟缓存策略(内存 + 磁盘 LRU、采样压缩、动图支持),而且图片缓存和 WebView 缓存解耦,清理策略独立。缺点是实现复杂,特别是 WebResourceResponse 要求同步返回 InputStream,而图片库是异步的,需要做一些线程同步的 trick。
一个关于缓存模式的版本差异
最后说一个我花了很长时间定位的问题,涉及不同 Android 版本上 WebSettings.setCacheMode 的行为差异。
在 Android 7.0(API 24)之前,WebView 的内核是 Android 系统自带的 WebView 实现(基于旧版 Chromium 或 WebKit),从 7.0 开始改用可独立更新的 Chrome WebView。这个变化导致 setCacheMode 的底层实现完全不同。
我在一个 Android 6.0 的测试机上发现,设置 LOAD_CACHE_ELSE_NETWORK 后,某些 POST 请求也会被缓存,这在文档里没有说明。查源码发现旧版 WebView 的缓存实现比较粗糙,没有严格区分安全方法和非安全方法,POST 响应如果被错误缓存,下次同样 URL 的 POST 请求会直接返回旧响应,导致数据错乱。
而在新版 Chrome WebView 中,Chromium 的网络栈严格遵循 HTTP 规范,POST 默认不缓存,除非响应明确带有 Cache-Control: public 等头。这种差异导致我在 Android 6.0 设备上测试通过的缓存策略,在 Android 10+ 上行为不同。
更隐蔽的是,从某个 Chrome WebView 版本开始(大概是 80 左右),LOAD_DEFAULT 在处理带有 Authorization 头的请求时,缓存行为也变了。旧版本会缓存这类请求的响应,新版本默认不缓存(涉及安全策略调整)。如果你的 H5 页面有登录态,请求带 Cookie 或 Token,这个变化会影响缓存命中率。
这些版本差异很难通过文档完整掌握,我的建议是在制定缓存策略时,至少在 Android 6、9、12、14 四个版本上做过验证,覆盖 WebView 内核的主要变迁节点。
调试工具:怎么验证缓存是否生效
说了这么多策略,最后分享几个我常用的调试方法。
最基础的是 Chrome DevTools 的 Remote Debugging。连接设备后,在 chrome://inspect 里找到 WebView 实例,打开 Network 面板,可以看到每个请求的详细情况。关键是注意 Size 列:如果是 "(disk cache)" 或 "(memory cache)",说明走了本地缓存;如果是具体数字,就是网络下载。但这个工具有时显示不准确,特别是缓存验证请求(304)可能被归类为网络请求。
更可靠的是直接在 Android 设备上抓包,用 tcpdump 或 HttpCanary(需要 root 或 VPN 模式)。看是否有实际的 TCP 连接建立、TLS 握手、HTTP 请求发出。如果 URL 对应的资源完全没有网络流量,那可以确定是本地缓存命中。
对于 WebView 内部缓存状态,可以用 Chromium 的调试页面。在 WebView 中加载 chrome://net-export/,可以导出网络日志,然后用 NetLog Viewer 分析。但这个操作需要代码配合,要在 WebView 启动时设置命令行参数开启 net logging,生产环境不实用。
还有一个土办法:直接看缓存目录的文件变化时间戳。用 adb shell 进入 app_webview/Default/HTTP Cache/,观察文件访问时间。如果二次打开页面时这些文件的 atime 没有更新,说明没有被读取。但这个分析需要对 Chromium 缓存格式有了解,Simple Cache 的文件名是哈希值,不是原始 URL。
写在最后的个人看法
WebView 的缓存机制,说到底是 Chromium 网络栈的一个暴露面,而 Chromium 的设计目标是通用浏览器,不是为混合应用优化的。它考虑的缓存场景是用户浏览各种网站,安全性和标准兼容性优先于性能。混合应用的需求恰恰相反:我们知道自己在加载什么内容,愿意用可控性换性能。
所以我认为,对于性能敏感的混合应用,完全依赖 WebSettings.setCacheMode 的几个枚举值是不够的。需要理解 Chromium 缓存的底层行为,在必要时绕过它(离线包、原生拦截),同时利用它(HTTP 缓存头、预加载预热)。这个度怎么把握,取决于团队的技术投入和业务对加载性能的敏感程度。
我见过的最糟糕的实践,是同时设置 LOAD_CACHE_ELSE_NETWORK 和不清缓存,导致用户永远看不到更新,出了问题只能让用户"清除应用数据"。也见过过度复杂的离线包系统,为了 100ms 的加载优化,投入了两个人力维护,ROI 极低。
技术选型没有银弹,但了解每个选项的真实行为和边界,至少能让我们踩坑时知道坑在哪。这篇文章里的经验,来自我过去一年维护一个日活百万级混合应用的踩坑记录,不一定适用于所有场景,但希望能给同样在 WebView 缓存里挣扎的开发者一些参考。