Skip to content

Repository files navigation

OptimizedGridLoader

A production-grade iOS demo that loads, caches, and renders thousands of remote images inside a UICollectionView — with butter-smooth, 60 fps scrolling and zero third-party dependencies.

Purpose: Serve as a reference implementation for engineers looking to squeeze every frame out of UICollectionView image grids without reaching for SDWebImage, Kingfisher, or Nuke.


Why This Project Exists

Most image-grid demos stop at "works on simulator" quality, but real apps fail under stress: fast flick scrolling, unstable networks, low-memory devices, and repeated navigation between list/detail screens.

This project exists to show a practical performance baseline for production-like conditions:

  • Clear separation of concerns (View + ViewModel + Service + Router) so performance improvements do not destroy maintainability.
  • A fully native stack (UIKit, URLSession, NSCache, URLCache, CGImageSource) to prove third-party libraries are optional, not mandatory.
  • A measurable pipeline where each optimization is explicit, testable, and explainable to teams.

In short, this repo is a "performance-first reference app" that teaches why each decision improves scroll smoothness, memory behavior, and user-perceived speed.


Trade-Off Analysis

Every optimization has a cost. The table below documents the key trade-offs chosen in this project.

DecisionBenefitCost / Trade-offWhy It Is Acceptable Here
Server-side thumbnail sizingDramatically smaller payloads, faster first paintRequires URL-building logic and depends on API support for dynamic sizesPicsum supports dynamic dimensions; this is the highest impact optimization
Actor-based ImageLoader + request dedupData-race safety, fewer duplicate requests, cleaner concurrency modelSlightly more complex mental model than naive callback loadingComplexity is localized in one utility, while app-level code remains simple
Two-tier cache (NSCache + URLCache)Fast repeat renders + reduced network usage across launchesNeed cache-size tuning; too large caches may increase memory/disk pressureCache limits are bounded and can be tuned per product needs
CGImageSource downsamplingLower peak memory and decoding cost vs full decodeMore verbose code than UIImage(data:)Worth it for large lists where decode spikes cause dropped frames
Prefetch + cancellationBetter perceived smoothness when scrolling forwardMore lifecycle handling (start/cancel) and edge-case managementManaged with Task cancellation and cell reuse guards
Custom zoom transitionStrong UX continuity between grid and detailMore navigation complexity and animation maintenanceIsolated behind Router + transition delegate, with fallback animation path
Combine state bindingReactive and scalable UI state propagationAdds a framework dependency in mental model (publisher/subscriber)Fits iOS 16+, removes callback plumbing as state grows

Metrics (Measurement Results)

The numbers below are from a repeatable local profiling run and should be treated as reference values, not absolute guarantees for all devices/networks.

Test Setup

  • Device profile: iPhone 14 simulator (iOS 16+ runtime)
  • Network profile: normal Wi-Fi, Picsum public API
  • Dataset pattern: multiple pages loaded with rapid up/down scroll
  • Tools: Xcode Instruments (Time Profiler + Memory), Network report, FPS gauge

Key Results (Representative)

MetricNaive Grid Baseline*OptimizedGridLoaderImprovement
Avg payload per image~2.0-3.0 MB~8-20 KB~100x to 300x less data
Time-to-first-visible-image~700-1200 ms~120-250 ms~3x to 6x faster
Smooth scrolling FPS (avg)~35-48 fps~56-60 fps+12 to +25 fps
Peak memory during fast scroll~280-420 MB~90-160 MB~2x to 3x lower
Duplicate requests for same URLFrequentNear-zero (deduped)Major bandwidth savings
Revisit screen load behaviorMany re-downloadsMostly cache hitsSignificantly faster revisits

*Baseline refers to a conventional implementation using full-size downloads, no deduplication, minimal cache strategy, and no decode downsampling.

How to Reproduce Metrics

  1. Run once to warm up caches, then terminate.
  2. Relaunch, load at least 3 pages, and perform rapid scroll sweeps for 30-60 seconds.
  3. Capture FPS + memory with Instruments.
  4. Compare against a naive branch/implementation under the same device and network profile.

For production benchmarking, always validate on real devices, including lower-memory iPhones and constrained networks (e.g., Network Link Conditioner).


What's New in This Version

ChangeDetails
Combine-based bindingsHomeViewModel exposes @Published properties; HomeViewController subscribes via AnyCancellable — callback closures eliminated
Detail screenTapping a cell navigates to DetailViewController, which loads the full-resolution image with a cross-dissolve transition
Custom zoom transitionPhotoZoomAnimator drives a shared-element expand/collapse animation between the grid cell and the detail image view
Router layerA Router / RouterDelegate abstraction owns all navigation logic and wires PhotoNavigationControllerDelegate for the custom transition

Highlights

FeatureDescription
Server-side resizingRequests device-appropriate thumbnails (~10 KB) instead of full-res originals (~3 MB)
Two-tier cachingIn-memory NSCache for decoded images + 200 MB disk URLCache for raw responses
Request deduplicationConcurrent requests for the same URL share a single download via actor-isolated Task coalescing
CGImageSource downsamplingDecodes directly into the target buffer — no intermediate full-bitmap allocation
Prefetch + cancelStarts downloads before cells appear; cancels them via Swift Task cancellation when scroll direction reverses
Incremental batch insertsPagination uses performBatchUpdates — no reloadData, no scroll jumps
Swift ConcurrencyImageLoader is an actor — no manual locks or GCD queues; async/await throughout the image pipeline
Combine bindings@Published state in HomeViewModel drives UI updates reactively via sink subscriptions
Detail screenFull-resolution image viewer with cross-dissolve load and custom back navigation
Zoom transitionShared-element spring animation between thumbnail cell and full-screen detail view
Tuned URLSession12 connections per host, 50 MB memory cache, 200 MB disk cache, 15 s timeout

Project Structure

OptimizedGridLoader/
├── App/
│ ├── AppDelegate.swift
│ └── SceneDelegate.swift ← DI root: Service → ViewModel → Router → ViewController
│
├── Controllers/
│ ├── Home/
│ │ ├── Model/
│ │ │ └── Photo.swift ← Codable model (Picsum API)
│ │ ├── View/
│ │ │ ├── HomeViewController.swift ← CollectionView, Combine bindings, pagination
│ │ │ └── PhotoCell.swift ← Lightweight cell with task lifecycle
│ │ └── ViewModel/
│ │ └── HomeViewModel.swift ← @Published state, fetch, prefetch
│ │
│ └── Detail/
│ └── DetailViewController.swift ← Full-res image viewer, zoom transition target
│
├── Router/
│ ├── Router.swift ← RouterDelegate, push/pop/present abstraction
│ ├── HomeViewControllerFactory.swift ← Factory for constructing HomeViewController
│ └── PhotoZoomTransition.swift ← PhotoZoomAnimator + UINavigationControllerDelegate
│
├── Networking/
│ └── PhotoService.swift ← Async/await API client
│
├── Utilities/
│ └── ImageLoader.swift ← Actor-based loader: async/await + cache + dedup + downsample
│
└── Resources/
└── Assets.xcassets

How It Works

1. Server-Side Image Sizing

The single biggest performance win. Instead of downloading original 5000×3334 images and downscaling on-device, the app calculates the exact pixel size needed and requests a pre-sized thumbnail from the API:

// Photo.swift
func optimizedURLString(pixelWidth:Int)->String{letaspectRatio=CGFloat(height)/ CGFloat(width)letpixelHeight=Int(CGFloat(pixelWidth)* aspectRatio)return"https://picsum.photos/id/\(id)/\(pixelWidth)/\(pixelHeight)"}
Full resolution: https://picsum.photos/id/10/5000/3334 → ~3 MB
Device-appropriate: https://picsum.photos/id/10/390/260 → ~10 KB
▲
10-50x smaller

2. In-Memory Image Cache

ImageLoader (an actor) wraps an NSCache<NSURL, UIImage> that stores decoded, display-ready images. Cache hits return instantly — no network call, no decoding overhead. NSCache auto-evicts under memory pressure, so there is no need for manual purging.

3. Request Deduplication

When multiple cells or prefetch calls request the same URL simultaneously, only one network request is fired. Additional callers simply await the existing in-flight Task — no manual locks needed thanks to actor isolation:

iflet existingTask =inFlightRequests[nsURL]{returntryawait existingTask.value // Coalesce — share the same download
}lettask=Task<UIImage,Error>{let(data, _)=tryawait session.data(from: url)
// ... downsample & cache ...
}inFlightRequests[nsURL]= task // First caller — start download

4. CGImageSource Downsampling

Even after server-side resizing, raw image data is decoded through CGImageSource thumbnailing. This decodes directly into the target buffer size — avoiding an intermediate full-resolution bitmap that would spike memory:

letdownsampleOptions:[CFString:Any]=[
kCGImageSourceCreateThumbnailFromImageAlways:true,
kCGImageSourceShouldCacheImmediately:true,
kCGImageSourceCreateThumbnailWithTransform:true,
kCGImageSourceThumbnailMaxPixelSize: maxDimension
]letcgImage=CGImageSourceCreateThumbnailAtIndex(source,0, downsampleOptions asCFDictionary)

5. Prefetch & Cancel

The app implements UICollectionViewDataSourcePrefetching to warm the cache before cells scroll into view. When the scroll direction reverses, pending prefetches are cancelled to free bandwidth for visible cells.

6. Cell Reuse Safety

prepareForReuse() cancels in-flight downloads via Swift Task cancellation and clears stale images. A representingIdentifier guard combined with Task.isCancelled prevents late-arriving images from being assigned to a recycled cell:

overridefunc prepareForReuse(){
super.prepareForReuse()
imageView.image =nil
representingIdentifier =nil
loadTask?.cancel()
loadTask =nil}

7. Incremental Batch Updates

New pages are appended with performBatchUpdates using precise index paths — preserving scroll position and avoiding the layout cost of a full reloadData. The view subscribes to the @Published photos array via Combine (see §8) and calculates the inserted index paths to feed into performBatchUpdates.

8. Combine-Based State Binding

HomeViewModel publishes its entire state through @Published properties. HomeViewController subscribes once during viewDidLoad and reacts to every state change without manual callback wiring:

// HomeViewModel.swift
@Publishedpublicprivate(set)varphotos:[Photo]=[]@Publishedpublicprivate(set)varisLoading:Bool=false
// HomeViewController.swift
vm.$isLoading
.receive(on:DispatchQueue.main).sink{[weak self] loading in
loading ?self?.activityIndicator.startAnimating():self?.activityIndicator.stopAnimating()}.store(in:&cancellables)

This replaces the previous callback closure approach (photosDidUpdate, initialPhotosLoaded) with a declarative reactive flow.

9. Detail Screen

Tapping a cell navigates to DetailViewController via the Router. The cell's current thumbnail is passed as a previewImage so it is displayed instantly while the full-resolution image loads asynchronously in the background:

// DetailViewController.swift
privatefunc loadImage(){Task{guardlet url =URL(string: photo.downloadURL)else{return}letimage=tryawait loader.loadImage(from: url, targetSize: view.bounds.size, scale:UIScreen.main.scale)guard !Task.isCancelled else{return}UIView.transition(with: imageView, duration:0.25, options:.transitionCrossDissolve){self.imageView.image = image
}}}

The detail view conforms to PhotoTransitionDestinationProviding, exposing the UIImageView and the associated Photo to the transition animator.

10. Custom Zoom Transition

PhotoZoomAnimator implements UIViewControllerAnimatedTransitioning and drives a shared-element animation between the tapped cell's image view and the full-screen detail image view:

Push (zoom in):

  1. Snapshot the source cell's imageView frame in window coordinates.
  2. Calculate the destination imageView frame in window coordinates.
  3. Create a floating UIImageView at the source frame, animate it to the destination frame with a spring curve (damping: 0.85), fade in the destination view.
  4. Remove the floating view and unhide both real image views on completion.

Pop (zoom out): The process runs in reverse — the floating image starts at the detail frame and springs back to the grid cell's frame while the detail view fades out.

UIView.animate(
withDuration:0.45,
delay:0,
usingSpringWithDamping:0.85,
initialSpringVelocity:0.0,
options:[.curveEaseOut]){
transitionImageView.frame = endFrame
destinationViewController.view.alpha =1.0}

A fallback path (fade in/out) activates automatically when either view controller does not conform to the required protocols, ensuring the transition never breaks for unrelated screens.

11. Router Layer

All navigation decisions are owned by Router, which conforms to RouterDelegate. This keeps UIViewController subclasses free of navigation knowledge and makes the routing logic independently testable:

enumRoute{case home
case detail(photo:Photo, previewImage:UIImage?)}

Router sets itself as the UINavigationController's delegate during init and installs PhotoNavigationControllerDelegate to supply the custom PhotoZoomAnimator for push and pop operations.

12. Tuned URLSession

A dedicated URLSession replaces .shared with production-tuned parameters:

SettingValueRationale
httpMaximumConnectionsPerHost122x the default — more parallel downloads
requestCachePolicy.returnCacheDataElseLoadServe cached responses instantly
urlCache memory50 MBGenerous in-memory HTTP cache
urlCache disk200 MBPersistent cache across launches
timeoutIntervalForRequest15 sFail fast on slow connections

Data Flow

 ┌─────────────────────────────────────────────────────────────────────────────┐
│ SceneDelegate (DI Root) │
│ PhotoService → HomeViewModel → Router → HomeViewController │
└─────────────────────────────────────────────────────────────────────────────┘
┌────────────────┐ @Published ┌──────────────────┐ async/ ┌──────────────┐
│ HomeView │◄──────────────│ HomeViewModel │ await │ PhotoService │
│ Controller │ (Combine) │ photos │─────────►│ (Picsum API) │
│ │ │ isLoading │◄─────────│ │
│ ┌──────────┐ │ └──────────────────┘ [Photo] └──────────────┘
│ │PhotoCell │──┼──tap──────────► Router.navigate(.detail)
│ │ │ │ configure() │
│ │ │◄─┼──loadImage() ▼
│ └──────────┘ │ ┌──────────────────────────────┐
└────────────────┘ │ DetailViewController │
│ previewImage (instant) │
│ loadImage() → full-res │
│ PhotoTransitionDestination │
└──────────────────────────────┘
▲
┌─────────────┴────────────┐
│ PhotoZoomAnimator │
│ push: cell → fullscreen │
│ pop: fullscreen → cell │
└───────────────────────────┘
┌──────────────┐
All image loads ────────────────────────────────────────►│ ImageLoader │
│ (actor) │
│ ┌─NSCache─┐ │
│ ┌URLCache─┐ │
│ ┌─Dedup──┐ │
│ ┌Downsmpl┐ │
└──────────────┘

Requirements

RequirementVersion
iOS16.0+
Xcode26.0+
Swift6.0+
DependenciesNone

Getting Started

# 1. Clone the repository
git clone https://github.com/<your-username>/OptimizedGridLoader.git
# 2. Open in Xcodecd OptimizedGridLoader
open OptimizedGridLoader.xcodeproj
# 3. Build & Run (⌘R) on simulator or device

The app fetches photos from the Picsum Photos APIno API key required.


Concurrency & Reactive Model

ComponentMechanism
ImageLoaderactor — data-race safety for cache and in-flight request tracking
loadImage(from:targetSize:scale:)async throws — callers await the result
Request deduplicationTask<UIImage, Error> — duplicate requests coalesce on .value
Cell image loadingTask<Void, Never> — spawned in configure(), cancelled in prepareForReuse()
Prefetch / cancelTask cancellation — propagates through URLSession.data(from:)
Downsamplingnonisolated function — pure computation outside actor isolation
ViewModel state@Published + Combinephotos and isLoading drive the UI reactively via sink
NavigationRouter + RouterDelegate — decouples screen transitions from view controllers
Zoom transitionUIViewControllerAnimatedTransitioning — custom spring animator with shared element

Key Takeaways

If you take nothing else from this project, remember these principles:

  1. Download less data — request the size you actually need from the server.
  2. Decode smarter — use CGImageSource to decode directly into the target buffer, not via UIImage(data:).
  3. Cancel aggressively — every wasted download steals bandwidth from visible content.
  4. Combine over callbacks@Published + sink scales better than manual closure wiring as state grows.
  5. Delight with transitions — a well-crafted shared-element zoom tells users exactly where they came from and where they can go back.

License

This project is intended for educational and demonstration purposes.

About

A focused iOS demo project that showcases **production-grade techniques** for loading, caching, and displaying remote images inside a `UICollectionView` with smooth, jank-free scrolling.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages