クラウド Mac で検証する iOS メモリキャッシュの契約
アカウントを切り替えても同じ情報カードに古い内容が表示される場合、原因はネットワークキャッシュとは限りません。メモリ内のオブジェクトがリソース ID だけをキーにして保存され、アカウントの識別情報が含まれていない可能性があります。ArmShell のクラウド Mac でこの種の回帰テストを実行する際、メモリプレッシャーを発生させる必要はありません。まずはキーの同一性、書き込み時のスナップショット、明示的な消去、キャッシュミス時の動作をテストし、システムによる追い出しは別途観察します。
まずキャッシュの契約を明確にする
この記事でテストするのは、プロセス内の NSCache だけです。ディスクキャッシュやネットワークレスポンスのキャッシュは扱いません。XCTest を有効にした iOS の単体テストターゲットを用意し、以降の型とテストを同じテストファイルに配置します。キャッシュはテストプロセスの起動ごとに新しく作成し、グローバルなシングルトンは使い回しません。
キーは、アカウント、リソース ID、コンテンツのバージョンで構成するものとします。リソース ID が同じでも、アカウントが異なれば互いのデータにヒットしてはいけません。バージョンが変わった場合も、古いデータを新しいバージョンの結果として返してはいけません。値は Data として保存し、書き込み後に呼び出し元が手元の変数を変更しても、キャッシュ内のスナップショットは変わらないようにします。
キャッシュは失われてもよい高速化レイヤーであり、正とするデータの取得元ではありません。どの読み取りでもキャッシュミスが起こり得るものとし、キャッシュ内のオブジェクトが残り続けることを、業務ロジックが正しく動作するための前提にしてはいけません。
ここではパスワード、トークン、ユーザープロフィール全体はテストに使いません。フィクスチャには短いテキストだけを使い、失敗ログにもテストケース名とヒットの有無だけを記録します。
識別に必要な情報をすべて含むキーを作る
3 つのフィールドを単純にハイフンで連結するのは避けてください。フィールド自体に区切り文字が含まれると、区別できない組み合わせが生じる可能性があります。以下では、オブジェクトの等価性によってキーの同一性を表現します。ハッシュ値の衝突は許容できますが、等価性は必ずフィールドごとに比較する必要があります。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()
}
}
この例では、UI の状態と整合性を保ちながら利用しやすいように、ラッパーをメインアクターに隔離しています。NSCache 自体がメインスレッドからしかアクセスできないという意味ではありません。バックグラウンドからのアクセスが必要な場合は、安易にこの制約を外すのではなく、隔離の方針を別途定義してください。
アカウントの分離、バージョン、スナップショットをテストする
最初のテストでは、異なるアカウントに対して意図的に同じリソース ID を使い、新しく作った等価なキーで読み取ります。これにより、「同じキーオブジェクトを渡せば読み戻せる」ことしか確認できていないテストを避けます。次のテストでは、元の 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))
}
}
これらの小さなオブジェクトは、リソースに余裕がある通常の環境で機能の回帰テストを行うためのものです。それでも、たまたまシステムがオブジェクトを追い出せば、ヒットを期待するアサーションは失敗する可能性があります。業務ロジック層で厳密に決定的なテストが必要な場合は、辞書で実装したキャッシュのテストダブルを注入してください。テストスイート全体を、システムがオブジェクトを保持し続けることに依存させてはいけません。
同じキーへの上書きもテストに追加します。2 つの値を続けて書き込んだら、読み取り結果は 2 回目に書き込んだデータになるはずです。また、空の Data とキャッシュミスも区別してください。前者は有効な値であり、後者は nil です。
容量の目安を追い出しアルゴリズムとみなさない
この例では、書き込みのたびに data.count を渡しているため、コストの単位はバイトです。totalCostLimit の単位は呼び出し側が決めるものであり、プロセスのメモリ使用量が自動的に集計されるわけではありません。オブジェクト、キー、コンテナー自体にも追加のオーバーヘッドがあります。画像をデコード済みの状態で保存する場合も、圧縮ファイルのサイズを実際のコストとして使うことはできません。
countLimit と totalCostLimit は、どちらも厳密な上限ではありません。いくつかのオブジェクトを挿入した後で、最初に書き込んだオブジェクトが必ず消えると断言してはいけません。また、NSCache が決定的な LRU(最も長く使われていないものを優先する)方式を実装していると仮定することもできません。負荷実験ではヒット率とプロセスのメモリ使用量の変化だけを報告し、マージのたびに必ず満たすべき固定の合格条件にはしません。
同様に、巨大な配列を際限なく確保してシステムのクリーンアップを引き起こそうとするのも避けてください。キャッシュが整理される前にテストプロセスが終了させられやすく、確認できるのはリソースの枯渇であって、キャッシュの契約ではありません。
キャッシュミスとログアウト処理を受け入れテストに組み込む
業務ロジックの読み取り層に、常に nil を返すキャッシュのテストダブルを注入し、それでもローダーが正とするデータを取得できることを確認します。次に、値が入ったテストダブルを注入し、キャッシュの使用を許可する分岐では重複した読み込みが発生しないことを確認します。これなら、実際の追い出しを待たずに両方の経路を直接検証できます。
ログアウト時は、まず旧アカウントのリクエストを停止してセッションの識別情報を更新し、その後でキャッシュを消去します。消去するだけでは不十分です。旧リクエストが後から完了すると、データが再び書き込まれる可能性があります。書き込み前に、リクエストが保持しているセッションの世代と現在の世代を比較し、一致しなければ書き込みを破棄してください。このルールは読み込みを調整する層の責務であり、独立してテストする必要があります。
最後に確認するのは、アカウントのキーが信頼できるセッションに由来しているか、コンテンツの更新に合わせてバージョンフィールドが変わるか、ログアウト後に旧リクエストが書き戻せてしまわないか、キャッシュミスでも読み込みを完了できるか、の 4 点です。安定した業務ロジックのアサーションは日常の回帰テストに組み込み、不安定な負荷時の観察は診断用に分けておきます。そうすることで、キャッシュの存在が、時折発生する不具合の隠れた前提になるのを防げます。
よくある質問
countLimit を設定すればオブジェクト数を厳密に検証できますか?
できません。countLimit と totalCostLimit は厳密な上限ではありません。未ヒット時の復元処理を検証し、容量や削除順の保証が必要なら独自の確定的な方式を実装します。
別アカウントのキャッシュを読まないことはどう確認しますか?
キーにアカウントの名前空間を含め、ログアウト時に明示的に消去します。同じリソース識別子を二つのアカウントで使い、結果の分離と消去後の未ヒットを検証します。
次のビルドに備えてクラウドMacを準備
ワークロードに合わせて、マシンと利用期間をお選びください。Apple Silicon搭載の専有物理ノードを利用でき、仮想マシンではありません。
クラウドMacをレンタル