Swift and Cocoa Essentials

Increasing Performance Through Caching

Stop Writing Swift That Sucks

DISCLAIMER: No Rocket Science Involved

Join 20,000+ Developers Learning About Swift Development

Download the 4 Swift Patterns I Swear By

In the previous episode, we drastically improved the performance and usability of the application. It no longer takes several seconds for the application to become responsive. Even though the application's performance has gotten better, there's room for improvement.

Let me show you which problems I'd like to address in this episode. Launch the application and open the Debug Navigator on the left. Select Network from the list of options. This shows us a summary of the application's network activity. Every time a table view cell comes into view the application fetches the image for the table view cell from the remote server. While this isn't surprising, there is a problem.

If we scroll the table view up and down a few times, we notice that the application continues to fetch images from the remote server. While this may not seem surprising, it should set off an alarm bell. The application fetches the same resources over and over. This impacts performance and it also impacts the resources of the device. Fetching data from a remote server takes time and energy. We need to find a solution to improve the application's behavior.

Caching Resources

We can substantially improve the application's performance by implementing a caching strategy. Even though caching is a simple concept to grasp, the implementation can be complex and often depends on the needs of the project. A cache is nothing more than a location to store data for later use. That location can be in memory or on disk.

As I mentioned earlier, the idea is simple. When the application requests the data for a particular resource, such as an image, the application stores a copy of that data in a store for later use. The data is cached. If the application needs the same resource at a later point in time, it first checks if the store has the data the application is requesting. If it does, the resource is served from the store, saving a roundtrip to the server.

The concept is simple, but there's much more to it. Deciding when a resource should be served from cache is an important consideration. That decision directly affects the efficiency of the cache. A cache should have a certain size and the data it stores should expire at some point to make sure the application doesn't show the user stale data that is no longer relevant. We won't cover the nitty-gritty details of caching in this episode. The focus of this episode is improving the performance by implementing a caching strategy.

Data Tasks

The first change we make has nothing to do with caching. We need to change the approach we take to fetch the data for the images the application displays. The current implementation is very basic. We initialize a Data instance by invoking the init(contentsOf:options:) initializer of the Data struct.

That's an easy solution, but it's an API you don't want to use if you're working with remote resources. It isn't possible to cancel the underlying request that fetches the data and this becomes a problem if you use the API in combination with a table or collection view. If a table or collection view cell is about to be reused and the data is still being fetched from the remote server, we want the ability to cancel the operation. The API of the Data struct doesn't offer this ability.

The URLSession API is a much better choice and it isn't difficult to implement. Open ImageTableViewCell.swift and define a private, variable property, dataTask, of type URLSessionDataTask?.

import UIKit

final class ImageTableViewCell: UITableViewCell {

    // MARK: - Static Properties

    static var reuseIdentifier: String {
        return String(describing: self)
    }

    // MARK: - Properties

    private var dataTask: URLSessionDataTask?

    // MARK: -

    @IBOutlet private var titleLabel: UILabel!

    // MARK: -

    @IBOutlet private var thumbnailImageView: UIImageView!

    ...

}

We ask the shared URLSession instance for a data task by invoking dataTask(with:completionHandler:). The first argument is the URL for the request. The second argument, a closure, is invoked when the request completes, successfully or unsuccessfully. The closure defines three parameters, an optional Data instance, an optional URLResponse instance, and an optional Error instance.

We're only interested in the Data instance. We safely unwrap the Data instance and use it to create a UIImage instance. That should look familiar. We update the image property of the image view on the main thread using Grand Central Dispatch.

Because we store a reference to the data task in the dataTask property of the ImageTableViewCell instance, we need to weakly reference self in the closure. We don't want to create a strong reference cycle. We use a capture list to accomplish that.

func configure(with title: String, url: URL?, session: URLSession) {
    // Configure Title Label
    titleLabel.text = title

    if let url = url {
        // Create Data Task
        let dataTask = URLSession.shared.dataTask(with: url) { [weak self] (data, _, _) in
            guard let data = data else {
                return
            }

            // Initialize Image
            let image = UIImage(data: data)

            DispatchQueue.main.async {
                // Configure Thumbnail Image View
                self?.thumbnailImageView.image = image
            }
        }
    }
}

To initiate the request, we invoke resume() on the URLSessionDataTask instance. Developers new to the URLSession API often overlook this step.

func configure(with title: String, url: URL?, session: URLSession) {
    // Configure Title Label
    titleLabel.text = title

    if let url = url {
        // Create Data Task
        let dataTask = URLSession.shared.dataTask(with: url) { [weak self] (data, _, _) in
            guard let data = data else {
                return
            }

            // Initialize Image
            let image = UIImage(data: data)

            DispatchQueue.main.async {
                // Configure Thumbnail Image View
                self?.thumbnailImageView.image = image
            }
        }

        // Resume Data Task
        dataTask.resume()
    }
}

Last but not least, we store a reference to the data task in the dataTask property of the ImageTableViewCell class. Why we keep a reference to the data task becomes clear in a moment.

func configure(with title: String, url: URL?, session: URLSession) {
    // Configure Title Label
    titleLabel.text = title

    if let url = url {
        // Create Data Task
        let dataTask = URLSession.shared.dataTask(with: url) { [weak self] (data, _, _) in
            guard let data = data else {
                return
            }

            // Initialize Image
            let image = UIImage(data: data)

            DispatchQueue.main.async {
                // Configure Thumbnail Image View
                self?.thumbnailImageView.image = image
            }
        }

        // Resume Data Task
        dataTask.resume()

        // Update Data Task
        self.dataTask = dataTask
    }
}

The images the application fetches from the remote server are quite large and if you scroll the table view, the application doesn't have the time to fetch each image in time to display it. This isn't a problem, but it's something we need to keep in mind.

If a table view cell is about to be reused, we need to cancel the request if it hasn't completed. That is why we keep a reference to the data task. In prepareForReuse(), we invoke cancel() on the URLSessionDataTask instance. That does the trick. We also discard the data task by setting the dataTask property to nil. This isn't strictly necessary, but it's a good habit to clean up objects you no longer need.

override func prepareForReuse() {
    super.prepareForReuse()

    // Cancel Data Task
    dataTask?.cancel()

    // Discard Data Task
    dataTask = nil

    // Reset Thumnail Image View
    thumbnailImageView.image = nil
}

This is the first performance improvement and I'm sure you agree that it wasn't that hard to implement. Let's now focus on caching.

Caching Resources

Let's implement a caching strategy to further improve the application's performance. Open ImagesViewController.swift. We won't use the shared URLSession instance for fetching the data for the images. Define a private, lazy, variable property, session, of type URLSession.

private lazy var session: URLSession = {

}()

To create a URLSession instance, we need a URLSessionConfiguration object. As the name implies, it configures the URLSession instance. We create a default session configuration by invoking the default class method.

private lazy var session: URLSession = {
    // Create URL Session Configuration
    let configuration = URLSessionConfiguration.default
}()

We define the request cache policy by setting the requestCachePolicy property of the URLSessionConfiguration instance to returnCacheDataElseLoad. The caching strategy we choose for is simple. If a cached response is available for the request, the cached response is used. If no cached response is available for the request, the data is fetched from the remote server. This is a simple caching strategy that works well for static resources, such as images.

private lazy var session: URLSession = {
    // Create URL Session Configuration
    let configuration = URLSessionConfiguration.default

    // Define Request Cache Policy
    configuration.requestCachePolicy = .returnCacheDataElseLoad
}()

We initialize a URLSession instance using the URLSessionConfiguration instance. It's the URLSessionConfiguration object that defines the behavior of the URLSession instance.

private lazy var session: URLSession = {
    // Create URL Session Configuration
    let configuration = URLSessionConfiguration.default

    // Define Request Cache Policy
    configuration.requestCachePolicy = .returnCacheDataElseLoad

    return URLSession(configuration: configuration)
}()

We pass the URLSession instance to the ImageTableViewCell as an argument of the configure(with:url:) method. Open ImageTableViewCell.swift and navigate to the configure(with:url:) method. We define a third parameter, session, of type URLSession. The ImageTableViewCell instance uses the URLSession object to create the data task.

func configure(with title: String, url: URL?, session: URLSession) {
    // Configure Title Label
    titleLabel.text = title

    if let url = url {
        // Create Data Task
        let dataTask = session.dataTask(with: url) { [weak self] (data, _, _) in
            guard let data = data else {
                return
            }

            // Initialize Image
            let image = UIImage(data: data)

            DispatchQueue.main.async {
                // Configure Thumbnail Image View
                self?.thumbnailImageView.image = image
            }
        }

        // Resume Data Task
        dataTask.resume()

        self.dataTask = dataTask
    }
}

Revisit ImagesViewController.swift and update the configure(with:url:) method of the ImageTableViewCell instance in the tableView(_:cellForRowAt:) method.

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    guard let cell = tableView.dequeueReusableCell(withIdentifier: ImageTableViewCell.reuseIdentifier, for: indexPath) as? ImageTableViewCell else {
        fatalError("Unable to Dequeue Image Table View Cell")
    }

    // Fetch Image
    let image = dataSource[indexPath.row]

    // Configure Cell
    cell.configure(with: image.title, url: image.url, session: session)

    return cell
}

Remove the application from the device or simulator. Build and run the application. It should take a few seconds for the images to load.

Open the Debug Navigator on the left and select Network. The network activity doesn't appear to improve over time. Something's not right. Remember that the images the application fetches from the remote server are quite large. Most of the images are well over 1 megabyte in size. The default cache size, however, isn't quite that large. We need to increase the cache size and that's easy to do.

Open ImagesViewController.swift and navigate to the session property we defined earlier. We can configure the cache of the URLSession instance through the URLSessionConfiguration instance by setting its urlCache property.

A default URL session configuration uses the shared URL cache object, which we can access through the shared class method of the URLCache class. Let's print the value of the memoryCapacity property to find out what the size of the in-memory cache is.

private lazy var session: URLSession = {
    print(URLCache.shared.memoryCapacity)

    // Create URL Session Configuration
    let configuration = URLSessionConfiguration.default

    // Define Request Cache Policy
    configuration.requestCachePolicy = .returnCacheDataElseLoad

    return URLSession(configuration: configuration)
}()

The size of the in-memory cache is only half a megabyte. That isn't enough for the application's needs.

512000

You shouldn't make the size of the cache too large, but let's increase it to half a gigabyte to resolve the issue we're having.

private lazy var session: URLSession = {
    // Set In-Memory Cache to 512 MB
    URLCache.shared.memoryCapacity = 512 * 1024 * 1024

    // Create URL Session Configuration
    let configuration = URLSessionConfiguration.default

    // Define Request Cache Policy
    configuration.requestCachePolicy = .returnCacheDataElseLoad

    return URLSession(configuration: configuration)
}()

Remove the application from the device or simulator. Build and run the application one more time and open the Debug Navigator on the left. The network activity of the application displays a few spikes when the application is launched, but the application doesn't perform any requests once the requests for the images are cached. That's the performance improvement we were looking for.

Image Size

Even though we have significantly improved the performance of the application, there's still room for improvement. Scrolling the table view still doesn't feel quite right. Every time an image is loaded from cache and displayed to the user, scrolling performance is affected. As I mentioned earlier, the images the application downloads are quite large and that isn't something the client can change. But there's a workaround that can improve the application's performance.

The size of the UIImage instance is something we can change by resizing the image that is used to populate the table view cells. That should improve the rendering performance of the image view. Create a new group, Extensions, and add a Swift file to it. Name the file UIImage.swift.

Replace the import statement for Foundation with an import statement for UIKit and create an extension for the UIImage class.

import UIKit

extension UIImage {

}

We define an instance method, resizedImage(with:), that accepts a CGSize instance. The return type of the instance method is UIImage?.

import UIKit

extension UIImage {

    func resizedImage(with size: CGSize) -> UIImage? {

    }

}

The implementation isn't important for the rest of the discussion. The idea is simple. We create a graphics context with the size that is passed as an argument. We draw the image in the graphics context and create a UIImage instance by invoking UIGraphicsGetImageFromCurrentImageContext(). This function creates a UIImage instance based on the contents of the current graphics context. We clean up the graphics context and return the resized image.

import UIKit

extension UIImage {

    func resizedImage(with size: CGSize) -> UIImage? {
        // Create Graphics Context
        UIGraphicsBeginImageContextWithOptions(size, false, 0.0)

        // Draw Image in Graphics Context
        draw(in: CGRect(origin: .zero, size: size))

        // Create Image from Current Graphics Context
        let resizedImage = UIGraphicsGetImageFromCurrentImageContext()

        // Clean Up Graphics Context
        UIGraphicsEndImageContext()

        return resizedImage
    }

}

The return type of the instance method is of an optional type because the return type of UIGraphicsGetImageFromCurrentImageContext() is of type UIImage?.

With the helper method in place, revisit ImageTableViewCell.swift. We resize the image the application fetches from the remote server and use the UIImage instance to populate the thumbnail image view of the table view cell.

func configure(with title: String, url: URL?, session: URLSession) {
    // Configure Title Label
    titleLabel.text = title

    if let url = url {
        // Create Data Task
        let dataTask = session.dataTask(with: url) { [weak self] (data, _, _) in
            guard let data = data else {
                return
            }

            // Initialize Image
            let image = UIImage(data: data)?.resizedImage(with: CGSize(width: 200.0, height: 200.0))

            DispatchQueue.main.async {
                // Configure Thumbnail Image View
                self?.thumbnailImageView.image = image
            }
        }

        // Resume Data Task
        dataTask.resume()

        self.dataTask = dataTask
    }
}

Launch the application and scroll the table view. Loading the images isn't faster, but the scrolling performance of the application has improved substantially. Scrolling the table view is smooth and no longer stutters. Resizing the images takes place on a background thread while rendering the images is performed on the main thread.

Fixing the Activity Indicator View

Because we're aiming for perfection, we need to fix a minor issue. The activity indicator view stops animating the moment a table view cell is reused. This is easy to fix. Define an outlet for an activity indicator view in ImageTableViewCell.swift.

import UIKit

final class ImageTableViewCell: UITableViewCell {

    ...

    // MARK: - Properties

    @IBOutlet private var titleLabel: UILabel!

    // MARK: -

    @IBOutlet private var thumbnailImageView: UIImageView!

    // MARK: -

    @IBOutlet private var activityIndicatorView: UIActivityIndicatorView!

    ...

}

Open Main.storyboard, open the Connections Inspector on the right, and select the table view cell in the table view of the images view controller. Connect the outlet we defined a moment ago to the activity indicator view of the table view cell.

Connecting the Outlet In the Storyboard

Open ImageTableViewCell.swift and revisit the configure(with:url:session:) method. Invoke startAnimating() on the activity indicator view before creating the data task.

func configure(with title: String, url: URL?, session: URLSession) {
    // Configure Title Label
    titleLabel.text = title

    if let url = url {
        // Start Activity Indicator View
        activityIndicatorView.startAnimating()

        // Create Data Task
        let dataTask = session.dataTask(with: url) { [weak self] (data, _, _) in
            guard let data = data else {
                return
            }

            // Initialize Image
            let image = UIImage(data: data)?.resizedImage(with: CGSize(width: 200.0, height: 200.0))

            DispatchQueue.main.async {
                // Configure Thumbnail Image View
                self?.thumbnailImageView.image = image
            }
        }

        // Resume Data Task
        dataTask.resume()

        self.dataTask = dataTask
    }
}

Run the application to make sure the issue is fixed.

Before You Go

We explicitly created a URLSession instance in the ImagesViewController class. This isn't necessary. We could have used the shared URLSession object the URLSession API exposes. By explicitly creating a URLSession instance, you see that no magic takes place under the hood.

Increasing the cache size was necessary to make everything work. The images the application downloads from the remote server are quite large and the default cache size is too small. It's important to keep the cache size as small as possible in a production application. You don't want your application to take up hundreds of megabytes of memory.

This episode has highlighted a number of essential aspects of Swift and Cocoa development. First, it's important to use the right tool for the job. Creating a Data instance by invoking the init(contentsOf:options:) initializer is convenient, but it doesn't give us any control over the request that fetches the data for the resource. The URLSession API is more powerful. It was designed to be flexible and versatile.

Second, application performance is something you should always keep an eye on. Even though it's fine to perform work on the main thread, as a project grows it can slowly lead to performance issues. Analyzing the performance of an application on a regular basis is important if you want to keep your users happy.

Third, improving application performance doesn't need to be complex or difficult. The changes we made in this and the previous episodes are no rocket science. Caching is a widely used strategy in software development to improve the performance and efficiency of an application.

And last but not least, it's important to scrutinize the APIs and services your application uses. If the images the application fetches from the remote server would be smaller, we wouldn't have to resize them on the client. Know that resizing the images on the client also takes up time and energy. There's no such thing as a free lunch.

What's Next?

Even though the application's performance has improved radically, there's room for improvement. For example, there's no need for the application to resize the images every time a table view cell comes into view. And should the table view cell be responsible for fetching the data for the image? Remember that a table view cell is a view and a view should be dumb. It shouldn't be concerned with networking.

There's always room for improvement. We stop here, but feel free to explore solutions to remedy these issues.

Stop Writing Swift That Sucks

DISCLAIMER: No Rocket Science Involved

Join 20,000+ Developers Learning About Swift Development

Download the 4 Swift Patterns I Swear By
Next Episode "What Is Asynchronous Programming"