We added the ability to cancel image requests in the previous episode. This and the next episode focus on caching images. We start simple by caching images in memory.
Adding Support for Caching Images
The simplest form of caching images is caching them in memory. The ImageService class we implemented in the previous episode is responsible for caching images. Open ImageService.swift and define a private, nested struct CachedImage. As the name suggests, a CachedImage object describes a cached image. It defines two properties, url of type URL and data of type Data.
import UIKit
final class ImageService {
// MARK: - Types
private struct CachedImage {
// MARK: - Properties
let url: URL
// MARK: -
let data: Data
}
...
}
The image service manages the cache in memory. Declare a private, variable property, cache, of type [CachedImage] and set its initial value to an empty array.
import UIKit
final class ImageService {
// MARK: - Types
private struct CachedImage {
// MARK: - Properties
let url: URL
// MARK: -
let data: Data
}
// MARK: - Properties
private var cache: [CachedImage] = []
...
}
We implement a helper method to request the cached image for a given URL. Define a private method with name cachedImage(for:). The method accepts a URL object as its only argument and returns an optional UIImage instance.
// MARK: - Helper Methods
private func cachedImage(for url: URL) -> UIImage? {
}
We use a guard statement to query the array of cached images for a CachedImage object whose URL matches the URL that is passed to the cachedImage(for:) method. The Data object of the cached image is used to create and return a UIImage instance.
// MARK: - Helper Methods
private func cachedImage(for url: URL) -> UIImage? {
guard let data = cache.first(where: { $0.url == url })?.data else {
return nil
}
return UIImage(data: data)
}
In the image(for:completion:) method, we request a cached image for the given URL. If a cached image is returned, we invoke the completion handler, passing in the UIImage instance.
// MARK: - Public API
func image(for url: URL, completion: @escaping (UIImage?) -> Void) -> Cancellable {
if let image = cachedImage(for: url) {
// Execute Handler
completion(image)
}
let dataTask = URLSession.shared.dataTask(with: url) { data, _, _ in
...
}
// Resume Data Task
dataTask.resume()
return dataTask
}
There is one problem, though. The image(for:completion:) method expects us to return an object that conforms to the Cancellable protocol. We could change the return type of the method to Cancellable? and return nil if the image is returned from memory, but that would mean leaking implementation details of the ImageService class. This is a viable approach, but I would like to show you an alternative solution that hides the implementation of the ImageService class.
We define another private, nested struct, CachedRequest, that conforms to the Cancellable protocol. The CachedRequest struct implements the cancel() method to meet the requirements of the Cancellable protocol, but the body of the cancel() method is empty.
import UIKit
final class ImageService {
// MARK: - Types
private struct CachedImage {
// MARK: - Properties
let url: URL
// MARK: -
let data: Data
}
private struct CachedRequest: Cancellable {
func cancel() {
}
}
// MARK: - Properties
private var cache: [CachedImage] = []
...
}
In the if statement of the image(for:completion:) method, the image service returns a CachedRequest object. Because the CachedRequest struct is declared privately, the image service doesn't leak any of its implementation details. The caller of the image(for:completion:) method receives an object conforming to the Cancellable protocol. It is unaware of the CachedRequest struct. That is the beauty and elegance of protocol-oriented programming.
// MARK: - Public API
func image(for url: URL, completion: @escaping (UIImage?) -> Void) -> Cancellable {
if let image = cachedImage(for: url) {
// Execute Handler
completion(image)
return CachedRequest()
}
let dataTask = URLSession.shared.dataTask(with: url) { data, _, _ in
...
}
// Resume Data Task
dataTask.resume()
return dataTask
}
Before we can test the implementation, we need to update the cache when the image service successfully fetches a remote image. Define another private method with name cacheImage(_:for:). The method accepts a Data object as its first argument and a URL object as its second argument.
private func cacheImage(_ data: Data, for url: URL) {
}
The image service creates a CachedImage object and appends it to the array of cached images.
private func cacheImage(_ data: Data, for url: URL) {
// Create Cached Image
let cachedImage = CachedImage(url: url, data: data)
// Cache Image
cache.append(cachedImage)
}
With the helper method in place, we can update the implementation of the image(for:completion:) method. We use a capture list to weakly reference the image service in the completion handler of the data task. In the if statement, the image service invokes its cacheImage(_:for:) method, passing in the Data object and the URL of the remote image.
// MARK: - Public API
func image(for url: URL, completion: @escaping (UIImage?) -> Void) -> Cancellable {
if let image = cachedImage(for: url) {
// Execute Handler
completion(image)
return CachedRequest()
}
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()
return dataTask
}
Add a print statement before the creation of the data task and print the value of the url parameter. Build and run the application to see the result of the changes we made to the ImageService class.
// MARK: - Public API
func image(for url: URL, completion: @escaping (UIImage?) -> Void) -> Cancellable {
if let image = cachedImage(for: url) {
// Execute Handler
completion(image)
return CachedRequest()
}
print(url)
let dataTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
...
}
// Resume Data Task
dataTask.resume()
return dataTask
}
Scroll to the bottom of the table view and inspect the output in the console. You can see that the application still fetches some images multiple times. Why is that?
Each table view cell has its own image service and each image service has its own cache of images. This means it takes much longer for each image service to cache the images. A more important problem is that the application consumes more memory because each image service manages its own cache of images. The good news is that the solution requires only a few lines of code.
Injecting the Image Service
We could solve this problem by creating an ImageService singleton and making it accessible through a static property. That isn't a solution I recommend, though. There are plenty of reasons to avoid singletons, one of them being poor testability. I prefer dependency injection as a solution.
We first move the imageService property from the LandscapeTableViewCell class to the LandscapesViewController class. A table view cell can use the image service, but it shouldn't own it, let alone instantiate it.
import UIKit
final class LandscapesViewController: UIViewController {
// MARK: - Properties
...
// MARK: -
private lazy var imageService = ImageService()
...
}
In the tableView(_:cellForRowAt:) method, the landscapes view controller passes a reference to its image service to each table view cell. Open LandscapeTableViewCell.swift and add a third parameter to the configure(title:imageUrl:) method, imageService, of type ImageService. Notice that the table view cell doesn't hold on to the image service. It uses the image service to request an image and stores the Cancellable object it returns in its imageRequest property. That's it.
// MARK: - Public API
func configure(title: String, imageUrl: URL, imageService: ImageService) {
// Configure Title Label
titleLabel.text = title
// Animate Activity Indicator View
activityIndicatorView.startAnimating()
// Request Image Using Image Service
imageRequest = imageService.image(for: imageUrl) { [weak self] image in
// Update Thumbnail Image View
self?.thumbnailImageView.image = image
}
}
Revisit LandscapesViewController.swift and pass a reference to the image service to the table view cell's updated configure(title:imageUrl:imageService:) method.
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: LandscapeTableViewCell.reuseIdentifier, for: indexPath) as? LandscapeTableViewCell else {
fatalError("Unable to Dequeue Landscape Table View Cell")
}
// Fetch Landscape
let landscape = landscapes[indexPath.row]
// Configure Cell
cell.configure(title: landscape.title,
imageUrl: landscape.imageUrl,
imageService: imageService)
return cell
}
Build and run the application. The result of injecting the image service into the table view cell is pretty dramatic. Once the images are cached in memory, the image service makes no more requests and the images are displayed without delay.
Setting Limitations
The benefits of caching images in memory are obvious. It is fast because the images are already in memory. The application can display them without delay. As a developer, you don't need to worry about cache control because the cache is empty every time the application launches.
There is one aspect of image caching we need to be mindful of, memory consumption. What happens if the API returns thousands of landscapes? Can the solution we implemented handle this? We need to set a limit on the size of the cache the image service manages.
Open ImageService.swift and define a private, constant property, maximumCacheSize, of type Int. We also define an initializer that accepts an argument with the same name and type. In the body of the initializer, we store the value of the maximumCacheSize parameter in the maximumCacheSize property.
import UIKit
final class ImageService {
// MARK: - Types
...
// MARK: - Properties
private var cache: [CachedImage] = []
// MARK: -
private let maximumCacheSize: Int
// MARK: - Initialization
init(maximumCacheSize: Int) {
// Set Maximum Cache Size
self.maximumCacheSize = maximumCacheSize
}
...
}
The maximum cache size is enforced in the cacheImage(_:for:) method using a while loop. Let me show you what I have in mind. We calculate the size of the cache by invoking the reduce(_:_:) method on the array of cached images. The result is stored in a local variable, cacheSize. That is why the CachedImage struct stores a Data object and doesn't hold on to a UIImage instance. If a CachedImage object were to store a UIImage instance, we would first need to convert it back to a Data object to obtain the size of the image.
private func cacheImage(_ data: Data, for url: URL) {
// Calculate Cache Size
var cacheSize = cache.reduce(0) { result, cachedImage -> Int in
result + cachedImage.data.count
}
// Create Cached Image
let cachedImage = CachedImage(url: url, data: data)
// Cache Image
cache.append(cachedImage)
}
We create a while loop and repeat the loop as long as the value of cacheSize is greater than that of maximumCacheSize.
private func cacheImage(_ data: Data, for url: URL) {
// Calculate Cache Size
var cacheSize = cache.reduce(0) { result, cachedImage -> Int in
result + cachedImage.data.count
}
while cacheSize > maximumCacheSize {
}
// Create Cached Image
let cachedImage = CachedImage(url: url, data: data)
// Cache Image
cache.append(cachedImage)
}
In the body of the while loop, the oldest cached image is removed from the array of cached images and its size is subtracted from the value stored in cacheSize.
private func cacheImage(_ data: Data, for url: URL) {
// Calculate Cache Size
var cacheSize = cache.reduce(0) { result, cachedImage -> Int in
result + cachedImage.data.count
}
while cacheSize > maximumCacheSize {
// Remove Oldest Cached Image
let oldestCachedImage = cache.removeFirst()
// Update Cache Size
cacheSize -= oldestCachedImage.data.count
}
// Create Cached Image
let cachedImage = CachedImage(url: url, data: data)
// Cache Image
cache.append(cachedImage)
}
Revisit LandscapesViewController.swift and set the maximum cache size of the image service to half a megabyte.
private lazy var imageService = ImageService(maximumCacheSize: 512 * 1024)
By setting a limit on the cache size, we can be sure the application's memory footprint stays within limits we define.
What's Next?
Even though the implementation of the ImageService class isn't complex, the benefits of image caching are obvious. In the next episode, we take it one step further by caching images on disk.