Image Caching in Swift

Cancelling Image Requests

Most applications display images in some way, shape, or form. Those images are often fetched from a remote server, introducing a number of interesting challenges. Performing a request to a remote server takes time and it requires resources. It is therefore important to consider solutions to minimize the number of requests an application makes.

In this series, I show you several solutions to improve the performance of an application by caching images. This episode focuses on cancelling image requests. The ability to cancel image requests is often overlooked. Forgetting to cancel image requests or not having the ability can lead to a number of hard to debug problems.

Starter Project

Fire up Xcode and open the starter project of this episode if you want to follow along. Build and run the application in a simulator to see it in action. The application fetches a collection of landscapes from a remote server and displays the landscapes in a table view. Each landscape has a title and an image.

Cancelling Image Requests in Swift

The project adopts the Model-View-Controller pattern. Let's take a look under the hood. The LandscapesViewController class does most of the heavy lifting. It invokes a helper method, fetchLandscapes(), in its viewDidLoad() method.

// MARK: - View Life Cycle

override func viewDidLoad() {
    super.viewDidLoad()

    // Fetch Landscapes
    fetchLandscapes()
}

The fetchLandscapes() method fetches an array of landscapes from a remote server using the URLSession API. The landscapes view controller stores the collection in a property, landscapes, and displays the landscapes in its table view.

// MARK: - Helper Methods

private func fetchLandscapes() {
    // Start/Show Activity Indicator View
    activityIndicatorView.startAnimating()

    let url = URL(string: "https://cdn.cocoacasts.com/api/landscapes/v1/landscapes.json")!
    URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
        guard let data = data else {
            print("Unable to Fetch Landscapes")
            return
        }

        do {
            // Decode Response
            let landscapes = try JSONDecoder().decode([Landscape].self, from: data)

            // Update Data Source and
            // User Interface on Main Thread
            DispatchQueue.main.async {
                // Update Landscapes
                self?.landscapes = landscapes

                // Stop/Hide Activity Indicator View
                self?.activityIndicatorView.stopAnimating()
            }
        } catch {
            print("Unable to Decode Landscapes")
        }
    }.resume()
}

The landscapes property is of type [Landscape] objects. Each Landscape object has a title and an image URL. The image URL points to a remote image.

private var landscapes: [Landscape] = [] {
    didSet {
        // Reload Table View
        tableView.reloadData()

        // Show/Hide Table View
        tableView.isHidden = landscapes.isEmpty
    }
}

We are most interested in the implementation of the view controller's tableView(_:cellForRowAt:) method. The view controller dequeues a table view cell, fetches the landscape that corresponds with the index path, and uses the Landscape object to populate the table view cell.

To display the image of the landscape, the view controller invokes another helper method, fetchImage(with:completion:). This helper method accepts a URL object as its first argument and a closure as its second argument. The closure accepts an optional UIImage instance as its only argument. In the closure, the view controller updates the image property of the table view cell's thumbnail image view.

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    guard let cell = tableView.dequeueReusableCell(withIdentifier: LandscapeTableViewCell.reuseIdentifier, for: indexPath) as? LandscapeTableViewCell else {
        fatalError("Unable to Dequeue Landscape Table View Cell")
    }

    // Fetch Landscape
    let landscape = landscapes[indexPath.row]

    // Configure Cell
    cell.configure(title: landscape.title)

    // Fetch Image for Landscape
    fetchImage(with: landscape.imageUrl) { [weak cell] image in
        cell?.thumbnailImageView.image = image
    }

    return cell
}

The current implementation has two flaws I want to address in this and the next episode. First, the application doesn't cache the images it fetches from the remote server. It fetches the same images multiple times as the user scrolls the table view.

Second, the application suffers from a race condition. Fetching an image from a remote server is an asynchronous operation that takes time. Multiple requests are initiated as the user scrolls the table view and it isn't possible to predict the order in which the requests complete. It can happen that a request for a table view cell that is no longer visible completes after a request for a table view cell that is visible completes. In other words, it is possible that a table view cell displays the wrong image. That isn't acceptable and something we need to fix.

We need to make two changes to the current implementation. First, we need the ability to cancel the request for an image for a table view cell that moves off-screen. Second, to avoid that the same image is fetched multiple times, the response of a successful request needs to be cached in memory.

Creating the Image Service

Before we implement these solutions, we move the responsibility of fetching images from the landscapes view controller to a service. Let me show you how that works. Create a group with name Services and add a Swift file to the group, ImageService.swift. Because the image service returns images, we replace the import statement for Foundation with an import statement for UIKit. Define a final class with name ImageService. The ImageService class is responsible for fetching remote images.

import UIKit

final class ImageService {

}

We define a method with name image(for:completion:). Notice that the method doesn't include the word fetch. The image service doesn't and shouldn't reveal to the consumer of the API where the image comes from. It is possible the image service fetches the image from a remote server, but it might as well return an image from a cache in memory or on disk. That is an implementation detail that is private to the image service. Carefully choosing the names of constants, variables, methods, and types can avoid confusion and it also helps with documenting the APIs you create.

The arguments of the image(for:completion:) method are identical to those of the fetchImage(for:completion:) method of the LandscapesViewController class and so is its implementation.

import UIKit

final class ImageService {

    // MARK: - Public API

    func image(for url: URL, completion: @escaping (UIImage?) -> Void) {
        URLSession.shared.dataTask(with: url) { data, _, _ in
            // Helper
            var image: UIImage?

            defer {
                // Execute Handler on Main Thread
                DispatchQueue.main.async {
                    // Execute Handler
                    completion(image)
                }
            }

            if let data = data {
                // Create Image from Data
                image = UIImage(data: data)
            }
        }.resume()
    }

}

Revisit LandscapesViewController.swift. Remove the fetchImage(for:completion:) method and remove the reference to the method in tableView(_:cellForRowAt:).

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    guard let cell = tableView.dequeueReusableCell(withIdentifier: LandscapeTableViewCell.reuseIdentifier, for: indexPath) as? LandscapeTableViewCell else {
        fatalError("Unable to Dequeue Landscape Table View Cell")
    }

    // Fetch Landscape
    let landscape = landscapes[indexPath.row]

    // Configure Cell
    cell.configure(title: landscape.title)

    return cell
}

Open LandscapeTableViewCell.swift and define a private, lazy, variable property, imageService, that stores a reference to an ImageService instance.

private lazy var imageService = ImageService()

We update the configure(title:) method to also accept the URL of the landscape's image. In the body of the configure(title:imageUrl:) method, we ask the image service for the landscape's image. In the completion handler, the table view cell updates the image property of the thumbnail image view.

// MARK: - Public API

func configure(title: String, imageUrl: URL) {
    // Configure Title Label
    titleLabel.text = title

    // Animate Activity Indicator View
    activityIndicatorView.startAnimating()

    // Request Image Using Image Service
    imageService.image(for: imageUrl) { [weak self] image in
        // Update Thumbnail Image View
        self?.thumbnailImageView.image = image
    }
}

Cancelling a Data Task

Even though we implemented and integrated the ImageService class, we haven't addressed any of the problems we discussed earlier. Let's start with the first problem. We need to add the ability to cancel the request for a remote image when a table view cell is about to be reused by the table view. To make that possible, the image service needs to return the data task it uses to fetch the remote image.

We could return the URLSessionDataTask instance, but there is a better, more elegant solution. Remember that a consumer of the ImageService API doesn't need to know how the image service obtains the image. It only needs the ability to cancel the request it made to the image service. The solution isn't complex.

Create a group with name Protocols and add a Swift file to the group, Cancellable.swift. Define a protocol with name Cancellable. The protocol defines a single method with name cancel().

import Foundation

protocol Cancellable {

    // MARK: - Methods

    func cancel()

}

Below the protocol definition, we create an extension for the URLSessionTask class, the superclass of URLSessionDataTask. We use the extension to conform URLSessionTask to the Cancellable protocol. Because URLSessionTask has a cancel() method of its own, it automatically conforms to the Cancellable protocol.

import Foundation

protocol Cancellable {

    // MARK: - Methods

    func cancel()

}

extension URLSessionTask: Cancellable {

}

Revisit ImageService.swift. The image(for:completion:) method should return an object that conforms to the Cancellable protocol. We update the method's signature to reflect that.

func image(for url: URL, completion: @escaping (UIImage?) -> Void) -> Cancellable {
    ...
}

We assign the data task the shared URL session returns to a local constant, dataTask, and return the data task from the image(for:completion:) method.

import UIKit

final class ImageService {

    // MARK: - Public API

    func image(for url: URL, completion: @escaping (UIImage?) -> Void) -> Cancellable {
        let dataTask = URLSession.shared.dataTask(with: url) { data, _, _ in
            // Helper
            var image: UIImage?

            defer {
                // Execute Handler on Main Thread
                DispatchQueue.main.async {
                    // Execute Handler
                    completion(image)
                }
            }

            if let data = data {
                // Create Image from Data
                image = UIImage(data: data)
            }
        }

        // Resume Data Task
        dataTask.resume()

        return dataTask
    }

}

Revisit LandscapeTableViewCell.swift and define a private, variable property, imageRequest, of type Cancellable?.

private var imageRequest: Cancellable?

In the configure(title:imageUrl:) method, we assign the Cancellable object the image service returns to the imageRequest property.

// MARK: - Public API

func configure(title: String, imageUrl: URL) {
    // Configure Title Label
    titleLabel.text = title

    // Animate Activity Indicator View
    activityIndicatorView.startAnimating()

    // Request Image Using Image Service
    imageRequest = imageService.image(for: imageUrl) { [weak self] image in
        // Update Thumbnail Image View
        self?.thumbnailImageView.image = image
    }
}

To cancel the image request, the table view cell invokes the cancel() method of the Cancellable object that is stored in the imageRequest property in its prepareForReuse() method.

// MARK: - Overrides

override func prepareForReuse() {
    super.prepareForReuse()

    // Reset Thumbnail Image View
    thumbnailImageView.image = nil

    // Cancel Image Request
    imageRequest?.cancel()
}

Cleaning Up the Implementation

Before we build and run the application, we need to make two changes. Revisit LandscapesViewController.swift and navigate to the tableView(_:cellForRowAt:) method. Pass the URL of the landscape's image to the configure(title:imageUrl:) method of the table view cell.

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    guard let cell = tableView.dequeueReusableCell(withIdentifier: LandscapeTableViewCell.reuseIdentifier, for: indexPath) as? LandscapeTableViewCell else {
        fatalError("Unable to Dequeue Landscape Table View Cell")
    }

    // Fetch Landscape
    let landscape = landscapes[indexPath.row]

    // Configure Cell
    cell.configure(title: landscape.title,
                   imageUrl: landscape.imageUrl)

    return cell
}

Open LandscapeTableViewCell.swift. Because the landscape table view cell uses the ImageService class to request the landscape's image, the thumbnail image view no longer needs to be exposed. We can declare it privately.

@IBOutlet private var thumbnailImageView: UIImageView! {
    didSet {
        // Configure Thumbnail Image View
        thumbnailImageView.contentMode = .scaleAspectFill
    }
}

Build and run the application. The table view still suffers from a performance problem, but we eliminated the race condition. Every table view cell displays the correct image.

What's Next?

In this episode, we added the ability to cancel image requests, a much needed addition. In the next episodes, we focus on caching. We start simple by caching images in memory. This is easy to implement and has a number of interesting benefits.