You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

自定义URLCache子类搭配URLSession时HTTP缓存逻辑失效问题排查

自定义URLCache子类与URLSession集成时的HTTP缓存逻辑问题

我开发的App基于URLSession实现网络请求,原本使用URLCache将请求结果存储到磁盘,但发现当磁盘缓存容量达到diskCapacity时,URLCache的回收策略会移除所有缓存条目,不符合业务需求。因此打算编写一个URLCache子类,实现自定义存储和可控的LRU缓存回收策略。

URLCache文档明确支持这种场景的子类化:

URLCache类设计为直接使用,但在有特定需求时可以子类化。例如,你可能需要筛选可缓存的响应,或出于安全等原因重新实现存储机制。

但将自定义子类与URLSession结合使用时遇到了问题:

测试资源的HTTP响应头包含:

  • Cache-Control: public, max-age=30
  • Etag:

使用标准URLCache时:首次请求从网络加载数据;30秒内的二次请求直接使用缓存;30秒后的请求会携带Etag发起条件GET请求,完全符合预期。

但使用自定义URLCache子类时,所有请求都从网络加载,max-age规则失效,也不会发起条件GET请求。看起来URLCache从内部存储加载CachedURLResponse实例后会执行特殊处理,而我忽略了这部分逻辑,导致影响了URLSession的HTTP缓存行为。

以下是复现问题的极简URLCache子类代码:

class CustomURLCache: URLCache {
    let cachedResponseFileURL = URL(filePath: NSTemporaryDirectory().appending("entry.data"))

    // MARK: Internal storage
    func read() -> CachedURLResponse? {
        guard let data = try? Data(contentsOf: cachedResponseFileURL) else { return nil }
        return try! NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as! CachedURLResponse
    }

    func store(_ cachedResponse: CachedURLResponse) {
        try! (try! NSKeyedArchiver.archivedData(withRootObject: cachedResponse, requiringSecureCoding: false)).write(to: cachedResponseFileURL)
    }

    // MARK: URLCache Overrides
    override func cachedResponse(for request: URLRequest) -> CachedURLResponse? {
        read()
    }

    override func getCachedResponse(for dataTask: URLSessionDataTask, completionHandler: @escaping (CachedURLResponse?) -> Void) {
        completionHandler(read())
    }

    override func storeCachedResponse(_ cachedResponse: CachedURLResponse, for request: URLRequest) {
        store(cachedResponse)
    }

    override func storeCachedResponse(_ cachedResponse: CachedURLResponse, for dataTask: URLSessionDataTask) {
        store(cachedResponse)
    }
}

测试用例代码:

func test() {
    let useEvictingCache = false
    let config = URLSessionConfiguration.default

    if useEvictingCache {
        config.urlCache = CustomURLCache()
    } else {
        config.urlCache = URLCache(memoryCapacity: 0, diskCapacity: 1024 * 1024 * 100)
    }

    self.urlSession = URLSession(configuration: config)

    let url = URL(string: "https://example.com/my-test-resource")!
    self.urlSession?.dataTask(with: URLRequest(url: url), completionHandler: { data, response, error in
        if let data {
            print("GOT DATA with \(data.count) bytes")
        } else if let error {
            print("GOT ERROR \(error)")
        }
    }).resume()
}

测试环境:iOS 16.2


问题原因与修复方案

核心问题在于你的自定义子类完全绕过了URLCache父类的缓存有效性校验逻辑,URLSession需要依赖这些逻辑来判断是否可以直接使用缓存,或是发起条件请求。标准URLCache内部会自动处理缓存过期校验、条件请求头生成等工作,而你的子类只是简单读写CachedURLResponse,缺失了这些关键步骤。

具体缺失的逻辑

  1. 缓存过期时间校验:URLSession需要知道缓存的响应时间(CachedURLResponse.responseDate)和max-age值,来判断缓存是否仍有效。如果你的存储没有正确保存responseDate,或者没有在返回缓存前做过期校验,URLSession会认为缓存不可用,直接走网络请求。
  2. 条件请求触发:当缓存过期时,标准URLCache会告知URLSession缓存存在但已过期,URLSession会自动提取Etag生成If-None-Match头发起条件GET请求。你的子类没有传递这层信息,导致URLSession直接发起全新请求。
  3. 完整元数据的序列化:NSKeyedArchiver虽然能序列化CachedURLResponse,但要确保所有缓存相关的元数据(比如responseDate、完整响应头)都被正确归档和恢复,否则URLSession无法识别缓存状态。

修复建议

  1. 保留缓存有效性校验逻辑:在返回缓存前,手动校验缓存是否过期,确保responseDate和max-age的计算正确:
    override func cachedResponse(for request: URLRequest) -> CachedURLResponse? {
        guard let cachedResponse = read() else { return nil }
        
        // 校验HTTP响应的缓存有效性
        guard let httpResponse = cachedResponse.response as? HTTPURLResponse,
              let cacheControl = httpResponse.allHeaderFields["Cache-Control"] as? String else {
            return cachedResponse
        }
        
        // 解析max-age值
        let regex = try! NSRegularExpression(pattern: #"max-age=(\d+)"#, options: [])
        if let match = regex.firstMatch(in: cacheControl, range: NSRange(cacheControl.startIndex..., in: cacheControl)),
           let maxAgeStr = Range(match.range(at: 1), in: cacheControl).map(String.init),
           let maxAge = Int(maxAgeStr) {
            let expirationDate = cachedResponse.responseDate.addingTimeInterval(TimeInterval(maxAge))
            // 如果缓存未过期,直接返回;即使过期也返回缓存,让URLSession发起条件请求
            return Date() <= expirationDate ? cachedResponse : cachedResponse
        }
        
        return cachedResponse
    }
    
  2. 确保元数据完整保存:检查NSKeyedArchiver是否完整保存了CachedURLResponse的responseDate属性——这个属性是判断缓存过期的核心,若缺失会导致URLSession无法计算缓存有效期。
  3. 不要完全绕过父类方法(可选):如果业务允许,可以在自定义存储的基础上,复用父类的部分逻辑,比如在存储时调用super.storeCachedResponse(_:for:),但这取决于你是否需要完全自定义存储机制。

另外,在实现LRU回收策略时,需要为每个缓存条目维护访问时间戳,当达到存储上限时,移除最久未使用的条目,同时保证每个条目的CachedURLResponse元数据完整。


内容的提问来源于stack exchange,提问作者Nikita Zhuk

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.02 11:01:29