Asynchronously loading images into table and collection views
At a glance
| Item |
Summary |
| Purpose |
Loads many images without blocking list scrolling, caches results, coalesces duplicate requests, and reloads items by stable identity. |
| App architecture |
Table and collection controllers share a process-wide ImageCache; Item identities anchor diffable snapshots, while a custom URL protocol simulates cancellable latency. |
| Main patterns |
Shared cache, request coalescing, diffable identity, asynchronous main-thread handoff, and URLProtocol test double. |
| Project style |
One UIKit target with parallel table/collection consumers and a small loading subsystem. |
Project structure
Async Image Loading/
├── TableViewController.swift
├── CollectionViewController.swift
├── Item.swift
├── ImageCache.swift
├── ImageURLProtocol.swift
└── AppDelegate.swift
Structure observations
- Table and collection screens deliberately duplicate only presentation setup; both use the same cache contract.
- Cache policy and simulated transport/cancellation are separate types.
- Mutable image content sits on stable-identity
Item reference objects used by snapshots.
Overall architecture
flowchart LR
Table["TableViewController"] --> Items["Item identities"]
Collection["CollectionViewController"] --> Items
Table --> Cache["ImageCache.publicCache"]
Collection --> Cache
Cache --> Memory["NSCache URL to UIImage"]
Cache --> Inflight["URL to completion list"]
Cache --> Session["URLSession"]
Session --> Protocol["ImageURLProtocol"]
Protocol --> Data["Delayed local file data"]
Cache -->|main queue completion| Items
Items --> Snapshot["Diffable snapshot reload"]
Reference code
Async Image Loading/ImageCache.swift:20 — the cache returns hits immediately and merges concurrent misses for the same URL into one completion list.
final func load(url: NSURL, item: Item,
completion: @escaping (Item, UIImage?) -> Void) {
if let cachedImage = image(url: url) {
DispatchQueue.main.async { completion(item, cachedImage) }
return
}
if loadingResponses[url] != nil {
loadingResponses[url]?.append(completion)
return
}
loadingResponses[url] = [completion]
ImageURLProtocol.urlSession().dataTask(with: url as URL) { data, _, error in
// decode, cache, and dispatch completions to the main queue
}.resume()
}
Interpretation
Consumers request by URL but update by Item identity. The cache owns transport and deduplication; list controllers remain responsible for verifying the item is still in their current snapshot before mutating/reloading it.
Ownership and state
classDiagram
ImageCache *-- NSCache : owns decoded images
ImageCache *-- Dictionary : owns in-flight callbacks
TableViewController *-- Array~Item~ : owns items
CollectionViewController *-- Array~Item~ : owns items
Item *-- UUID : stable identity
Item o-- UIImage : current placeholder or result
ListController *-- DiffableDataSource : owns
URLSession *-- ImageURLProtocol : creates per request
ImageURLProtocol *-- DispatchWorkItem : owns until completion or cancellation
Ownership evidence
Async Image Loading/ImageCache.swift:9 — one shared cache owns private decoded and in-flight state.
public class ImageCache {
public static let publicCache = ImageCache()
var placeholderImage = UIImage(systemName: "rectangle")!
private let cachedImages = NSCache<NSURL, UIImage>()
private var loadingResponses = [NSURL: [(Item, UIImage?) -> Void]]()
}
| Owner |
Object or state |
Relationship |
Mutation authority |
ImageCache.publicCache |
Decoded cache and in-flight callback registry |
Process-wide owner |
Cache methods only |
| Each list controller |
Item array, diffable source/snapshot |
Creates and retains |
Cell-provider completion on main queue |
Item |
Stable UUID, URL, current image |
Reference model |
List completion updates image |
ImageURLProtocol instance |
Cancellation flag and work item |
Per request |
startLoading / stopLoading on serial queue |
Class and protocol design
| Type |
Responsibility |
Depends on |
ImageCache |
Cache lookup, request coalescing, fetch, decode, main-queue callback |
NSCache, URLSession |
ImageURLProtocol |
Simulate delayed file transport and cancellation |
Serial dispatch queue, URLProtocolClient |
Item / Hashable |
Stable diffable identity plus mutable image payload |
UUID, URL, UIImage |
| Table/collection controllers |
Build items, configure cells, validate/reload snapshots |
Diffable data sources, shared cache |
No app-defined protocol abstraction is present; URLProtocol subclassing supplies the transport substitution point.
Access control
| Symbol |
Access |
Verified effect |
Likely rationale |
ImageCache and publicCache |
public |
Can be referenced outside the app module if packaged; singleton is immutable. |
Demonstrates a reusable cache entry point, though the sample target does not require public scope. |
| Decoded cache and callback registry |
private |
Consumers cannot bypass cache/coalescing invariants. |
Preserve one loading path. |
load |
implicit internal final |
Target consumers can request images; subclasses cannot override behavior. |
Internal app operation with fixed semantics. |
Controller imageObjects |
private |
Other files cannot mutate each screen’s displayed collection. |
Snapshot/item consistency stays local. |
| URL protocol queue |
private static |
All request state transitions use one hidden queue. |
Serialize cancellation/completion state. |
Reference code
Async Image Loading/TableViewController.swift:9 — the screen exposes its diffable source to its own code but keeps the backing item collection private.
class TableViewController: UITableViewController {
var dataSource: UITableViewDiffableDataSource<Section, Item>! = nil
private var imageObjects = [Item]()
}
Logic ownership and placement
| Logic |
Owning type or file |
Placement rationale |
| Caching, deduplication, decode |
ImageCache |
Shared across all list consumers. |
| Transport delay and cancellation |
ImageURLProtocol |
URL-loading test boundary. |
| Stable identity |
Item |
Snapshot correctness follows item UUID, not row position. |
| Cell/snapshot update |
Each list controller |
Must validate against its current data source. |
Design patterns
| Pattern |
Source evidence |
Purpose or tradeoff |
| Shared cache |
Async Image Loading/ImageCache.swift:11 |
Reuses decoded images across both screens; global lifetime increases memory scope. |
| Request coalescing |
Async Image Loading/ImageCache.swift:29 |
Avoids duplicate work for simultaneous URL requests. |
| Diffable identity |
Async Image Loading/Item.swift:13 |
Lets async results reload the correct item after list changes. |
| Main-thread handoff |
Async Image Loading/ImageCache.swift:24 |
Delivers UI-facing completions on the main queue. |
| Protocol test double |
Async Image Loading/ImageURLProtocol.swift:9 |
Exercises asynchronous/cancellation behavior with bundled files. |
Naming conventions
ImageCache names policy/ownership; ImageURLProtocol names the URL-loading adapter.
publicCache explicitly signals shared scope; cachedImages and loadingResponses distinguish completed versus in-flight state.
Item.identifier names stable identity, while image is the mutable presentation payload.
Architecture takeaways
- Centralize caching and coalesce requests by resource identity.
- Return async results with stable item identity, then confirm current snapshot membership before reloading.
- Keep backing item arrays private to the screen that owns its snapshot.
- Test latency and cancellation through a URL-loading substitution rather than UI sleeps.
Source map
| Source file |
Relevant symbols |
Async Image Loading/ImageCache.swift |
Cache and in-flight request coalescing |
Async Image Loading/ImageURLProtocol.swift |
Delayed cancellable transport |
Async Image Loading/Item.swift |
Diffable identity and mutable image |
Async Image Loading/TableViewController.swift |
Table consumer/snapshot reload |
Async Image Loading/CollectionViewController.swift |
Collection consumer/layout |