Language: English · 简体中文
RECache is a modern, generic, memory + disk two-tier key-value cache for Swift. It was originally ported from YYCache and redesigned to embrace a fully generic, Codable-friendly, async/await-native Swift design. The SQLite + file-system storage engine and LRU algorithms that made YYCache fast are preserved verbatim — only the interface layer was rewritten. A few long-standing community fixes from stalled YYCache pull requests are also merged in: f874dd0 · ef9755b · c7efac0.
- Full generics —
MemoryCache<Key, Value>,DiskCache<Key, Value>,Cache<Key, Value>withKey: Hashable & Sendable,Value: Sendable Transformer<Value>serialization — built-in factories forData,Codable(JSON / binary plist), andUIImage/NSImage; or bring your own- Cache-level
Expiration—.never,.seconds(_:),.date(_:), evaluated at read time against each entry's write time - Two-tier architecture — a lock-free(ish) LRU linked-list memory layer on top of a SQLite + file-system disk layer that automatically picks the best storage medium per value
- LRU eviction — automatic trimming by count, cost, expiration, and free-disk-space
- Smart storage selection — values smaller than
inlineThreshold(default 20KB) live as SQLite blobs; larger values spill to standalone files - Sync and
async/awaitAPIs — every access method has a matchingasyncXxxvariant that dispatches off your thread extendedData— attach opaque metadata (ETag, source URL, decoding hints) to any cached entry without it affecting theTransformer- System event handling — automatic response to memory warnings, backgrounding, and app termination
- Swift 6 concurrency ready — all public types are
Sendable; every@unchecked Sendablecarries a documented safety invariant
dependencies: [
.package(url: "https://github.com/reers/RECache.git", from: "1.0.0")
]Then add RECache to your target's dependencies.
import RECache
struct Article: Codable, Sendable {
let id: Int
let title: String
let body: String
}
let cache = Cache<Int, Article>(name: "articles")!
// Sync
try cache.set(article, forKey: 42)
let fetched = try cache.value(forKey: 42) // memory first, disk fallback
// Async — same names, just add `await`. Sync versions are marked
// `@available(*, noasync)`, so calling them from an async context
// warns (or errors under Swift 6 strict concurrency).
try await cache.set(article, forKey: 42)
let fetched2 = try await cache.value(forKey: 42)
cache.remove(forKey: 42)
cache.removeAll()let blobs = DiskCache<String, Data>(path: "/tmp/blobs")!
try blobs.set(Data(...), forKey: "thumbnail")
let data = try blobs.value(forKey: "thumbnail")#if canImport(UIKit)
let images = DiskCache<URL, UIImage>(path: NSTemporaryDirectory() + "images")!
#endifWhen
ValueisCodable,Data, orUIImage/NSImage, the initializer automatically picks the matching built-inTransformer— no need to specify one. See Transformer for details and customization.
// Configured per layer — memory and disk have independent policies.
cache.memoryCache.expiration = .seconds(300)
cache.diskCache.expiration = .days(10)
// Or with an absolute deadline.
cache.memoryCache.expiration = .date(futureDate)
// Manual sweep
cache.memoryCache.removeExpired()
cache.diskCache.removeExpired()Semantics: expiration is measured from each entry's write time. Reading an entry moves it to the head of the LRU list but does not refresh the write time. set always refreshes. Expired entries are treated as misses and removed lazily on access; the disk layer tracks write time at second-level precision.
cache.memoryCache.countLimit = 500 // max number of entries
cache.memoryCache.costLimit = 50 * 1024 * 1024 // max total "cost" units
cache.diskCache.countLimit = 10_000
cache.diskCache.costLimit = 500 * 1024 * 1024 // max bytes on disk
cache.diskCache.freeDiskSpaceLimit = 100 * 1024 * 1024 // trim when disk free < 100MB
// Manual trim
cache.memoryCache.trim(toCount: 100)
cache.diskCache.trim(toCost: 50 * 1024 * 1024)The background auto-trim timer runs every autoTrimInterval seconds (5 for memory, 60 for disk by default).
extendedData is opaque Data persisted alongside a cached value but outside the Transformer — useful for things like HTTP ETag, decode hints, or cache provenance that you don't want inside your Codable struct.
try cache.set(
image,
forKey: url,
extendedData: "etag=abc123".data(using: .utf8)
)
if let meta = cache.extendedData(forKey: url) {
// ...
}
// Fetch both in one round-trip
if let (value, meta) = try cache.diskCache.valueWithExtendedData(forKey: url) {
// ...
}MemoryCache,DiskCache, andCacheare all@unchecked Sendable.MemoryCacheis protected byos_unfair_lock;DiskCacheby aDispatchSemaphore.asyncmethods dispatch onto an internalDispatchQueueso they never block the caller's thread.Transformer<Value>isSendable— encode / decode closures must be@Sendable.
Reading and writing the same key from multiple concurrent tasks is safe — operations are serialized by the lock. If you need to make multiple reads / writes atomic with respect to each other, protect them at the call site.
DiskCache needs a Transformer<Value> to convert values to/from Data. When you omit it from the initializer, the library auto-selects one based on Value:
Value type |
Auto-selected transformer | Notes |
|---|---|---|
Any Codable |
.codable() (JSON) |
Pass format: .binaryPlist if your payload is Data-heavy |
Data |
.data() |
Zero-copy pass-through |
UIImage / NSImage |
.image() |
PNG for alpha, JPEG otherwise |
You can always pass a Transformer explicitly — useful for compressed, encrypted, or otherwise non-standard payloads:
let cache = Cache<String, MyModel>(
name: "compressed",
transformer: Transformer(
encode: { try compress(JSONEncoder().encode($0)) },
decode: { try JSONDecoder().decode(MyModel.self, from: decompress($0)) }
)
)!┌─────────────────────────────────────────────────────────────┐
│ Cache<Key, Value> │
│ (coordinates memory + disk, Transformer is shared) │
└────────────────────────┬────────────────────────────────────┘
│
┌────────────────┴────────────────┐
│ │
┌───────▼─────────┐ ┌────────▼────────────┐
│ MemoryCache │ │ DiskCache │
│ <Key, Value> │ │ <Key, Value> │
│ ─────────────── │ │ ─────────────────── │
│ LRU linked list │ │ raw transformer │
│ os_unfair_lock │ │ payload │
│ UIKit warnings │ │ DispatchSemaphore │
└─────────────────┘ └──────────┬──────────┘
│
┌────────▼────────┐
│ KVStorage │
│ (internal) │
│ ─────────────── │
│ SQLite manifest │
│ + data files │
│ + trash folder │
└─────────────────┘
KVStorage is kept internal; app code talks to it only via DiskCache or Cache. Each disk entry is exactly what the Transformer produces — no header, no envelope — and its write time comes from the SQLite manifest's modTime (second-level precision) used for cache-level expiration checks.
Any Hashable & Sendable type works as a key. On disk, keys are converted to stable strings via String(describing:); override CustomStringConvertible.description to control the on-disk representation:
struct ItemKey: Hashable, CustomStringConvertible {
let userID: Int
let scope: String
var description: String { "\(scope)-\(userID)" }
}For file names (used when values spill to standalone files on disk), override via DiskCache.fileNameProvider:
diskCache.fileNameProvider = { key in "item-\(key.userID)" }Tested on iPhone 15 Pro, 200,000 key-value pairs for memory, 1,000 key-value pairs for disk. All times in milliseconds (lower is better).
| Test | YYMemoryCache | RECache | NSCache | Swift Dict | Dict + Lock |
|---|---|---|---|---|---|
| set (200k pairs) | 61.78 | 27.08 | 55.69 | 9.43 | 9.07 |
| set (no resize) | 31.52 | 18.80 | 34.81 | 2.63 | 4.43 |
| get (sequential) | 20.95 | 17.11 | 21.56 | 2.72 | 2.95 |
| get (random) | 31.09 | 32.29 | 31.97 | 5.05 | 5.00 |
| get (mixed hit/miss) | 28.49 | 27.86 | 24.93 | 6.32 | 6.68 |
Swift Dict / Dict + Lock are baselines without LRU, eviction, or thread safety overhead.
Columns are grouped file mode · SQLite mode · mixed (default). YY (file) / YY (SQLite) talk to YYKVStorage directly; YYDiskCache is the default YYDiskCache (mixed mode). Likewise, RECache (file) / RECache (SQLite) pin DiskCache with inlineThreshold: 0 / .max; RECache is the default mixed mode that auto-routes small payloads to SQLite and large ones to the file store.
| Test | YY (file) | RECache (file) | YY (SQLite) | RECache (SQLite) | YYDiskCache | RECache |
|---|---|---|---|---|---|---|
| set NSNumber | 322.54 | 386.41 | 49.23 | 57.36 | 49.96 | 51.14 |
| set Data (100KB) | 461.79 | 396.86 | 624.43 | 542.96 | 523.38 | 414.73 |
| replace NSNumber | 189.90 | 173.06 | 72.14 | 70.53 | 75.20 | 74.62 |
| replace Data (100KB) | 305.61 | 227.51 | 546.03 | 562.34 | 296.51 | 215.49 |
| Test | YY (file) | RECache (file) | YY (SQLite) | RECache (SQLite) | YYDiskCache | RECache |
|---|---|---|---|---|---|---|
| get NSNumber (random, cold) | 152.90 | 154.20 | 29.78 | 31.28 | 28.66 | 37.55 |
| get Data 100KB (random, cold) | 252.16 | 252.30 | 525.05 | 519.32 | 289.61 | 247.95 |
| get NSNumber (random, warm) | 149.28 | 148.06 | 36.82 | 36.59 | 32.48 | 39 |
| get Data 100KB (random, warm) | 246.95 | 249.26 | 539.86 | 537.50 | 279.30 | 243.34 |
| get none exist (small) | 1.94 | 1.95 | 1.98 | 1.98 | 2.00 | 1.95 |
| get none exist (large) | 1.84 | 1.81 | 1.87 | 1.83 | 1.86 | 1.83 |
RECachepays a small fixed overhead per lookup to apply theTransformer/Codableround-trip (49.96 → 51.14for NSNumber writes,28.66 → 37.55for NSNumber reads). In exchange, the default mixed mode wins on the workloads that actually dominate in practice — 100 KBDatapayloads, both write and read, cold and warm.
RECache is released under the MIT license. Portions derive from YYCache, also MIT. See LICENSE.
- YYCache by ibireme — the SQLite + file-system storage engine and the LRU designs this library builds on.