Earlier in this series, you learned that a cache on disk has a number of benefits. It persists the cache across launches and it can be used to seed a cache in memory. Even though modern devices have plenty of disk space, we need to be mindful of the space the cache on disk takes up on the user's device. Applications like Twitter and Instagram fetch hundreds if not thousands of images. Even if those images are optimized and small in size, the cache on disk can grow quickly, taking up a non-trivial amount of space on the user's device. In this episode, we add the ability to limit the size of the cache on disk, similar to how the image service limits the size of the cache in memory.

Updating the Initializer

We pass the maximum cache size on disk as an argument to the initializer of the ImageService class. We first rename the maximumCacheSize property to maximumCacheSizeInMemory and we repeat this for the maximumCacheSize parameter of the initializer.

final class ImageService {

    ...

    private let maximumCacheSizeInMemory: Int

    // MARK: - Initialization

    init(maximumCacheSizeInMemory: Int) {
        // Set Maximum Cache Size
        self.maximumCacheSizeInMemory = maximumCacheSizeInMemory

        // Create Image Cache Directory
        createImageCacheDirectory()
    }

    ...

}

The cacheImage(_:for:writeToDisk:) method accesses the value of the maximumCacheSizeInMemory property. We update the cacheImage(_:for:writeToDisk:) method to reflect the name change.

private func cacheImage(_ data: Data, for url: URL, writeToDisk: Bool = true) {
    ...

    while cacheSize > maximumCacheSizeInMemory {
        ...
    }

    ...
}

Define a private, constant property, maximumCacheSizeOnDisk, of type Int. We also update the initializer to accept an argument with the same name and type. In the body of the initializer, we store the value of the maximumCacheSizeOnDisk parameter in the maximumCacheSizeOnDisk property.

final class ImageService {

    ...

    private let maximumCacheSizeInMemory: Int
    private let maximumCacheSizeOnDisk: Int

    // MARK: - Initialization

    init(maximumCacheSizeInMemory: Int, maximumCacheSizeOnDisk: Int) {
        // Set Maximum Cache Size
        self.maximumCacheSizeInMemory = maximumCacheSizeInMemory
        self.maximumCacheSizeOnDisk = maximumCacheSizeOnDisk

        // Create Image Cache Directory
        createImageCacheDirectory()
    }

    ...

}

Open LandscapesViewController.swift and update the initialization of the ImageService instance by updating the name of the first parameter to maximumCacheSizeInMemory and passing a value for the maximumCacheSizeOnDisk parameter. We set the maximum size of the cache on disk to a low value to test the implementation.

private lazy var imageService = ImageService(maximumCacheSizeInMemory: 512 * 1024,
                                             maximumCacheSizeOnDisk: 50 * 1024)

Before we move on, I would like to make a small change to improve readability. Create a group with name Extensions. Add a Swift file with name Int+Size.swift to the Extensions group. We define an extension for the Int struct. In the extension, we define a static, constant property, kilobyte, of type Int. We assign the number of bytes that go into a kilobyte to the static property.

import Foundation

extension Int {

    static let kilobyte: Int = 1024

}

Revisit LandscapesViewController.swift. We can make the initialization of the ImageService instance more readable by using the kilobyte static property we defined in the extension for the Int struct.

private lazy var imageService = ImageService(maximumCacheSizeInMemory: 512 * .kilobyte,
                                             maximumCacheSizeOnDisk: 50 * .kilobyte)

Listing the Contents of a Directory

Open ImageService.swift. The remainder of this episode focuses on the implementation of the writeImageToDisk(_:for:) method. We invoke a helper method, updateCacheOnDisk(), after successfully writing the image to disk. There is no need to update the cache on disk if writing the image to disk failed.

private func writeImageToDisk(_ data: Data, for url: URL) {
    do {
        // Write Image to Disk
        try data.write(to: locationOnDisk(for: url))

        // Update Cache on Disk
        updateCacheOnDisk()
    } catch {
        print("Unable to Write Image to Disk \(error)")
    }
}

There is a good reason for creating a helper method to limit the size of the cache on disk. The cache on disk needs to be updated in two scenarios. The first scenario is when the image service successfully writes an image to disk. The second scenario is less obvious. The image service should enforce the value stored in maximumCacheSizeOnDisk when it is initialized. This is important because it is possible the value of maximumCacheSizeOnDisk changed across launches. The image service needs to prevent the cache on disk from exceeding the value stored in maximumCacheSizeOnDisk. Invoke the helper method in the initializer to cover the second scenario.

// MARK: - Initialization

init(maximumCacheSizeInMemory: Int, maximumCacheSizeOnDisk: Int) {
    // Set Maximum Cache Size
    self.maximumCacheSizeInMemory = maximumCacheSizeInMemory
    self.maximumCacheSizeOnDisk = maximumCacheSizeOnDisk

    // Create Image Cache Directory
    createImageCacheDirectory()

    // Update Cache on Disk
    updateCacheOnDisk()
}

Limiting the size of the cache on disk involves three steps. First, the image service asks the default file manager for the contents of the image cache directory. Second, it uses the list of URLs to calculate the size of the cache on disk. Third, if the cache on disk is too large, the image service removes the oldest cached image. It repeats the third step until the size of the cache on disk meets the requirement defined by maximumCacheSizeOnDisk.

Let's start simple and ask the default file manager for the contents of the image cache directory by invoking the contentsOfDirectory(at:includingPropertiesForKeys:options:) method. Because this method is throwing, we wrap the method call in a do-catch statement.

We store the result, an array of URL objects, in a constant with name contents. The contentsOfDirectory(at:includingPropertiesForKeys:options:) method accepts the URL of the directory as its first argument. It also accepts an array of resource keys as its second argument. What is that about?

Each file or directory has metadata, such as its size and creation date. We can ask a URL object for that metadata. To make this operation performant, we ask the default file manager to preload that metadata while it lists the contents of the image cache directory. Don't worry about this if it sounds confusing. It will make more sense in a few minutes. The third argument of the contentsOfDirectory(at:includingPropertiesForKeys:options:) method is an optional list of options we can ignore.

private func updateCacheOnDisk() {
    do {
        // List Contents of Directory
        let contents = try FileManager.default.contentsOfDirectory(at: imageCacheDirectory,
                                                                   includingPropertiesForKeys: [
                                                                    .creationDateKey,
                                                                    .totalFileAllocatedSizeKey
                                                                   ])
    } catch {
        print("Unable to Update Cache on Disk \(error)")
    }
}

Because we need the same array of resource keys in a few moments, we declare a constant of type [URLResourceKey] to store the resource keys. The metadata we are interested in are the creation date of each file in the image cache directory and its size.

private func updateCacheOnDisk() {
    do {
        // Helpers
        let resourceKeys: [URLResourceKey] = [
            .creationDateKey,
            .totalFileAllocatedSizeKey
        ]

        // List Contents of Directory
        let contents = try FileManager.default.contentsOfDirectory(at: imageCacheDirectory,
                                                                   includingPropertiesForKeys: resourceKeys)
    } catch {
        print("Unable to Update Cache on Disk \(error)")
    }
}

To make working with files and URLs easier, we convert the URL objects to File objects. Let me show you how that works. At the top of the ImageService class, we declare a private, nested struct, File. It defines three properties, url of type URL, size of type Int, and createdAt of type Date.

import UIKit

final class ImageService {

    // MARK: - Types

    ...

    private struct File {

        // MARK: - Properties

        let url: URL

        // MARK: -

        let size: Int

        // MARK: -

        let createdAt: Date

    }

    // MARK: - Properties

    ...

}

In updateCacheOnDisk(), we invoke the compactMap(_:) method on contents to convert the array of URL objects to an array of File objects. To obtain the size and creation date of the file the URL points to, we invoke resourceValues(forKeys:) on the URL object, passing in the array of resource keys as a set. The result is stored in a constant with name resourceValues. We prefix the method call with the try keyword since resourceValues(forKeys:) is a throwing method. This means we also need to prefix the compactMap(_:) method call with the try keyword.

private func updateCacheOnDisk() {
    do {
        // Helpers
        let resourceKeys: [URLResourceKey] = [
            .creationDateKey,
            .totalFileAllocatedSizeKey
        ]

        // List Contents of Directory
        let contents = try FileManager.default.contentsOfDirectory(at: imageCacheDirectory,
                                                                   includingPropertiesForKeys: resourceKeys,
                                                                   options: [])

        // Map `URL` Objects to `File` Objects
        var files = try contents.compactMap { url -> File? in
            // Fetch Resource Values
            let resourceValues = try url.resourceValues(forKeys: Set(resourceKeys))
        }

    } catch {
        print("Unable to Update Cache on Disk \(error)")
    }
}

We use a guard statement to safely access the creation date and the size of the file the URL object points to and use the metadata to create and return a File object from the closure we pass to the compactMap(_:) method.

private func updateCacheOnDisk() {
    do {
        // Helpers
        let resourceKeys: [URLResourceKey] = [
            .creationDateKey,
            .totalFileAllocatedSizeKey
        ]

        // List Contents of Directory
        let contents = try FileManager.default.contentsOfDirectory(at: imageCacheDirectory,
                                                                   includingPropertiesForKeys: resourceKeys,
                                                                   options: [])

        // Map `URL` Objects to `File` Objects
        var files = try contents.compactMap { url -> File? in
            // Fetch Resource Values
            let resourceValues = try url.resourceValues(forKeys: Set(resourceKeys))

            guard
                let createdAt = resourceValues.creationDate,
                let size = resourceValues.totalFileAllocatedSize
            else {
                return nil
            }

            return File(url: url, size: size, createdAt: createdAt)
        }
    } catch {
        print("Unable to Update Cache on Disk \(error)")
    }
}

Last but not least, we invoke the sorted(by:) method on the result of the compactMap(_:) method to sort the array of File objects by their creation date, from oldest to newest.

private func updateCacheOnDisk() {
    do {
        // Helpers
        let resourceKeys: [URLResourceKey] = [
            .creationDateKey,
            .totalFileAllocatedSizeKey
        ]

        // List Contents of Directory
        let contents = try FileManager.default.contentsOfDirectory(at: imageCacheDirectory,
                                                                   includingPropertiesForKeys: resourceKeys,
                                                                   options: [])

        // Map `URL` Objects to `File` Objects
        var files = try contents.compactMap { url -> File? in
            // Fetch Resource Values
            let resourceValues = try url.resourceValues(forKeys: Set(resourceKeys))

            guard
                let createdAt = resourceValues.creationDate,
                let size = resourceValues.totalFileAllocatedSize
            else {
                return nil
            }

            return File(url: url, size: size, createdAt: createdAt)
        }
        // Sort by Created At
        .sorted { $0.createdAt < $1.createdAt }
    } catch {
        print("Unable to Update Cache on Disk \(error)")
    }
}

The remainder of the implementation of the updateCacheOnDisk() method is similar to that of the cacheImage(_:for:writeToDisk:) method. We invoke reduce(_:_:) on the array of File objects to calculate the size of the cache on disk. The result is stored in a local variable, cacheSize. The File struct makes this easy. We don't need to deal with the URL API to access the size and creation date of the file on disk.

private func updateCacheOnDisk() {
    do {
        // Helpers
        let resourceKeys: [URLResourceKey] = [
            .creationDateKey,
            .totalFileAllocatedSizeKey
        ]

        // List Contents of Directory
        let contents = try FileManager.default.contentsOfDirectory(at: imageCacheDirectory,
                                                                   includingPropertiesForKeys: resourceKeys,
                                                                   options: [])

        // Map `URL` Objects to `File` Objects
        var files = try contents.compactMap { url -> File? in
            // Fetch Resource Values
            let resourceValues = try url.resourceValues(forKeys: Set(resourceKeys))

            guard
                let createdAt = resourceValues.creationDate,
                let size = resourceValues.totalFileAllocatedSize
            else {
                return nil
            }

            return File(url: url, size: size, createdAt: createdAt)
        }
        // Sort by Created At
        .sorted { $0.createdAt < $1.createdAt }

        // Calculate Cache Size
        var cacheSize = files.reduce(0) { result, cachedImage -> Int in
            result + cachedImage.size
        }
    } catch {
        print("Unable to Update Cache on Disk \(error)")
    }
}

We create a while loop and repeat the loop as long as the value of cacheSize is greater than that of maximumCacheSizeOnDisk.

private func updateCacheOnDisk() {
    do {
        // Helpers
        let resourceKeys: [URLResourceKey] = [
            .creationDateKey,
            .totalFileAllocatedSizeKey
        ]

        // List Contents of Directory
        let contents = try FileManager.default.contentsOfDirectory(at: imageCacheDirectory,
                                                                   includingPropertiesForKeys: resourceKeys,
                                                                   options: [])

        // Map `URL` Objects to `File` Objects
        var files = try contents.compactMap { url -> File? in
            // Fetch Resource Values
            let resourceValues = try url.resourceValues(forKeys: Set(resourceKeys))

            guard
                let createdAt = resourceValues.creationDate,
                let size = resourceValues.totalFileAllocatedSize
            else {
                return nil
            }

            return File(url: url, size: size, createdAt: createdAt)
        }
        // Sort by Created At
        .sorted { $0.createdAt < $1.createdAt }

        // Calculate Cache Size
        var cacheSize = files.reduce(0) { result, cachedImage -> Int in
            result + cachedImage.size
        }

        while cacheSize > maximumCacheSizeOnDisk {

        }
    } catch {
        print("Unable to Update Cache on Disk \(error)")
    }
}

We make sure the array of File objects isn't empty. This is important because the next step is removing the oldest cached image from the array of File objects and remove it from disk by invoking the default file manager's removeItem(at:) method, passing in the URL of the file. The removeItem(at:) method is a throwing method so we prefix the method call with the try keyword. Last but not least, we subtract the size of the file on disk from the value stored in cacheSize.

private func updateCacheOnDisk() {
    do {
        // Helpers
        let resourceKeys: [URLResourceKey] = [
            .creationDateKey,
            .totalFileAllocatedSizeKey
        ]

        // List Contents of Directory
        let contents = try FileManager.default.contentsOfDirectory(at: imageCacheDirectory,
                                                                   includingPropertiesForKeys: resourceKeys,
                                                                   options: [])

        // Map `URL` Objects to `File` Objects
        var files = try contents.compactMap { url -> File? in
            // Fetch Resource Values
            let resourceValues = try url.resourceValues(forKeys: Set(resourceKeys))

            guard
                let createdAt = resourceValues.creationDate,
                let size = resourceValues.totalFileAllocatedSize
            else {
                return nil
            }

            return File(url: url, size: size, createdAt: createdAt)
        }
        // Sort by Created At
        .sorted { $0.createdAt < $1.createdAt }

        // Calculate Cache Size
        var cacheSize = files.reduce(0) { result, cachedImage -> Int in
            result + cachedImage.size
        }

        while cacheSize > maximumCacheSizeOnDisk {
            if files.isEmpty {
                break
            }

            // Remove Oldest Cached Image
            let oldestCachedImage = files.removeFirst()
            try FileManager.default.removeItem(at: oldestCachedImage.url)

            // Update Cache Size
            cacheSize -= oldestCachedImage.size
        }
    } catch {
        print("Unable to Update Cache on Disk \(error)")
    }
}

Testing the Implementation

To test the implementation, we add a print statement before the while loop, printing the number of files in the image cache directory and the size of the image cache directory in kilobytes.

private func updateCacheOnDisk() {
    do {
        ...

        print("\(files.count) Images Cached, Size on Disk \(cacheSize / .kilobyte) KB")

        while cacheSize > maximumCacheSizeOnDisk {
            ...
        }
    } catch {
        print("Unable to Update Cache on Disk \(error)")
    }
}

Comment out the print statements in the image(for:completion:) and cachedImage(for:completion:) methods. Build and run the application. Scroll to the bottom of the table view and inspect the output in the console.

0 Images Cached, Size on Disk 0 KB
1 Images Cached, Size on Disk 16 KB
2 Images Cached, Size on Disk 8 KB
4 Images Cached, Size on Disk 52 KB
4 Images Cached, Size on Disk 52 KB
4 Images Cached, Size on Disk 52 KB
5 Images Cached, Size on Disk 60 KB
5 Images Cached, Size on Disk 60 KB
5 Images Cached, Size on Disk 60 KB
Unable to Update Cache on Disk Error Domain=NSCocoaErrorDomain Code=4 "“aHR0cHM6Ly9jZG4uY29jb2FjYXN0cy5jb20vN2JhNWMzZTdkZjY2OTcwM2NkN2YwZjBkNGNlZmE1ZTU5NDcxMjZhOC8xLXNtYWxsLmpwZw==” couldn’t be removed." UserInfo={NSUserStringVariant=(
    Remove
), NSFilePath=/Users/Bart/Library/Developer/CoreSimulator/Devices/54FE7403-6F0A-4736-B0AE-3626C340F753/data/Containers/Data/Application/BF44CFC1-AA0F-4CCB-BB32-587AB4CFB147/Library/Caches/ImageCache/aHR0cHM6Ly9jZG4uY29jb2FjYXN0cy5jb20vN2JhNWMzZTdkZjY2OTcwM2NkN2YwZjBkNGNlZmE1ZTU5NDcxMjZhOC8xLXNtYWxsLmpwZw==, NSUnderlyingError=0x6000014051d0 {Error Domain=NSPOSIXErrorDomain Code=2 "No such file or directory"}}
4 Images Cached, Size on Disk 44 KB
5 Images Cached, Size on Disk 56 KB
5 Images Cached, Size on Disk 64 KB
5 Images Cached, Size on Disk 56 KB
5 Images Cached, Size on Disk 60 KB
4 Images Cached, Size on Disk 48 KB
5 Images Cached, Size on Disk 60 KB
5 Images Cached, Size on Disk 60 KB
5 Images Cached, Size on Disk 56 KB
5 Images Cached, Size on Disk 56 KB
4 Images Cached, Size on Disk 48 KB

The image service is behaving as expected. It removes the oldest files on disk if the size of the cache on disk exceeds 50 kilobytes. The output also shows a few errors. What is that about? It appears that the default file manager is sometimes not able to remove a file from the image cache directory. How is that possible?

The updateCacheOnDisk() method is invoked several times as the user scrolls the table view. Writing images to disk and updating the cache on disk take place on a background thread. The problem is that those operations can take place at the same time because we use a concurrent, global dispatch queue to schedule them. The solution is simple, though.

Revisit ImageService.swift and declare a private, constant property, cacheOnDiskQueue. We create a serial dispatch queue and store a reference to the dispatch queue in the cacheOnDiskQueue property. Notice that we set the quality of service class of the dispatch queue to utility. You can learn more about serial and concurrent dispatch queues and quality of service classes in Mastering Grand Central Dispatch.

private let cacheOnDiskQueue = DispatchQueue(label: "Cache On Disk Queue", qos: .utility)

We put the serial dispatch queue to use in the cacheImage(_:for:writeToDisk:) method. In the body of the if statement we no longer use a global dispatch queue. We use the serial dispatch queue of the image service instead.

private func cacheImage(_ data: Data, for url: URL, writeToDisk: Bool = true) {
    ...

    if writeToDisk {
        cacheOnDiskQueue.async {
            // Write Image to Disk
            self.writeImageToDisk(data, for: url)
        }
    }
}

The serial dispatch queue executes one block of work at a time and it does this in FIFO order, first in, first out. This prevents the image service from modifying the contents of the image cache directory from multiple threads at the same time. Build and run the application one more time. You should no longer see error messages in the console.

4 Images Cached, Size on Disk 48 KB
5 Images Cached, Size on Disk 64 KB
4 Images Cached, Size on Disk 44 KB
5 Images Cached, Size on Disk 56 KB
5 Images Cached, Size on Disk 60 KB
4 Images Cached, Size on Disk 44 KB
5 Images Cached, Size on Disk 60 KB
4 Images Cached, Size on Disk 48 KB
5 Images Cached, Size on Disk 60 KB
5 Images Cached, Size on Disk 60 KB
4 Images Cached, Size on Disk 44 KB
5 Images Cached, Size on Disk 60 KB

What's Next?

The image service started out as a simple service for fetching images. It has evolved into a fairly powerful service that fetches and caches images. While there are plenty of bells and whistles we can add to the ImageService class, the current implementation should give you an idea of how to implement a lightweight, scalable caching solution for images.