In this episode, we continue to improve the solution we implemented in this series by caching images on disk. Caching images on disk has a number of benefits. It reduces the number of requests the application makes and it improves the performance of the application. The user experiences the application as fast and snappy.
Choosing a Location on Disk
Before we can cache images on disk, we need to decide where the image service stores the images in the application's sandbox. There are several options, but two stand out, the tmp directory and the Caches directory. The tmp directory is a viable option, but it isn't the best choice. As the name suggests, the tmp directory should be used for storing data temporarily. The system can decide to clear the contents of the tmp directory at any time, for example, when the device is running low on disk space. The Caches directory lives in the Library directory and is meant to be used for caching.
Open ImageService.swift. We first declare a private computed property, imageCacheDirectory of type URL, that returns the directory the image service uses to store images. The computed property keeps the implementation readable and reduces code duplication.
We ask the default file manager for the URL of the caches directory by invoking the urls(for:in:) method, passing in cachesDirectory as the first argument and userDomainMask as the second argument. The urls(for:in:) method returns an array of URL objects. There is only one Caches directory in the application's sandbox so we ask the default file manager for the first element of the array of URL objects.
private var imageCacheDirectory: URL {
FileManager.default
.urls(for: .cachesDirectory, in: .userDomainMask)[0]
}
I don't recommend storing data in the Caches directory itself. Let's create a subdirectory to avoid cluttering the Caches directory. It also makes it easier to delete the cache on disk and to calculate the size of the cache on disk. We append the name of the subdirectory, ImageCache, to the URL of the Caches directory and return the result.
private var imageCacheDirectory: URL {
FileManager.default
.urls(for: .cachesDirectory, in: .userDomainMask)[0]
.appendingPathComponent("ImageCache")
}
Creating the Image Cache Directory
Before we attempt to write data to the ImageCache directory, the image service needs to create it. We invoke a helper method in the initializer of the ImageService class, createImageCacheDirectory().
// MARK: - Initialization
init(maximumCacheSize: Int) {
// Set Maximum Cache Size
self.maximumCacheSize = maximumCacheSize
// Create Image Cache Directory
createImageCacheDirectory()
}
The implementation of the helper method is straightforward. The image service creates the ImageCache directory by invoking the createDirectory(at:withIntermediateDirectories:attributes:) method on the default file manager. Because the method is throwing, we wrap the method call in a do-catch statement.
The method accepts two required arguments and one optional argument we can ignore. The first argument is the URL of the directory we want to create. By passing true as the second argument of the createDirectory(at:withIntermediateDirectories:attributes:) method, we ask the default file manager to create any missing parent directories.
private func createImageCacheDirectory() {
do {
// Create Image Cache Directory
try FileManager.default.createDirectory(at: imageCacheDirectory, withIntermediateDirectories: true)
} catch {
print("Unable to Create Image Cache Directory")
}
}
Defining the Location on Disk
The next step is defining or constructing the URL of the image on disk. We create a helper method that returns the URL on disk for the URL of a remote image. We define a method with name locationOnDisk(for:). It accepts the URL of the remote image as its only argument and returns the URL on disk.
// MARK: - Convenience Methods
private func locationOnDisk(for url: URL) -> URL {
}
We could use the absolute string of the URL as the filename, but I want to take a different approach that looks cleaner and prevents possible edge cases. We create a Data object from the absolute string of the URL and convert the Data object to a Base-64 encoded string. This ensures the filename doesn't contain characters that might cause problems when writing the image to disk.
// MARK: - Convenience Methods
private func locationOnDisk(for url: URL) -> URL {
// Define Filename
let fileName = Data(url.absoluteString.utf8).base64EncodedString()
}
We append the filename to the URL of the ImageCache directory and return the result. The helper method keeps the implementation readable and we avoid code duplication.
// MARK: - Convenience Methods
private func locationOnDisk(for url: URL) -> URL {
// Define Filename
let fileName = Data(url.absoluteString.utf8).base64EncodedString()
// Define Location on Disk
return imageCacheDirectory
.appendingPathComponent(fileName)
}
Writing Data to Disk
The image is written to disk in the cacheImage(_:for:) method. We make use of yet another helper method, writeImageToDisk(_:for:). I prefer a handful of small helper methods over methods that are difficult to navigate or have too many responsibilities.
private func cacheImage(_ data: Data, for url: URL) {
...
// Write Image to Disk
writeImageToDisk(data, for: url)
}
The helper method accepts the image as a Data object and the URL of the remote image.
private func writeImageToDisk(_ data: Data, for url: URL) {
}
Writing data to disk is a throwing operation so we make use of a do-catch statement. In the do clause we call write(to:) on the Data object, passing in the result of locationOnDisk(for:), the helper method we created earlier. The image service fails silently in the catch clause, printing a message to the console. There is no need to notify the user if this operation fails.
private func writeImageToDisk(_ data: Data, for url: URL) {
do {
// Write Image to Disk
try data.write(to: locationOnDisk(for: url))
} catch {
print("Unable to Write Image to Disk \(error)")
}
}
There is one change I want to make before moving forward. Writing the image to disk should take place on a background thread. Performing this operation on the main thread could result in performance issues.
Grand Central Dispatch makes this trivial. We ask Grand Central Dispatch for a global dispatch queue, passing in utility as the quality of service class. You can learn more about Grand Central Dispatch and quality of service classes in Mastering Grand Central Dispatch.
private func cacheImage(_ data: Data, for url: URL) {
...
DispatchQueue.global(qos: .utility).async {
// Write Image to Disk
self.writeImageToDisk(data, for: url)
}
}
Using the Cache on Disk
We put the cache on disk to use in the cachedImage(for:) method. The image service should always default to the cache in memory because it is faster than the cache on disk. We replace the guard statement with an if statement. If the cache in memory contains the requested remote image, the image service returns it from memory.
// 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
{
return UIImage(data: data)
}
return nil
}
We use an else if clause to read the data of the remote image from disk. Because reading the data from disk should fail silently, we use the try? keyword instead of the try keyword. The locationOnDisk(for:) helper method keeps the else if clause concise and readable.
The body of the else if clause is important. Before the image service returns the image, it passes the image to the cacheImage(_:for:) method. Why is that important? Returning an image from disk is slower than returning an image from memory. In other words, we need to make sure we only read the image from disk once. You could say that the cache on disk lazily seeds the cache in memory.
// 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
{
return UIImage(data: data)
}
else if
// Fall Back to Cache on Disk
let data = try? Data(contentsOf: locationOnDisk(for: url))
{
// Cache Image in Memory
cacheImage(data, for: url)
return UIImage(data: data)
}
return nil
}
There is one more detail we need to take care of. The cacheImage(_:for:) method writes the image to disk. This is fine, but we don't want to write the same image to disk multiple times. In other words, the cacheImage(_:for:) method shouldn't write an image to disk in the else if clause of the cachedImage(for:) method.
We can resolve this by adding an optional parameter to the cacheImage(_:for:) method. Define a parameter with name writeToDisk. The parameter is of type Bool and has a default value of true.
private func cacheImage(_ data: Data, for url: URL, writeToDisk: Bool = true) {
...
}
We wrap the writeImageToDisk(_:for:) method call in an if statement. It should only be executed if writeToDisk is equal to true.
private func cacheImage(_ data: Data, for url: URL, writeToDisk: Bool = true) {
...
if writeToDisk {
DispatchQueue.global(qos: .utility).async {
// Write Image to Disk
self.writeImageToDisk(data, for: url)
}
}
}
In the else if clause of the cachedImage(for:) method, we pass false for the writeToDisk parameter.
// 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
{
return UIImage(data: data)
}
else if
// Fall Back to Cache on Disk
let data = try? Data(contentsOf: locationOnDisk(for: url))
{
// Cache Image in Memory
cacheImage(data, for: url, writeToDisk: false)
return UIImage(data: data)
}
return nil
}
Limiting the Number of Requests
Add a print statement to the if clause of the cachedImage method. We print the string using cache in memory to the console. We also add a print statement to the else if clause, printing the string using cache on disk to the console.
// 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
{
print("Using Cache in Memory")
return UIImage(data: data)
}
else if
// Fall Back to Cache on Disk
let data = try? Data(contentsOf: locationOnDisk(for: url))
{
print("Using Cache on Disk")
// Cache Image in Memory
cacheImage(data, for: url, writeToDisk: false)
return UIImage(data: data)
}
return nil
}
Open the console and run the application in a simulator. The output in the console illustrates that the application fetches every remote image once. That is the result of the cache in memory. Stop the application and run it one more time.
https://cdn.cocoacasts.com/7ba5c3e7df669703cd7f0f0d4cefa5e5947126a8/1-small.jpg
https://cdn.cocoacasts.com/7ba5c3e7df669703cd7f0f0d4cefa5e5947126a8/2-small.jpg
https://cdn.cocoacasts.com/7ba5c3e7df669703cd7f0f0d4cefa5e5947126a8/3-small.jpg
https://cdn.cocoacasts.com/7ba5c3e7df669703cd7f0f0d4cefa5e5947126a8/4-small.jpg
https://cdn.cocoacasts.com/7ba5c3e7df669703cd7f0f0d4cefa5e5947126a8/5-small.jpg
https://cdn.cocoacasts.com/7ba5c3e7df669703cd7f0f0d4cefa5e5947126a8/6-small.jpg
https://cdn.cocoacasts.com/7ba5c3e7df669703cd7f0f0d4cefa5e5947126a8/7-small.jpg
https://cdn.cocoacasts.com/7ba5c3e7df669703cd7f0f0d4cefa5e5947126a8/8-small.jpg
https://cdn.cocoacasts.com/7ba5c3e7df669703cd7f0f0d4cefa5e5947126a8/9-small.jpg
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
The output in the console illustrates that the cache on disk is working as expected. The application uses the cache on disk to seed the cache in memory. The application doesn't perform a single request.
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
What's Next?
In the next episode, we make a few changes to the ImageService class to improve its performance.
The combination of a cache in memory and a cache on disk is quite powerful. Caching is a flexible solution to improve the performance of an application. This series focused on caching images, but it is also common to cache other resources, such as data. The same ideas apply. You can cache in memory, cache on disk, or a combination of both. Caching data comes with a few more challenges, though.