搭建本地缓存拦截器:设计、注入与命中策略
本地缓存拦截器是 WebView 缓存架构的核心执行层,它拦截每一次网络请求,在请求发出前决定是否从本地缓存返回,在响应到达后决定是否更新缓存。本章聚焦两大平台的主流拦截机制——Android 的 OkHttp Interceptor 与 iOS 的 URLProtocol,并详解缓存命中逻辑与过期策略的实现。
关键前提:拦截器只处理应用层 HTTP(S) 请求,不处理自定义协议或 WebSocket;且在
WebView 注册前完成注入才能生效。
4.1 拦截器工作流程
拦截器在请求/响应链路中以下图方式插入,每个请求都经过“读缓存 → 检查过期 → 网络请求 → 写缓存”的闭环。注意自定义拦截器与框架内置拦截器的执行顺序:OkHttp 按添加顺序形成链条,URLProtocol 则通过注册覆盖系统默认处理。
4.2 OkHttp 拦截器设计与注入
Android 端将自定义缓存规则封装为 Interceptor,通过 OkHttpClient.Builder 链式注入。关键点:
- 使用
Application Interceptor(不区分重定向/重试)或Network Interceptor(区分真实网络流量) - 缓存逻辑在
intercept(Chain)方法中实现 - 维护一个并发安全的本地缓存数据库或 LRU 文件缓存
// OkHttp 自定义缓存拦截器 - 仅展示结构
public class LocalCacheInterceptor implements Interceptor {
private final LruCache 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 常见错误与稳定性优化
- 递归拦截——拦截器内再次发起相同 URL 的请求,导致无限循环。解决方案:在请求中注入标记头或 property 跳过二次拦截。
- Response body 消耗——OkHttp Response body 为一次性流,拦截器读取后必须重新包装,否则后续消费方获得空流。示例:
ResponseBody.create(contentType, cachedBytes)。 - 线程安全——内存缓存需使用
ConcurrentHashMap或LruCache本身线程安全,磁盘读写需要加锁或使用 SQLite。 - 内存泄漏——URLProtocol 的
client引用持有不当,导致 WebView 无法释放。在stopLoading中清空所有强引用。 - SSL / 证书验证——自定义拦截器可能绕过系统证书链,确保保持
HostnameVerifier和SSLSocketFactory的默认行为。