列表连续翻页后,缓存明明还没达到预期容量,刚看过的数据却被淘汰了。先别加内存。更常见的问题是读取没有刷新访问顺序,或者同键更新留下了重复记录。在云端 Mac 上,把缓存逻辑抽成独立 Swift Package,就能脱离界面、网络和模拟器,用几条确定的操作序列复现问题。
先写清五条缓存契约
本文验证按条目数限容的 LRU:最近最少使用的条目优先淘汰,不包含过期时间和磁盘持久化。先固定五条规则:
- 命中读取会把该键提升为最近使用。
- 未命中读取不改变已有顺序。
- 写入已有键只更新值、提升顺序,不增加条目数。
- 写入新键超过容量时,淘汰最久未使用的键。
- 容量为零时不保存数据;负容量属于调用错误。
这几条要由团队确认,而不是从实现细节反推。例如有些业务希望“读取不续命”,那就不是这里定义的访问策略,不能直接复用同一组预期。
命中率是运行指标,淘汰顺序是功能契约。命中率看起来正常,也可能隐藏同键重复和错误淘汰。
在独立目录实现可观察的模型
节点安装好完整工具链后,在独立工作目录执行下面两条命令。这里不需要管理员权限,也不需要启动图形界面。
mkdir LRUContract
cd LRUContract
swift package init --type library
将 Sources/LRUContract/LRUContract.swift 替换为以下实现。顺序数组从最旧排到最新,方便断言;它不是面向大容量缓存的性能实现。
public final class LRU<Key: Hashable, Value> {
private let capacity: Int
private var values: [Key: Value] = [:]
public private(set) var oldestFirst: [Key] = []
public init(capacity: Int) {
precondition(capacity >= 0)
self.capacity = capacity
}
public var count: Int { values.count }
public func get(_ key: Key) -> Value? {
guard let value = values[key] else { return nil }
touch(key)
return value
}
public func put(_ key: Key, _ value: Value) {
guard capacity > 0 else { return }
values.updateValue(value, forKey: key)
touch(key)
if values.count > capacity {
let victim = oldestFirst.removeFirst()
values.removeValue(forKey: victim)
}
}
private func touch(_ key: Key) {
oldestFirst.removeAll { $0 == key }
oldestFirst.append(key)
}
}
touch 先删除旧位置,再追加新位置,因此同键不会占两个顺序槽。这里用 updateValue 写入,也避免把字典下标赋值中的删除语义混入更新逻辑。生产实现若改成字典加双向链表,可以保留本文测试,但不要声称这个数组版本具有常数时间复杂度。
用短序列区分读取与更新错误
删除测试目录中自动生成的示例测试,将下面内容保存为 Tests/LRUContractTests/LRUContractTests.swift。显式使用 XCTest,不依赖初始化模板选择了哪种测试框架。
import XCTest
@testable import LRUContract
final class LRUContractTests: XCTestCase {
func testReadPromotesAndMissDoesNot() {
let cache = LRU<String, Int>(capacity: 2)
cache.put("a", 1)
cache.put("b", 2)
XCTAssertNil(cache.get("missing"))
XCTAssertEqual(cache.oldestFirst, ["a", "b"])
XCTAssertEqual(cache.get("a"), 1)
XCTAssertEqual(cache.oldestFirst, ["b", "a"])
cache.put("c", 3)
XCTAssertNil(cache.get("b"))
XCTAssertEqual(cache.oldestFirst, ["a", "c"])
XCTAssertEqual(cache.count, 2)
}
func testUpdateDoesNotConsumeAnotherSlot() {
let cache = LRU<String, Int>(capacity: 2)
cache.put("a", 1)
cache.put("b", 2)
cache.put("a", 9)
XCTAssertEqual(cache.count, 2)
XCTAssertEqual(cache.oldestFirst, ["b", "a"])
cache.put("c", 3)
XCTAssertNil(cache.get("b"))
XCTAssertEqual(cache.get("a"), 9)
}
func testZeroCapacityStoresNothing() {
let cache = LRU<String, Int>(capacity: 0)
cache.put("a", 1)
XCTAssertEqual(cache.count, 0)
XCTAssertTrue(cache.oldestFirst.isEmpty)
XCTAssertNil(cache.get("a"))
}
}
运行 swift test。第一项失败通常指向读取提升或未命中副作用;第二项失败优先检查同键更新;第三项防止零容量仍偷偷保留一项。不要只断言最终数量:数量正确,留下来的键也可能完全错误。
检查动作本身也可能改变顺序
get 不是只读观察接口。为了检查某个值而调用它,会改变后续淘汰对象。所以上例先检查顺序,再读取值;测试中不要随手遍历所有键调用 get,否则测试自身就在改写现场。
从三个用例扩展到序列验收
接入真实缓存时,补上容量为一、连续覆盖同键、连续未命中、淘汰后重新插入四类序列。每一步都检查:数量不超过容量,顺序没有重复键,顺序长度等于实际条目数。
进一步测试可以使用固定种子的伪随机操作,但种子之外还要保留完整操作列表。失败记录至少包含操作序号、键、写入值及执行前后的顺序,这样实现升级后仍能重放。
若生产版本使用链表,让简单数组模型充当参照,两边执行同一序列并比较结果。不要复制生产实现作为“标准答案”,否则同一个缺陷很容易在两边同时通过。
接回 iOS 工程时守住边界
示例没有同步机制。即使只有两个异步下载任务,也不能直接共享它。使用 actor 或统一串行入口后,读取提升、写入与淘汰必须一起受保护;只给写入加锁仍然会破坏顺序。
按条目数限容也不等于限制字节数。十张缩略图和十张原图的内存成本不同。若改为成本限容,需要另写总成本、同键成本替换及超大单项拒绝规则,不能把现有数量测试当成内存预算证明。
最后,将实际业务缓存适配到相同契约,而不是让测试只覆盖教学模型。纯算法测试通过后,再用少量集成用例验证下载完成后的写入与界面读取确实走同一实例。保留失败序列,比盲目扩大缓存容量更能解决问题。
常见问题
可以依赖 NSCache 保证严格的 LRU 淘汰顺序吗?
不可以。NSCache 没有承诺严格按最近最少使用顺序淘汰。业务若依赖确定的淘汰次序,应实现明确的顺序结构,并用操作序列验证。
示例缓存可以直接被多个并发任务共享吗?
不可以。示例没有同步保护。共享实例应由 actor 或统一的串行访问入口管理,而且读取提升与写入淘汰必须处于同一个隔离域。