Skip to content

Coder-ACJHP/OptimizedGridLoader

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

9 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

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.

Decision Benefit Cost / Trade-off Why It Is Acceptable Here
Server-side thumbnail sizing Dramatically smaller payloads, faster first paint Requires URL-building logic and depends on API support for dynamic sizes Picsum supports dynamic dimensions; this is the highest impact optimization
Actor-based ImageLoader + request dedup Data-race safety, fewer duplicate requests, cleaner concurrency model Slightly more complex mental model than naive callback loading Complexity is localized in one utility, while app-level code remains simple
Two-tier cache (NSCache + URLCache) Fast repeat renders + reduced network usage across launches Need cache-size tuning; too large caches may increase memory/disk pressure Cache limits are bounded and can be tuned per product needs
CGImageSource downsampling Lower peak memory and decoding cost vs full decode More verbose code than UIImage(data:) Worth it for large lists where decode spikes cause dropped frames
Prefetch + cancellation Better perceived smoothness when scrolling forward More lifecycle handling (start/cancel) and edge-case management Managed with Task cancellation and cell reuse guards
Custom zoom transition Strong UX continuity between grid and detail More navigation complexity and animation maintenance Isolated behind Router + transition delegate, with fallback animation path
Combine state binding Reactive and scalable UI state propagation Adds 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)

Metric Naive Grid Baseline* OptimizedGridLoader Improvement
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 URL Frequent Near-zero (deduped) Major bandwidth savings
Revisit screen load behavior Many re-downloads Mostly cache hits Significantly 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

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

Highlights

Feature Description
Server-side resizing Requests device-appropriate thumbnails (~10 KB) instead of full-res originals (~3 MB)
Two-tier caching In-memory NSCache for decoded images + 200 MB disk URLCache for raw responses
Request deduplication Concurrent requests for the same URL share a single download via actor-isolated Task coalescing
CGImageSource downsampling Decodes directly into the target buffer — no intermediate full-bitmap allocation
Prefetch + cancel Starts downloads before cells appear; cancels them via Swift Task cancellation when scroll direction reverses
Incremental batch inserts Pagination uses performBatchUpdates — no reloadData, no scroll jumps
Swift Concurrency ImageLoader 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 screen Full-resolution image viewer with cross-dissolve load and custom back navigation
Zoom transition Shared-element spring animation between thumbnail cell and full-screen detail view
Tuned URLSession 12 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 {
    let aspectRatio = CGFloat(height) / CGFloat(width)
    let pixelHeight = 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:

if let existingTask = inFlightRequests[nsURL] {
    return try await existingTask.value          // Coalesce — share the same download
}

let task = Task<UIImage, Error> {
    let (data, _) = try await 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:

let downsampleOptions: [CFString: Any] = [
    kCGImageSourceCreateThumbnailFromImageAlways: true,
    kCGImageSourceShouldCacheImmediately: true,
    kCGImageSourceCreateThumbnailWithTransform: true,
    kCGImageSourceThumbnailMaxPixelSize: maxDimension
]
let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, downsampleOptions as CFDictionary)

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:

override func 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
@Published public private(set) var photos: [Photo] = []
@Published public private(set) var isLoading: 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
private func loadImage() {
    Task {
        guard let url = URL(string: photo.downloadURL) else { return }
        let image = try await 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:

enum Route {
    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:

Setting Value Rationale
httpMaximumConnectionsPerHost 12 2x the default — more parallel downloads
requestCachePolicy .returnCacheDataElseLoad Serve cached responses instantly
urlCache memory 50 MB Generous in-memory HTTP cache
urlCache disk 200 MB Persistent cache across launches
timeoutIntervalForRequest 15 s Fail 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

Requirement Version
iOS 16.0+
Xcode 26.0+
Swift 6.0+
Dependencies None

Getting Started

# 1. Clone the repository
git clone https://github.com/<your-username>/OptimizedGridLoader.git

# 2. Open in Xcode
cd 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

Component Mechanism
ImageLoader actor — data-race safety for cache and in-flight request tracking
loadImage(from:targetSize:scale:) async throws — callers await the result
Request deduplication Task<UIImage, Error> — duplicate requests coalesce on .value
Cell image loading Task<Void, Never> — spawned in configure(), cancelled in prepareForReuse()
Prefetch / cancel Task cancellation — propagates through URLSession.data(from:)
Downsampling nonisolated function — pure computation outside actor isolation
ViewModel state @Published + Combinephotos and isLoading drive the UI reactively via sink
Navigation Router + RouterDelegate — decouples screen transitions from view controllers
Zoom transition UIViewControllerAnimatedTransitioning — 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.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages