The API the image service exposes no longer accepts a completion handler. It returns a publisher instead. This is an improvement, but the image service still uses completion handlers under the hood. In this episode, we replace the completion handlers the image service uses internally with publishers.

Taking a Step Backwards

We first create a helper method to fetch the data for a remote image. We define a method with name remoteImage(for:) that accepts a URL as its first argument and a completion handler as its second argument. The completion handler accepts an optional UIImage instance as an argument and returns Void. The method signature is similar to that of the cachedImage(for:completion:) method.

private func remoteImage(for url: URL, completion: @escaping (UIImage?) -> Void) {

}

We move the contents of the else clause of the image(for:) method to the remoteImage(for:completion:) method.

private func remoteImage(for url: URL, completion: @escaping (UIImage?) -> Void) {
    // 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()
}

Instead of invoking the promise, the remoteImage(for:) method invokes the completion handler, passing in a UIImage instance. We also invoke the completion handler in the else clause of the guard statement, passing in nil.

private func remoteImage(for url: URL, completion: @escaping (UIImage?) -> Void) {
    // Request Data for Image
    URLSession.shared.dataTask(with: url) { [weak self] data, _, error in
        guard let data = data else {
            // Execute Handler
            completion(nil)

            return
        }

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

        // Cache Image
        self?.cacheImage(data, for: url)
    }.resume()
}

It may look as if we are taking a step backwards by creating yet another method that accepts a completion handler. That is true, but we are preparing the image(for:) method for what is next. In the image(for:) method, we invoke the remoteImage(for:completion:) method. In the completion handler, we invoke the future's promise if image isn't equal to nil.

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
                self.remoteImage(for: url) { image in
                    if let image = image {
                        // Execute Promise
                        promise(.success(image))
                    }
                }
            }
        }
    }
    .receive(on: DispatchQueue.main)
    .eraseToAnyPublisher()
}

The implementation of the image(for:) method illustrates the problem completion handlers often introduce, nested closures and more complexity. We can refactor the implementation by leveraging futures and a few other nice features the Combine framework has to offer.

Returning a Cached Image

We first refactor the cachedImage(for:completion:) method. We remove the completion handler from the method signature and change its return type to Future. The Output type of the future is UIImage? and its Failure type is Never.

private func cachedImage(for url: URL) -> Future<UIImage?, Never> {
    ...
}

We clean up the implementation of the cachedImage(for:) method by removing the print statements. We no longer need them. In the body of the method, we wrap the implementation in a future. Because we access the cache property of the image service from within the closure, we explicitly use self to access the cache property.

private func cachedImage(for url: URL) -> Future<UIImage?, Never> {
    Future { promise in
        if
            // Default to Cache in Memory
            let data = self.cache.first(where: { $0.url == url })?.data
        {
            // Execute Handler
            completion(UIImage(data: data))
        }
        else
        {
            DispatchQueue.global(qos: .userInitiated).async {
                // Fall Back to Cache on Disk
                guard let data = try? Data(contentsOf: self.locationOnDisk(for: url)) else {
                    // Execute Handler
                    completion(nil)

                    return
                }

                // Cache Image in Memory
                self.cacheImage(data, for: url, writeToDisk: false)

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

In the closure we pass to the future's initializer, we invoke the future's promise instead of the completion handler.

private func cachedImage(for url: URL) -> Future<UIImage?, Never> {
    Future { promise in
        if
            // Default to Cache in Memory
            let data = self.cache.first(where: { $0.url == url })?.data
        {
            // Execute Promise
            promise(.success(UIImage(data: data)))
        }
        else
        {
            DispatchQueue.global(qos: .userInitiated).async {
                // Fall Back to Cache on Disk
                guard let data = try? Data(contentsOf: self.locationOnDisk(for: url)) else {
                    // Execute Promise
                    promise(.success(nil))

                    return
                }

                // Cache Image in Memory
                self.cacheImage(data, for: url, writeToDisk: false)

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

Fetching a Remote Image

The next step is reactifying the remoteImage(for:completion:) method. We remove the completion handler from the method signature and change its return type to AnyPublisher. The Output type of the future is UIImage? and its Failure type is Never.

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

In the body of the remoteImage(for:) method, we start with a clean slate and create a data task publisher by invoking the dataTaskPublisher(for:) method on the shared URL session.

private func remoteImage(for url: URL) -> AnyPublisher<UIImage?, Never> {
    URLSession.shared.dataTaskPublisher(for: url)
}

Remember that a data task publisher emits a tuple with two values, a Data object and a URLResponse object. We use the map operator to convert the Data object to a UIImage instance.

private func remoteImage(for url: URL) -> AnyPublisher<UIImage?, Never> {
    URLSession.shared.dataTaskPublisher(for: url)
        .map { data, _ in
            UIImage(data: data)
        }
}

The image service fails silently if the data task publisher emits an error. Earlier in this series, you learned how to use the replaceError operator to recover from an upstream error. We apply the replaceError operator to convert the error the data task publisher emits to nil.

private func remoteImage(for url: URL) -> AnyPublisher<UIImage?, Never> {
    URLSession.shared.dataTaskPublisher(for: url)
        .map { data, _ in
            UIImage(data: data)
        }
        .replaceError(with: nil)
}

We use the eraseToAnyPublisher() method to wrap the resulting publisher of the replaceError operator with a type eraser.

private func remoteImage(for url: URL) -> AnyPublisher<UIImage?, Never> {
    URLSession.shared.dataTaskPublisher(for: url)
        .map { data, _ in
            UIImage(data: data)
        }
        .replaceError(with: nil)
        .eraseToAnyPublisher()
}

Putting Everything Together

With cachedImage(for:) and remoteImage(for:) both reactified, it is time to refactor the image(for:) method. We start with a clean slate. We invoke the cachedImage(for:) method, passing in the URL of the remote image.

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

If the image service isn't able to load the remote image from cache, it should fetch the data for the remote image. In that scenario, the image service should replace the publisher returned by the cachedImage(for:) method with the publisher returned by the remoteImage(for:) method. How do we do that? How do we replace one publisher with another publisher? Combine's flatMap operator is the answer to this question. Let me show you how it works.

We apply the flatMap operator to the publisher the cachedImage(for:) method returns. The flatMap(_:) method accepts a closure as its only argument and returns another publisher. The closure we pass to the flatMap(_:) method accepts an optional UIImage instance as its only argument. The image is emitted by the upstream publisher, the publisher returned by the cachedImage(for:) method.

We use optional binding to safely unwrap the image. If the publisher emits an image, the flatMap operator returns a publisher that emits the image. We use the Just struct to create a publisher that emits a single value, the image in this example, wrapping it with a type eraser.

func image(for url: URL) -> AnyPublisher<UIImage?, Never> {
    cachedImage(for: url)
        .flatMap { image -> AnyPublisher<UIImage?, Never> in
            if let image = image {
                return Just(image)
                    .eraseToAnyPublisher()
            } else {

            }
        }
}

In the else clause, we invoke the remoteImage(for:) method and return the result.

func image(for url: URL) -> AnyPublisher<UIImage?, Never> {
    cachedImage(for: url)
        .flatMap { image -> AnyPublisher<UIImage?, Never> in
            if let image = image {
                return Just(image)
                    .eraseToAnyPublisher()
            } else {
                return self.remoteImage(for: url)
            }
        }
}

It is important that you understand what sets the flatMap operator apart from the map operator. While they may seem similar, they are quite different. The map operator transforms the elements of an upstream publisher. We covered that several times in this series. The flatMap operator transforms the elements of an upstream publisher by returning another publisher.

The map operator accepts a closure that accepts an element of the upstream publisher. The closure transforms the element and returns the transformed element. Like the map operator, the flatMap operator accepts a closure that accepts an element of the upstream publisher. The closure doesn't return a transformed element, though. It returns another publisher whose Output type is the same as that of the upstream publisher.

Let's continue with the implementation of the image(for:) method. We apply the receive operator to the publisher the flatMap operator returns to ensure the publisher delivers its events to its subscribers on the main thread. The resulting publisher is wrapped with a type eraser.

func image(for url: URL) -> AnyPublisher<UIImage?, Never> {
    cachedImage(for: url)
        .flatMap { image -> AnyPublisher<UIImage?, Never> in
            if let image = image {
                return Just(image)
                    .eraseToAnyPublisher()
            } else {
                return self.remoteImage(for: url)
            }
        }
        .receive(on: DispatchQueue.main)
        .eraseToAnyPublisher()
}

Build and run the application to see the result. Even though the behavior of the application hasn't changed, we drastically improved the implementation of the image service.

What's Next?

The flatMap operator can be confusing at first and that is why some developers avoid it or don't use it. I hope you don't make the same mistake because the flatMap operator can often turn complex code into code that is easier to read and reason about.