雲端 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

從閱讀到第一次建置

準備好工作負載,再挑選適合自己的獨享實體節點。