In the previous episode, you learned about futures and promises. In this episode, I show you how to use them in a project.

Replacing Completion Handlers with Futures

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.

Replacing Completion Handlers with Futures

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

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

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,
                   imageService: imageService)

    return cell
}

Let's take a closer look at the configure(title:imageUrl:imageService:) method of the LandscapeTableViewCell class. The table view cell asks the image service for the landscape's image by invoking its image(for:completion:) method. The method accepts a URL as its first argument and a completion handler as its second argument.

func configure(title: String, imageUrl: URL, imageService: ImageService) {
    // 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
    }
}

Open ImageService.swift and navigate to the image(for:completion:) method. The goal of this episode is to replace the completion handler with a future.

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

Because we are about to work with futures, we need to add an import statement for the Combine framework at the top.

import UIKit
import Combine

final class ImageService {
    ...
}

Revisit the image(for:completion:) method. Remove the completion handler from the method signature and change the method's return type to AnyPublisher. The ImageService class uses a future internally, but it doesn't expose the future. The Output type of the publisher is UIImage? and its Failure type is Never.

func image(for url: URL) -> AnyPublisher<UIImage?, Never> {
    ...
}

In the body of the image(for:) method, we create a future. Remember from the previous episode that the initializer accepts a closure as an argument. The closure that is passed to the future's initializer accepts a single argument, a promise.

func image(for url: URL) -> AnyPublisher<UIImage?, Never> {
    // Create Future
    Future { promise in

    }
}

Because the image(for:) method no longer returns an object of type Cancellable, we can simplify its implementation. In the closure that we pass to the initializer of the future, we invoke the cachedImage(for:completion:) method to request a cached image, passing in the URL of the remote image and a completion handler.

func image(for url: URL) -> AnyPublisher<UIImage?, Never> {
    // Create Future
    Future { promise in
        // Request Image from Cache
        self.cachedImage(for: url) { image in

        }
    }
}

The argument of the completion handler is of type UIImage?. We use optional binding to safely unwrap the image. If a cached image is available, we invoke the future's promise, passing in the image as the associated value of the success case.

func image(for url: URL) -> AnyPublisher<UIImage?, Never> {
    // Create Future
    Future { promise in
        // Request Image from Cache
        self.cachedImage(for: url) { image in
            if let image = image {
                // Execute Promise
                promise(.success(image))
            } else {

            }
        }
    }
}

In the else clause, we create a data task, passing in the URL of the remote image and a completion handler. Because the image(for:) method no longer returns an object of type Cancellable, we can simplify the implementation. First, we no longer store the data task in a constant. Second, we call resume() on the data task to initiate the request. Third, we no longer return the data task from the image(for:) method.

func image(for url: URL) -> AnyPublisher<UIImage?, Never> {
    // Create Future
    Future { promise in
        // Request Image from Cache
        self.cachedImage(for: url) { image in
            if let image = image {
                // Execute Promise
                promise(.success(image))
            } else {
                // Request Data for Image
                URLSession.shared.dataTask(with: url) { [weak self] data, _, error in
                    // Helper
                    let result: Result<Data, Error>? = {
                        if let data = data {
                            // Success
                            return .success(data)
                        } else if let error = error, (error as NSError).code != URLError.cancelled.rawValue {
                            // Failure
                            return .failure(error)
                        } else {
                            // Cancelled
                            return nil
                        }
                    }()

                    // Execute Handler on Main Thread
                    DispatchQueue.main.async {
                        switch result {
                        case .success(let data):
                            print("Data Task Succeeded")

                            // Execute Handler
                            completion(UIImage(data: data))

                            // Cache Image
                            self?.cacheImage(data, for: url)
                        case .failure:
                            print("Data Task Failed")

                            // Execute Handler
                            completion(nil)
                        case .none:
                            print("Data Task Cancelled")

                            break
                        }
                    }
                }.resume()
            }
        }
    }
}

The nested closures add quite a bit of complexity to the implementation. We fix that in a moment. Before we clean up the implementation, we need to invoke the future's promise in the switch statement. The changes are straightforward. We invoke the promise instead of the completion handler. We only do this in the success case because the Failure type of the publisher the image(for:) method returns is Never. In the success case, we create a UIImage instance using the Data object and pass the result as the associated value of the success case. We don't invoke the promise for the failure and none cases.

func image(for url: URL) -> AnyPublisher<UIImage?, Never> {
    // Create Future
    Future { promise in
        // Request Image from Cache
        self.cachedImage(for: url) { image in
            if let image = image {
                // Execute Promise
                promise(.success(image))
            } else {
                // Request Data for Image
                URLSession.shared.dataTask(with: url) { [weak self] data, _, error in
                    // Helper
                    let result: Result<Data, Error>? = {
                        if let data = data {
                            // Success
                            return .success(data)
                        } else if let error = error, (error as NSError).code != URLError.cancelled.rawValue {
                            // Failure
                            return .failure(error)
                        } else {
                            // Cancelled
                            return nil
                        }
                    }()

                    // Execute Handler on Main Thread
                    DispatchQueue.main.async {
                        switch result {
                        case .success(let data):
                            print("Data Task Succeeded")

                            // Execute Promise
                            promise(.success(UIImage(data: data)))

                            // Cache Image
                            self?.cacheImage(data, for: url)
                        case .failure:
                            print("Data Task Failed")
                        case .none:
                            print("Data Task Cancelled")
                        }
                    }
                }.resume()
            }
        }
    }
}

Before we clean up the implementation, we need to wrap the future in a type eraser because that is what the compiler expects. Remember that the image(for:) method doesn't return a future, it returns a publisher of type AnyPublisher.

func image(for url: URL) -> AnyPublisher<UIImage?, Never> {
    // Create Future
    Future { promise in
        ...
    }
    .eraseToAnyPublisher()
}

Earlier in this series, you learned how easy it is to force a publisher to deliver its events on the main thread, using the receive operator. Let's use the receive operator to make sure the future publishes its result on the main thread. Before we invoke the eraseToAnyPublisher() method, we invoke the receive(on:) method, passing it a reference to the main dispatch queue. This ensures the main dispatch queue is used to deliver the future's result to its subscribers.

func image(for url: URL) -> AnyPublisher<UIImage?, Never> {
    // Create Future
    Future { promise in
        ...
    }
    .receive(on: DispatchQueue.main)
    .eraseToAnyPublisher()
}

This means we no longer need to use Grand Central Dispatch in the completion handler of the data task.

func image(for url: URL) -> AnyPublisher<UIImage?, Never> {
    // Create Future
    Future { promise in
        // Request Image from Cache
        self.cachedImage(for: url) { image in
            if let image = image {
                // Execute Promise
                promise(.success(image))
            } else {
                // Request Data for Image
                URLSession.shared.dataTask(with: url) { [weak self] data, _, error in
                    ...

                    switch result {
                    case .success(let data):
                        print("Data Task Succeeded")

                        // Execute Promise
                        promise(.success(UIImage(data: data)))

                        // Cache Image
                        self?.cacheImage(data, for: url)
                    case .failure:
                        print("Data Task Failed")
                    case .none:
                        print("Data Task Cancelled")
                    }
                }.resume()
            }
        }
    }
    .receive(on: DispatchQueue.main)
    .eraseToAnyPublisher()
}

The image(for:) method no longer returns an object of type Cancellable. The caller is no longer able to cancel the image request. This is a downside, but it simplifies the implementation of the completion handler of the data task. We use a guard statement to safely access the Data object and use it to create a UIImage instance. We pass the image as the associated value of the success case to the promise. We also pass the Data object and the URL of the remote image to the cacheImage(_:for:) method to cache the result.

func image(for url: URL) -> AnyPublisher<UIImage?, Never> {
    // Create Future
    Future { promise in
        // Request Image from Cache
        self.cachedImage(for: url) { image in
            if let image = image {
                // Execute Promise
                promise(.success(image))
            } else {
                // Request Data for Image
                URLSession.shared.dataTask(with: url) { [weak self] data, _, error in
                    guard let data = data else {
                        return
                    }

                    // Execute Promise
                    promise(.success(UIImage(data: data)))

                    // Cache Image
                    self?.cacheImage(data, for: url)
                }.resume()
            }
        }
    }
    .receive(on: DispatchQueue.main)
    .eraseToAnyPublisher()
}

These changes drastically simplify the implementation of the image(for:) method. Before we can test the changes we made, we need to update the LandscapeTableViewCell class. Open LandscapeTableViewCell.swift and navigate to the configure(title:imageUrl:imageService:) method.

The table view cell should no longer hold on to an object of type Cancellable. We add an import statement for the Combine framework at the top and replace the imageRequest property with a property, subscription, of type AnyCancellable?.

import UIKit
import Combine

final class LandscapeTableViewCell: UITableViewCell {
    ...
}
private var subscription: AnyCancellable?

The image(for:) method no longer returns an object of type Cancellable. It returns a publisher instead. The table view cell attaches a subscriber to the publisher the image(for:) method returns by invoking the assign(to:on:) method. It accepts a key path as its first argument and an object as its second argument. We covered the assign(to:on:) method earlier in this series.

We pass the key path of the image property of the thumbnail image view as the first argument and the thumbnailImageView property of the table view cell as the second argument. The assign(to:on:) method returns an AnyCancellable instance. We store a reference to the AnyCancellable instance in the subscription property.

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

    // Animate Activity Indicator View
    activityIndicatorView.startAnimating()

    // Request Image Using Image Service
    subscription = imageService.image(for: imageUrl)
        .assign(to: \.image, on: thumbnailImageView)
}

In the prepare for reuse method, the table view cell sets its subscription property to nil to dispose of the subscription it references.

override func prepareForReuse() {
    super.prepareForReuse()

    // Reset Thumbnail Image View
    thumbnailImageView.image = nil

    // Terminate Subscription
    subscription = nil
}

Because the image(for:) method no longer returns an object of type Cancellable, we can remove the Cancellable protocol from the project. Build and run the application to see the result. The behavior of the application shouldn't have changed.

What's Next?

Replacing a completion handler with a future isn't difficult and it often results in less code that is easier to reason about. The receive operator we applied to the future shows the flexibility futures, and publishers in general, add to the code you write. It is important to note that it is no longer possible to cancel the data task that fetches the data for the remote image. This is an important drawback that you need to take into consideration if you decide to replace completion handlers with futures.