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.
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.