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.