云端 Mac 上的 iOS 内存缓存契约:隔离与淘汰测试

云端 Mac 上的 iOS 内存缓存契约:隔离与淘汰测试

同一张资料卡切换账户后仍显示旧内容,未必是网络缓存的问题:内存中的对象可能只按资源编号保存,没有包含账户身份。在 ArmShell 云端 Mac 上跑这类回归时,不必制造内存压力。先把键的身份、写入快照、清空入口和未命中行为写成测试,再单独观察系统淘汰。

先把缓存契约写清楚

本文只测试进程内的 NSCache,不涉及磁盘缓存与网络响应缓存。准备一个启用 XCTest 的 iOS 单元测试目标,将后面的类型与测试放入同一个测试文件。测试进程每次启动都重新创建缓存,不复用全局单例。

约定键由账户、资源编号和内容版本组成。相同编号在不同账户下不能互相命中;版本变化后,旧数据不能充当新版本结果。值保存为 Data,写入后调用方继续修改自己的变量,不应改变缓存中的快照。

缓存是可丢失的加速层,不是事实来源。任何一次读取都必须允许未命中,业务不能靠缓存对象一直存在才能正确运行。

这里不测试密码、令牌或完整用户资料。夹具只用短文本,失败日志也只记录用例名称与命中状态。

用完整身份构造键

不要直接把三个字段用短横线拼接:字段自身包含分隔符时可能产生歧义。下面通过对象相等性表达键身份;哈希碰撞可以存在,但相等性必须逐字段比较。NSCache 不会替你复制键,因此键字段保持不可变。

import Foundation
import XCTest

final class MemoryKey: NSObject {
    let account: String
    let resource: String
    let revision: String

    init(_ account: String, _ resource: String, _ revision: String) {
        self.account = account
        self.resource = resource
        self.revision = revision
    }

    override var hash: Int {
        var hasher = Hasher()
        hasher.combine(account)
        hasher.combine(resource)
        hasher.combine(revision)
        return hasher.finalize()
    }

    override func isEqual(_ object: Any?) -> Bool {
        guard let other = object as? MemoryKey else { return false }
        return account == other.account
            && resource == other.resource
            && revision == other.revision
    }
}

@MainActor
final class SessionMemoryCache {
    private let storage = NSCache<MemoryKey, NSData>()

    init() {
        storage.totalCostLimit = 4 * 1024 * 1024
    }

    func store(_ data: Data, for key: MemoryKey) {
        storage.setObject(
            NSData(data: data),
            forKey: key,
            cost: data.count
        )
    }

    func read(_ key: MemoryKey) -> Data? {
        guard let value = storage.object(forKey: key) else { return nil }
        return Data(referencing: value)
    }

    func clear() {
        storage.removeAllObjects()
    }
}

示例把包装层限制在主执行器,便于与界面状态一致地使用;这不是说 NSCache 本身只能在主线程访问。需要后台访问时,应另行定义隔离方式,而不是随手移除约束。

测试隔离、版本与快照

第一组测试故意为不同账户使用同一个资源编号,并用新建的等价键读取,避免测试只证明“同一个键对象可以读回来”。第二组修改原始 Data,再检查缓存值与显式清空。

final class SessionMemoryCacheTests: XCTestCase {
    @MainActor
    func testIdentityIsolation() {
        let cache = SessionMemoryCache()
        cache.store(Data([1]), for: MemoryKey("a", "card", "v1"))
        cache.store(Data([2]), for: MemoryKey("b", "card", "v1"))

        XCTAssertEqual(
            cache.read(MemoryKey("a", "card", "v1")), Data([1])
        )
        XCTAssertEqual(
            cache.read(MemoryKey("b", "card", "v1")), Data([2])
        )
        XCTAssertNil(cache.read(MemoryKey("a", "card", "v2")))
    }

    @MainActor
    func testSnapshotAndClear() {
        let cache = SessionMemoryCache()
        let key = MemoryKey("a", "card", "v1")
        var source = Data([1, 2])
        cache.store(source, for: key)
        source.append(3)

        XCTAssertEqual(cache.read(key), Data([1, 2]))
        cache.clear()
        XCTAssertNil(cache.read(key))
    }
}

这些小对象用于正常资源条件下的功能回归。如果系统恰好发生淘汰,命中断言仍可能失败。业务层的严格确定性测试应注入字典实现的缓存替身;不要让整个测试套件依赖系统保留对象。

还应加入同键覆盖用例:连续写入两个值,读取结果应是第二次写入的数据。空 Data 与未命中也要分开,前者是有效值,后者是 nil。

不把容量提示当成淘汰算法

示例的成本单位是字节,因为每次写入传入 data.count。totalCostLimit 的单位由调用者约定,它不是自动统计出的进程内存;对象、键和容器自身仍有额外开销。图片若保存的是解码结果,也不能用压缩文件长度代表实际成本。

countLimit 与 totalCostLimit 都不是严格上限。不能插入若干对象后,断言最早写入的那个一定消失,也不能假定 NSCache 实现了确定的最近最少使用策略。压力实验只报告命中率与进程内存变化,不作为每次合并必须通过的固定结果。

同样,不要靠无限分配大数组触发系统清理:这容易先杀死测试进程,得到的只是资源耗尽,不是缓存契约证据。

把未命中与退出流程接入验收

在业务读取层注入一个始终返回 nil 的缓存替身,验证加载器仍能获得权威数据。再注入已有值的替身,验证允许使用缓存的分支不会重复加载。这样可以直接检查两条路径,不需要等待真实淘汰。

退出账户时,先停止旧账户请求并更新会话身份,再清空缓存。仅清空不够:旧请求若随后完成,仍可能把数据写回。写入前应比较请求捕获的会话代次与当前代次,不一致便放弃写入;这一规则属于加载协调层,应独立测试。

最后检查四项:账户键是否来自可信会话、版本字段是否随内容更新、退出后旧请求能否回写、缓存未命中是否仍能完成加载。把稳定的业务断言放进日常回归,把不稳定的压力观察留作诊断,缓存才不会成为偶发故障的隐藏前提。

常见问题

设置 countLimit 后能断言缓存一定只剩指定数量的对象吗?

不能。countLimit 和 totalCostLimit 都不是可用于精确容量断言的硬限制。应测试未命中后的恢复路径;需要严格容量和淘汰顺序时,应实现独立的确定性缓存策略。

如何验证退出账户后不会读取上一个账户的缓存?

缓存键必须包含账户命名空间,退出时调用显式清空入口。测试应使用同一资源标识分别写入两个账户,再验证读取隔离和清空后的未命中。

为下一次构建准备云端 Mac

按工作负载选择机型与周期。独享 Apple Silicon 物理节点,非虚拟机。

立即租用云端 Mac

从阅读走到第一次构建

准备好工作负载,再选择适合自己的独享物理节点。