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