The previous episodes have illustrated that caching images can result in significant performance improvements. In the previous episode, I stressed the importance of writing cached images to disk on a background thread to prevent the image service from blocking the main thread.

There is another potential bottleneck we haven't addressed, reading data from disk. Like writing data to disk, reading data from disk can take a non-trivial amount of time. Reading data from disk on the main thread can quickly result in a poorly performing application. In this episode, I show you how to asynchronously read data from disk on a background thread.

Grand Central Dispatch to the Rescue

We can use Grand Central Dispatch to read data from disk on a background thread, but the solution is more complex than that. Don't worry, though. We break the implementation down into small steps that are easy to digest.

Open ImageService.swift and navigate to the cachedImage(for:) method. The image service reads the data from disk on the same thread the image(for:completion:) method is invoked on, that is, the main thread. Because this can cause performance issues, we need to make some changes to the implementation.

The first step is simple. We convert the else if statement to an else statement and read the data from disk on a background thread using Grand Central Dispatch. Notice that we ask Grand Central Dispatch for a global dispatch queue with a quality of service class of userInitiated. You can learn more about Grand Central Dispatch and quality of service classes in Mastering Grand Central Dispatch.

Because we use the try? keyword, we use a guard statement to safely unwrap the Data object.

// MARK: - Helper Methods

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

        print("Using Cache on Disk")

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

        return UIImage(data: data)
    }

    return nil
}

This change introduces a problem. The cachedImage(for:) method expects us to return an optional UIImage instance. This isn't possible since we asynchronously read the data from disk on a background thread. We can work around this problem by passing the UIImage instance to a completion handler. The cachedImage(for:) method accepts the completion handler as an argument and executes it when it successfully read the data from disk. This change makes the cachedImage(for:) method asynchronous. It no longer returns an optional UIImage instance.

// MARK: - Helper Methods

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

In the if clause, the image service invokes the completion handler, passing it the UIImage instance.

// MARK: - Helper Methods

private func cachedImage(for url: URL, completion: @escaping (UIImage?) -> Void) {
    if
        // Default to Cache in Memory
        let data = cache.first(where: { $0.url == url })?.data
    {
        print("Using Cache in Memory")

        // Execute Handler
        completion(UIImage(data: data))
    }
    else
    {
        ...
    }

    return nil
}

In the else clause, the cacheImage(_for:writeToDisk:) method call and the creation of the UIImage instance are moved to the closure that is passed to the global dispatch queue. In that same closure, the image service invokes the completion handler, passing it the UIImage instance. In the else clause of the guard statement, we pass nil as the argument of the completion handler.

// MARK: - Helper Methods

private func cachedImage(for url: URL, completion: @escaping (UIImage?) -> Void) {
    if
        // Default to Cache in Memory
        let data = cache.first(where: { $0.url == url })?.data
    {
        print("Using Cache in Memory")

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

            print("Using Cache on Disk")

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

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

    return nil
}

We also remove the return statement at the end of the cachedImage(for:completion:) method.

// MARK: - Helper Methods

private func cachedImage(for url: URL, completion: @escaping (UIImage?) -> Void) {
    if
        // Default to Cache in Memory
        let data = cache.first(where: { $0.url == url })?.data
    {
        print("Using Cache in Memory")

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

            print("Using Cache on Disk")

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

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

Fixing What We Broke

The changes we made to the cachedImage(for:completion:) method break the implementation of the image(for:completion:) method. Let's fix that. We start by invoking the updated cachedImage(for:completion:) method. It no longer returns an optional UIImage instance. We pass it a completion handler instead.

// MARK: - Public API

func image(for url: URL, completion: @escaping (UIImage?) -> Void) -> Cancellable {
    // Request Cached Image
    cachedImage(for: url) { image in

    }

    ...
}

The completion handler of the cachedImage(for:completion:) method accepts an optional UIImage instance as its only argument. The argument is equal to nil if no image is cached in memory or on disk for the given URL. We safely unwrap the image and pass it to the completion handler of the image(for:completion:) method if it has a value. We use Grand Central Dispatch to execute the completion handler on the main thread.

// MARK: - Public API

func image(for url: URL, completion: @escaping (UIImage?) -> Void) -> Cancellable {
    // Request Cached Image
    cachedImage(for: url) { image in
        if let image = image {
            // Execute Handler on Main Thread
            DispatchQueue.main.async {
                // Execute Handler
                completion(image)
            }
        }
    }

    ...
}

If we reach the else clause, then it means we need to fetch the remote image using a data task. How does that work? The image service already creates a data task and stores it in the dataTask constant. Let's move the cachedImage(for:completion:) method call to the bottom of the image(for:completion:) method, before the return statement.

// MARK: - Public API

func image(for url: URL, completion: @escaping (UIImage?) -> Void) -> Cancellable {
    let dataTask = URLSession.shared.dataTask(with: url) { [weak self] 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)

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

    // Resume Data Task
    dataTask.resume()

    // Request Cached Image
    cachedImage(for: url) { image in
        if let image = image {
            // Execute Handler on Main Thread
            DispatchQueue.main.async {
                // Execute Handler
                completion(image)
            }
        }
    }

    return dataTask
}

We add an else clause to the if statement in the completion handler of the cachedImage(for:completion:) method. In the body of the else clause, the image service calls resume() on the data task.

// MARK: - Public API

func image(for url: URL, completion: @escaping (UIImage?) -> Void) -> Cancellable {
    let dataTask = URLSession.shared.dataTask(with: url) { [weak self] 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)

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

    // Request Cached Image
    cachedImage(for: url) { image in
        if let image = image {
            // Execute Handler on Main Thread
            DispatchQueue.main.async {
                // Execute Handler
                completion(image)
            }
        } else {
            // Fetch Image
            dataTask.resume()
        }
    }

    return dataTask
}

Let me explain what is happening. The image(for:completion:) method creates a data task, even if the remote image is cached in memory or on disk. It only resumes the data task in the completion handler of the cachedImage(for:completion:) method if the remote image isn't cached in memory or on disk.

You may be wondering why the image service creates the data task? Why doesn't it create the data task in the completion handler of the cachedImage(for:completion:) method? The image(for:completion:) method expects us to return a Cancellable object. That isn't possible if the image service creates the data task in the completion handler of the cachedImage(for:completion:) method. Creating a data task isn't an expensive operation so this isn't an issue as long as we make sure to only resume the data task if the remote image cannot be returned from cache.

Cancelling the Data Task

We are not quite done yet. There is a subtle issue we need to address. When the data task is cancelled, the completion handler of the data task is executed. The completion handler defines three parameters, an optional Data object, an optional URLResponse object, and an optional Error object. If the data task failed for whatever reason, the Error object contains the reason of the failure. Cancellation is also considered a failure because the data task didn't complete successfully.

The problem is that the completion handler of the image(for:completion:) method is also executed if the data task is cancelled. That is something we need to avoid because that means there are scenarios in which the completion handler is executed multiple times. We need to refactor the completion handler of the data task to address this issue.

We define a constant, result, of type Result?. The associated value of the success case is of type Data and the associated value of the failure case is of type Error. We use a self-executing closure to set the value of the result constant.

let dataTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
    // Helper
    let result: Result<Data, Error> = {

    }()
}

If the data parameter isn't equal to nil, we return success from the self-executing closure, passing the Data object as the associated value.

let dataTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
    // Helper
    let result: Result<Data, Error>? = {
        if let data = data {
            // Success
            return .success(data)
        }
    }()
}

We use an else if clause to safely unwrap the error parameter of the completion handler and cast the error to an NSError object. If the data task was cancelled, the error code is set to -999. Don't worry, though. We don't need to use a number literal and you don't need to remember the error code for a cancelled data task. The URLError enum defines the possible error codes. We compare the error code to the raw value of the cancelled case. We return failure from the self-executing closure, passing the Error object as the associated value unless the error code indicates the data task was cancelled. In the else clause of the if statement, we return nil.

let dataTask = 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
        }
    }()
}

We can now switch on the result constant. If the data task completed successfully, the image service uses the Data object to create a UIImage instance and passes it to the completion handler. It caches the image by passing the Data object to its cacheImage(_:for:) method.

let dataTask = 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
        }
    }()

    switch result {
    case .success(let data):
        // Execute Handler
        completion(UIImage(data: data))

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

If the data task failed and it wasn't cancelled, the image service executes the completion handler. Because the data task failed, we pass nil as the argument of the completion handler.

let dataTask = 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
        }
    }()

    switch result {
    case .success(let data):
        // Execute Handler
        completion(UIImage(data: data))

        // Cache Image
        self?.cacheImage(data, for: url)
    case .failure:
        // Execute Handler
        completion(nil)
    }
}

If result is equal to nil, we know the data task was cancelled. We add a none case with a break statement to the switch statement to cover this scenario. As I mentioned earlier, the image service doesn't execute the completion handler if the data task was cancelled.

let dataTask = 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
        }
    }()

    switch result {
    case .success(let data):
        // Execute Handler
        completion(UIImage(data: data))

        // Cache Image
        self?.cacheImage(data, for: url)
    case .failure:
        // Execute Handler
        completion(nil)
    case .none:
        break
    }
}

We use Grand Central Dispatch to dispatch the execution of the switch statement to the main thread.

let dataTask = 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):
            // Execute Handler
            completion(UIImage(data: data))

            // Cache Image
            self?.cacheImage(data, for: url)
        case .failure:
            // Execute Handler
            completion(nil)
        case .none:
            break
        }
    }
}

Testing the Implementation

We add a print statement to each case of the switch statement to illustrate the result of the changes we made.

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

Build and run the application to see the result. Scroll the table view and inspect the output in the console.

Using Cache on Disk
Using Cache on Disk
Using Cache on Disk
Using Cache on Disk
Using Cache on Disk
Using Cache on Disk
Using Cache on Disk
Using Cache on Disk
Using Cache on Disk
Using Cache in Memory
Using Cache in Memory
Using Cache in Memory
Using Cache in Memory
Using Cache in Memory
Using Cache in Memory
Using Cache in Memory
Using Cache in Memory
Using Cache in Memory
Using Cache in Memory
Using Cache in Memory
Using Cache in Memory
Data Task Cancelled
Using Cache in Memory
Data Task Cancelled

The output in the console confirms that the image service relies exclusively on the cache in memory and the cache on disk. The output also confirms that a data task is cancelled if an image is returned from cache.

Cleaning Up

Because the image(for:completion:) method always returns a data task, we no longer need the CachedRequest struct. It is safe to remove it. Can we also remove the Cancellable protocol and simply return a data task? That isn't something I recommend. It is important that the image service doesn't leak its implementation. Objects using the ImageService class don't need to know how it fetches remote images. By returning an object that conforms to the Cancellable protocol, we have the flexibility to make changes to the ImageService class without breaking its API.

What's Next?

The image service is becoming more flexible and more capable as this series progresses. It takes advantage of a cache in memory and a cache on disk to keep the application performant. Accessing the cache on disk, that is, reading and writing, takes place on a background thread to make sure it doesn't impact the main thread.

The image service offers the option to define the size of the cache in memory. In the next episode, we add a similar option for the cache on disk.