如何实现 WebView 的离线缓存(AppCache)功能?

解读

面试官问“如何实现 WebView 的离线缓存(AppCache)功能”,并不是想听你背 API,而是考察三件事:

  1. 是否知道国内 Android 生态下“离线”的真实含义——弱网、断网、运营商劫持、ROM 杀后台;
  2. 是否能把“缓存”拆成“资源缓存 + 数据缓存 + 离线包更新”三层,并给出可落地的工程方案;
  3. 是否意识到 AppCache 已被 W3C 废弃,却仍要在国内 4.x~11.x 机型上兼容,如何优雅降级。
    因此,回答必须“先兼容、再优化、最后兜底”,并给出灰度、回滚、监控闭环。

知识点

  1. AppCache 生命周期:manifest 文件格式、CACHE / NETWORK / FALLBACK 段、obsolete 事件、error 事件。
  2. WebView 配置:setAppCacheEnabled(true)、setAppCachePath、setAppCacheMaxSize(API 24 被忽略,需自己删旧文件)。
  3. 国内 ROM 差异:小米/华为默认禁止 WebView 写 /data/data/pkg/app_webview,需主动申请 READ/WRITE_EXTERNAL_STORAGE 并放到 getExternalFilesDir 下;OPPO/vivo 后台 5 分钟杀进程,需用 WorkManager 做离线包下载。
  4. 安全与合规:manifest 必须走 https,防止运营商缓存投毒;离线包需做 MD5 + 服务端 RSA256 签名,公钥内置在 apk 的 raw 里。
  5. 废弃替代:Chrome 86 起正式移除 AppCache,国内微信、钉钉、支付宝自研 XWeb/UC 内核仍保留,但未来会关;官方推荐 Service Worker + CacheStorage,需内核≥73,国内 4.x 机型占比 6%,必须双轨。
  6. 性能监控:在 WebViewClient.onReceivedError 里埋点,区分 -6(CONNECT)、-2(TIMEOUT)、-8(FILE_NOT_FOUND),回捞 manifest 下载失败率;用 AOP 拦截 shouldInterceptRequest,统计离线命中率 = 本地命中次数 / 总请求次数。
  7. 灰度与回滚:离线包按城市 + 机型维度灰度,后台接口返回“packageUrl + md5 + switch=0/1”,switch=0 时 WebView 强制带 ?t={timestamp} 走网络,实现秒级回滚。

答案

分四步落地,兼顾兼容、性能、合规、灰度:

第一步:兼容层——AppCache 仍可用场景

  1. 初始化 WebView 前,先判断内核版本:
    int major = getWebViewPackageVersionMajor(); // 反射拿 com.google.android.webview 版本
    if (major < 73) {
    // 低内核,继续用 AppCache
    webView.getSettings().setAppCacheEnabled(true);
    File cacheDir = new File(context.getExternalFilesDir(null), "webcache");
    webView.getSettings().setAppCachePath(cacheDir.getAbsolutePath());
    webView.getSettings().setAppCacheMaxSize(50 * 1024 * 1024); // API 24 后无效,仅作声明
    webView.getSettings().setCacheMode(WebSettings.LOAD_DEFAULT);
    } else {
    // 高内核,走 Service Worker 方案,见第二步
    }

  2. 服务端返回的 html 必须带 <html manifest="https://cdn.xxx.com/appcache/m.manifest">,manifest 文件全部资源走 https,禁用 http。

  3. 在 Application 初始化时启动 WorkManager 任务,每日凌晨 4 点触发 DownloadManifestWorker:

    • 下载 manifest 后计算 md5,与本地 sp 缓存比对,不一致则批量下载 CACHE 段资源;
    • 下载完成再次校验整包 md5,校验失败删除整目录,防止运营商劫持。
  4. 在 WebViewClient.onReceivedError 中,如果 errorCode=-8 且 url 以 “manifest” 结尾,上报“manifest 404”埋点,后台 5 分钟内自动关闭 AppCache 开关,回滚到在线模式。

第二步:现代方案——Service Worker + CacheStorage(内核≥73)

  1. 前端同学提供 sw.js,注册时 scope 为 /webapp/;
  2. 客户端在 shouldInterceptRequest 里拦截 sw.js 请求,返回本地 assets 里预埋的 sw.js,避免首次 404;
  3. 前端在 sw.js 里使用 caches.open(“offline-v1”).addAll([…]) 缓存静态资源;
  4. 客户端通过 JavaScriptInterface 注入版本号,sw.js 比对版本不一致时调用 caches.delete() 清旧缓存;
  5. 后台同样提供灰度接口,sw.js 里 fetch 时带 ?switch=0 可强制走网络,实现秒级回滚。

第三步:兜底策略——离线包预置 + 网络降级

  1. 把核心 html/js/css 打进 apk assets/offline 目录,首次安装直接复制到 getFilesDir()/offline;
  2. shouldInterceptRequest 拦截 html 请求,若本地存在 offline/${path} 且网络不可用,直接返回 WebResourceResponse(FileInputStream, mimeType, encoding);
  3. 网络恢复后,用 WorkManager 下载最新离线包,下载完成原子替换,下次启动生效;
  4. 若 manifest 或 sw.js 连续 3 次校验失败,自动降级到 assets 内置离线包,保证“永远可开”。

第四步:监控与优化

  1. 在 shouldInterceptRequest 里埋点,记录 url、isHitCache、costTime;
  2. 每天上报离线命中率、manifest 下载成功率、sw.js install 成功率;
  3. 使用 Battery Historian 对比开启离线前后,验证 24 小时耗电增量 < 0.3%;
  4. 灰度城市按“一二三线城市 + 5G 覆盖”维度划分,命中率 < 90% 或崩溃率 > 0.1% 自动回滚。

一句话总结:国内环境下,先判断内核版本,低于 73 用 AppCache+manifest+WorkManager 做兼容,高于 73 用 Service Worker+CacheStorage 做主流,同时内置离线包兜底,通过灰度+监控+回滚形成闭环,实现“弱网可用、断网可开、随时回滚”的 WebView 离线缓存体系。

拓展思考

  1. 如果业务是车载 ROM,系统 WebView 被厂商裁剪掉 AppCache 与 SW,如何离线?
    答:采用“本地 mini-httpd”方案,在 apk 里集成 NanoHTTPd,监听 127.0.0.1:8080,把离线包解压到 /sdcard/Android/data/pkg/files/carweb,WebView 加载 http://localhost:8080/index.html,既绕过内核限制,又享受 http 缓存头。
  2. 面对工信部 164 号文“App 在用户拒绝存储权限后不得写公共目录”,如何把 50 MB 离线包放到私有目录且不影响卸载重装?
    答:使用 ScopedStorage + MediaStore 的“下载管理器”模式,首次把离线包下到 getExternalFilesDir()/offline,随后复制到 no_backup 目录,卸载时自动清除;同时提供“设置-清理缓存”入口,用户可一键删。
  3. 未来隐私沙盒限制 AdId、Cookie,离线包里的广告位如何实时刷新?
    答:把广告位拆成独立 iframe,走客户端 MSA 联盟 SDK 提供的 OAID 拼接地址,shouldInterceptRequest 里对 /ad/* 路径强制不走缓存,保证实时竞价;其余静态资源继续离线,兼顾体验与收益。