A small, reusable async image loading + caching component for iOS / macOS / visionOS. Shipped as a separate SPM library product so any consumer (RichText inline images, an ActionUIChat image bubble, a standalone image element) can depend on just this.
Two design goals drive everything:
- Nothing image-heavy runs on the main thread. Byte fetch, decode, downscale, and corner rounding all happen off-main; the ONLY main-thread work is the final completion callback. Long, image-heavy scrolling stays smooth.
- Image dimensions are cheap to obtain BEFORE the pixels hydrate, so layout reserves the exact box and never reflows on hydration - even across app relaunch.
public typealias PlatformImage = UIImage // NSImage on macOS
// The cache variant key: a URL plus optional presentation transforms.
public struct ImageRequest: Hashable, Sendable {
public init(url: URL, targetWidth: CGFloat? = nil, cornerRadius: CGFloat = 0)
}
public final class ImageStore: @unchecked Sendable {
public static let shared: ImageStore
public init(name: String = "default", memoryCountLimit: Int = 150,
memoryByteLimit: Int = ImageStore.recommendedMemoryByteLimit(), // scales with device RAM
diskByteLimit: Int = 200 * 1024 * 1024)
// A per-tier memory budget scaled to physical RAM (a fraction, clamped per platform). macOS gets a higher
// ceiling than iOS, where NSCache's automatic memory-pressure purge is the real safety net.
public static func recommendedMemoryByteLimit() -> Int
// Synchronous, thread-safe reads (fast; safe to call during layout):
public func cachedImage(for request: ImageRequest) -> PlatformImage? // ready-to-draw variant, nil on miss
public func cachedPixelSize(for url: URL) -> CGSize? // natural size WITHOUT decoding (see below)
public func cachedOriginalBytes(for url: URL) -> (data: Data, pixelSize: CGSize)? // raw source bytes, e.g. for copy
// Asynchronous load; completion fires on the MAIN thread. Concurrent identical requests are de-duplicated.
public func load(_ request: ImageRequest, completion: @escaping (PlatformImage?) -> Void)
public func clearMemory()
public func removeAll() // memory + disk
}
// SwiftUI view (named for the cache, to avoid confusion with SwiftUI's AsyncImage):
public struct CachedImage: View {
public init(url: URL?, intrinsicSize: CGSize? = nil, cornerRadius: CGFloat = 0,
contentMode: ContentMode = .fill, maxPixelWidth: CGFloat? = nil, store: ImageStore = .shared)
}
// SwiftUI: reserves the box from `intrinsicSize` (e.g. server dims) -> zero reflow; loads off-main.
CachedImage(url: url, intrinsicSize: CGSize(width: 1024, height: 768), cornerRadius: 10)
// Direct store use (e.g. a UIView/NSView): reserve size synchronously, then hydrate.
let size = ImageStore.shared.cachedPixelSize(for: url) // may be non-nil even on first launch of this run
ImageStore.shared.load(ImageRequest(url: url, targetWidth: 840, cornerRadius: 10)) { image in
// on the main thread
}- Two-tier cache:
- In memory: an
NSCacheof ready-to-draw variants (keyed by the fullImageRequest) + anNSCacheof originals (raw bytes + natural pixel size). Each tier is bounded by BOTH a count limit and a byte (totalCostLimit) budget: variant cost is the DECODED bitmap size (width*height*4), original cost is the compressed transport size. So a handful of large photos evict on wired-RAM pressure, not just on count -memoryByteLimitcaps each tier (default 100 MB). - On disk: the ORIGINAL transport bytes under
<Caches>/AsyncImageCache/<name>/, filenames =SHA256(url), with a soft byte budget enforced by an LRU-ish trim (oldest-by-mtime). Survives relaunch; the OS may still evict Caches (a miss just falls through to the network).
- In memory: an
- Byte resolution order (off-main): in-memory originals -> disk -> source (
data:/file:decode locally,http(s)viaURLSession). - Variant key quantization:
targetWidth/cornerRadiusareCGFloatin the API (caller convenience) but QUANTIZED to whole pixels in the cache key, so sub-pixel layout jitter does not mint a distinct variant per fractional value; the store scales to the same quantized values. - Format-agnostic: the cache stores bytes and hands back originals via
cachedOriginalBytes. It does NOT transcode - copy/embedding concerns (PNG/JPEG/GIF choices, RTF still frames) belong to the consumer. pixelSizereads platform metadata, not a forcedCGImage:UIImage.size * scale/NSImageRep.pixelsWide/High(NSImage'scgImage(forProposedRect:)can rasterize).
Within a run, a loaded image's size lives in the memory cache. Across relaunch the memory tier is empty but
the disk bytes persist - so the natural pixel size is stored as an 8-byte extended attribute (two Int32) ON
the cached bytes file (setxattr/getxattr, <sys/xattr.h>; works on iOS as well as macOS - Foundation
re-exports the Darwin C API, no bridging header; xattr name public.asyncimagecache.pixelsize). So:
cachedPixelSize resolves: memory originalCache -> pixelSizeMemo (an NSCache of xattr-read sizes) ->
disk xattr (cheap getxattr, no decode; memoized). A cached image therefore reserves its exact box before its
pixels arrive, even on the first frame after a relaunch.
Fallback (resilience): should the xattr ever be missing while the bytes are still on disk - it was stripped, or
the bytes were cached by a build that predates the attribute - the size is recovered from the image HEADER via
CGImageSource (still no full decode), and the xattr is REPAIRED so the next read is back on the fast path. So
a lost attribute costs one slightly-slower read, never a wrong box or a full decode.
Measured with XCTest measure (see AsyncImageCacheTests), 10 iterations, per whole batch of 100:
| Getting the natural size 100x | Total (avg) | Per file |
|---|---|---|
xattr read (getxattr, 8 bytes) |
~7 ms | ~70 us |
CGImageSource header (no decompress) |
~10 ms | ~100 us |
| read file + decode | ~15 ms | ~150 us |
Reading:
- The xattr read is the fastest and, crucially, O(1) in image size - it touches 8 bytes regardless of whether the image is 8x8 or 6000x4000, while the decode/header paths grow with the file. A real multi-MB photo (a smooth gradient compresses well, so this is a conservative contrast) widens the gap further.
- Honestly,
CGImageSource's header read is a close, decode-free alternative that needs no extra write. The xattr still edges it (format-independent, no file open) and, once read, memoizes to a nanosecond memory hit;CGImageSourcere-opens + parses each call unless cached the same way. The real win in either approach is the memoization that makes steady-state layout queries free.
All three paths are well under a millisecond per lookup, so obtaining dimensions is never the bottleneck; the point is that the xattr keeps it that way as images get large.