4. 搭建本地缓存拦截器

本地缓存拦截器是 WebView 缓存架构的核心执行层,它拦截每一次网络请求,在请求发出前决定是否从本地缓存返回,在响应到达后决定是否更新缓存。本章聚焦两大平台的主流拦截机制——Android 的 OkHttp Interceptor 与 iOS 的 URLProtocol,并详解缓存命中逻辑与过期策略的实现。

关键前提:拦截器只处理应用层 HTTP(S) 请求,不处理自定义协议或 WebSocket;且在 WebView 注册前完成注入才能生效。

4.1 拦截器工作流程

拦截器在请求/响应链路中以下图方式插入,每个请求都经过“读缓存 → 检查过期 → 网络请求 → 写缓存”的闭环。注意自定义拦截器与框架内置拦截器的执行顺序:OkHttp 按添加顺序形成链条,URLProtocol 则通过注册覆盖系统默认处理。

本地缓存拦截器工作流程 拦截器处理请求/响应链 应用发起请求 检查本地缓存 缓存是否命中 返回缓存响应 发起网络请求 更新本地缓存 返回网络响应 若缓存未过期且有效
(未设置 no-cache 等) 拦截器优先于框架缓存执行 且可通过响应头控制写缓存

4.2 OkHttp 拦截器设计与注入

Android 端将自定义缓存规则封装为 Interceptor,通过 OkHttpClient.Builder 链式注入。关键点:

  • 使用 Application Interceptor(不区分重定向/重试)或 Network Interceptor(区分真实网络流量)
  • 缓存逻辑在 intercept(Chain) 方法中实现
  • 维护一个并发安全的本地缓存数据库或 LRU 文件缓存
// OkHttp 自定义缓存拦截器 - 仅展示结构
public class LocalCacheInterceptor implements Interceptor {
    private final LruCache<String, CacheEntry> memoryCache;

    public LocalCacheInterceptor(int maxSize) {
        this.memoryCache = new LruCache<>(maxSize);
    }

    @NonNull
    @Override
    public Response intercept(@NonNull Chain chain) throws IOException {
        Request request = chain.request();
        String url = request.url().toString();

        // 1. 尝试从内存缓存获取
        CacheEntry entry = memoryCache.get(url);
        if (entry != null && !entry.isExpired()) {
            // 构建缓存 Response (需模拟原始状态码、header)
            return entry.toResponse(request);
        }

        // 2. 放行到网络
        Response response = chain.proceed(request);

        // 3. 根据响应头决定是否写入缓存
        if (response.isSuccessful() && response.cacheControl().maxAgeSeconds() > 0) {
            CacheEntry newEntry = new CacheEntry(response);
            memoryCache.put(url, newEntry);
            // 可选写入磁盘缓存
        }

        return response;
    }
}

// 注入到 OkHttpClient
OkHttpClient client = new OkHttpClient.Builder()
        .addInterceptor(new LocalCacheInterceptor(10 * 1024))  // 10MB 内存缓存
        .addNetworkInterceptor(new CacheInterceptor())        // 框架内置缓存
        .build();
实践建议:将自定义拦截器置于框架内置缓存之前,以保证自定义规则优先执行;同时注意保持 Response 对象的不可变性,不要在拦截器中修改已返回的 Response body 流,否则会报 closed 异常。

4.3 URLProtocol 拦截器设计与注入(iOS)

iOS 通过注册 NSURLProtocol 子类来拦截所有基于 URL Loading System 的请求(包括 WKWebView 的子资源请求,但需注意 WKWebView 默认使用独立网络进程,需通过 WKUserContentController + WKURLSchemeHandler 配合处理自定义协议)。标准流程:

  • 子类化 NSURLProtocol,重写 canInitWithRequest: 过滤需要拦截的请求
  • 实现 startLoading 和 stopLoading 承载缓存逻辑
  • 在 + (BOOL)canInitWithTask: 中返回 YES 以支持 NSURLSession
// URLProtocol 缓存拦截器 - 核心结构
@interface LocalCacheURLProtocol : NSURLProtocol
@end

@implementation LocalCacheURLProtocol

+ (BOOL)canInitWithRequest:(NSURLRequest *)request {
    // 仅拦截 http/https 且未被自定义标记的请求
    if (![request.URL.scheme hasPrefix:@"http"]) return NO;
    if ([NSURLProtocol propertyForKey:@"CachedByLocal" inRequest:request]) return NO;
    return YES;
}

+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request {
    return request; // 通常直接返回
}

- (void)startLoading {
    NSString *urlString = self.request.URL.absoluteString;
    // 1. 从本地缓存读取
    id cachedResponse = [CacheManager shared].memoryCache[urlString];
    if (cachedResponse) {
        // 构造 NSURLResponse + NSError,通过 client 回调
        [self.client URLProtocol:self didReceiveResponse:cachedResponse.response
              cacheStoragePolicy:NSURLCacheStorageNotAllowed];
        [self.client URLProtocol:self didLoadData:cachedResponse.data];
        [self.client URLProtocolDidFinishLoading:self];
        return;
    }

    // 2. 未命中,发起真实请求
    // 注意用 NSURLSession 或 NSURLConnection 避免递归
    NSMutableURLRequest *newRequest = [self.request mutableCopy];
    [NSURLProtocol setProperty:@YES forKey:@"CachedByLocal" inRequest:newRequest];
    // ... 发起网络请求并在回调中更新缓存
}

- (void)stopLoading {
    // 清理临时连接
}
@end

// 注册(通常在 AppDelegate didFinishLaunching 中)
[NSURLProtocol registerClass:[LocalCacheURLProtocol class]];
陷阱提示:URLProtocol 在 WKWebView 中无法拦截主资源请求(document load),且对 POST 请求和 JavaScript 发起的 fetch 支持有限。如需完整拦截,考虑使用 WKNavigationDelegate + WKUserContentController 组合方案。 WKWebView 自 iOS 11 支持的 setURLSchemeHandler:forURLScheme: 仅适用于自定义 scheme,不适用于 http/https。

4.4 缓存命中逻辑

拦截器内部的命中判定需兼顾三个维度:

检查项判定条件示例
缓存存在性Key(URL + 查询参数)在存储中存在cache.get(url) != null
过期状态expireTime > now 且 age <= maxAge创建时间 + max-age / Expires 头部
验证头无强制 no-cache / no-store / must-revalidate检查 Cache-Control 指令
Etag / Last-Modified若本地缓存包含验证头,网络层可做条件请求在命中本地缓存后仍可触发 304 更新

简易命中伪代码:

CacheEntry get(String url, Map<String,String> requestHeaders) {
    CacheEntry entry = store.get(url);
    if (entry == null) return null;
    // 1. 检查是否明确禁止缓存
    if (entry.hasControl("no-store")) return null;
    // 2. 检查过期
    if (entry.expiry < System.currentTimeMillis()) {
        if (entry.hasControl("must-revalidate")) return null;
        // 允许 stale 时延长使用
    }
    // 3. 命中!
    return entry;
}

4.5 过期策略实现

过期策略基于 HTTP 缓存规范,具体字段优先级:

  • Cache-Control: max-age=3600 —— 相对于请求时间,最常用
  • Expires: Wed, 21 Oct 2025 07:28:00 GMT —— 时间,优先级低于 max-age
  • Cache-Control: s-maxage=3600 —— 仅对共享缓存有效
  • Cache-Control: no-cache / must-revalidate —— 强制每次验证

在拦截器中实现过期计算:

// Java 示例:解析并计算过期时间
long computeExpiryTime(Response response, long now) {
    CacheControl cc = response.cacheControl();
    if (cc.maxAgeSeconds() != -1) {
        return now + cc.maxAgeSeconds() * 1000L;
    }
    String expires = response.header("Expires");
    if (expires != null) {
        return parseDate(expires); // 使用 SimpleDateFormat
    }
    // 默认不缓存 / 启发式缓存(例如 last-modified 差值)
    return -1; // 无缓存
}
性能优化点:将过期时间与响应数据一起存储,避免每次命中时重新计算;内存缓存可配置容量上限,超出时淘汰最久未使用项;磁盘缓存写入使用异步队列,不阻塞拦截器主线程。

4.6 常见错误与稳定性优化

  1. 递归拦截——拦截器内再次发起相同 URL 的请求,导致无限循环。解决方案:在请求中注入标记头或 property 跳过二次拦截。
  2. Response body 消耗——OkHttp Response body 为一次性流,拦截器读取后必须重新包装,否则后续消费方获得空流。示例:ResponseBody.create(contentType, cachedBytes)。
  3. 线程安全——内存缓存需使用 ConcurrentHashMap 或 LruCache 本身线程安全,磁盘读写需要加锁或使用 SQLite。
  4. 内存泄漏——URLProtocol 的 client 引用持有不当,导致 WebView 无法释放。在 stopLoading 中清空所有强引用。
  5. SSL / 证书验证——自定义拦截器可能绕过系统证书链,确保保持 HostnameVerifier 和 SSLSocketFactory 的默认行为。