
Caching is a critical strategy for enhancing the performance and responsiveness of networked iOS applications. It reduces the need for repeated network requests, saving bandwidth and decreasing load times. Effective caching strategies are essential for maintaining up-to-date information and ensuring a seamless user experience. Let’s delve into the various tools and techniques to enable proper caching for your Swift applications.
URLCache
URLCache, baked into the Foundation framework, is a built-in mechanism in iOS for automatically caching HTTP requests and responses. At its core, URLCache uses a combination of in-memory and on-disk storage for caching. If your application doesn’t have specific caching needs or limitations, using the default shared cache instance should be enough.
let sharedCache = URLCache.shared
If you need more controls over URLCache, such as setting the location or allocating how much disk space for your cache, you can configure a custom URLCache object and designate it as the shared cache instance.
let memoryCapacity = 50 * 1024 * 1024 // 50 MB
let diskCapacity = 150 * 1024 * 1024 // 150 MB
let cacheDirectory = "YourAppURLCache"
let urlCache = URLCache(memoryCapacity: memoryCapacity,
diskCapacity: diskCapacity,
diskPath: cacheDirectory)
URLCache.shared = urlCache
This code sets up a shared URLCache with specified memory and disk capacities. The cache will be used by all URLSession instances within the app that rely on the default configuration.
Alternatively, you can configure URLCache for individual URLSession instances, which is useful if different parts of your app have different caching needs.
let configuration = URLSessionConfiguration.default
configuration.urlCache = URLCache(memoryCapacity: 20 * 1024 * 1024, // 20 MB
diskCapacity: 100 * 1024 * 1024, // 100 MB
diskPath: nil) // Uses default cache location
let session = URLSession(configuration: configuration)
URLCache operates based on HTTP cache control headers. When making network requests, URLCache automatically checks if a cached response is available and valid based on the request’s cache policy and the server’s cache-related headers (Cache-Control, Last-Modified, Etag, etc.). If a suitable cached response is available, it will be returned immediately, avoiding a network call. You can also choose appropriate cache policies for your URLRequest. For example, the following setting will use cached data if available; otherwise, load from network.
let url = URL(string: "https://grokkingswift.io/cache")!
var request = URLRequest(url: url)
request.cachePolicy = .returnCacheDataElseLoad
let task = session.dataTask(with: request) { data, response, error in
// Handle response here
}
task.resume()
You can check out the available cache policies at Apple’s documentation.
What if you want to implement custom caching rules that are not directly supported by the defaults URLCache behavior? For example, you might want to modify the caching strategy based on the content type, URL pattern, or other response headers not typically considered by URLCache. In that case, you can subclass a URLCache. This process involves overriding several methods to support the operations such as storing, retrieving, and removing the cached responses. Here is an example:
class CustomURLCache: URLCache {
override func cachedResponse(for request: URLRequest) -> CachedURLResponse? {
// Custom logic to modify or log the cached response
return super.cachedResponse(for request: request)
}
override func storeCachedResponse(_ cachedResponse: CachedURLResponse, for request: URLRequest) {
// Custom logic before storing the response
super.storeCachedResponse(cachedResponse, for: request)
}
}
Subclassing a URLCache also allows you to modify or preprocess cached responses before they’re returned to the requester. For example, you can implement additional logging or monitoring around cache hits, misses, and storage behaviors, which can be valuable for debugging or analyzing the performance of your caching strategy.
Disk Caching
Sometimes you will have to deal with caching large files, such as images, videos, or sizable data blobs that don’t fit well into in-memory caches. In those scenarios, you might want to leverage the file system storage by directly saving data to the devices’ disk. The implementation is straightforward, you need the file data, and the location where you store it. Here’s how you can save an image to the document directory under a specified name:
func saveImageToDisk(_ image: UIImage, withFileName fileName: String) {
guard let data = image.pngData() else { return }
let fileURL = getDocumentsDirectory().appendingPathComponent(fileName)
do {
try data.write(to: fileURL)
print("Saved image to: \(fileURL.path)")
} catch {
print("Error saving image: \(error)")
}
}
func getDocumentsDirectory() -> URL {
let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
return paths[0]
}
And retrieve the saved image:
func loadImageFromDisk(withFileName fileName: String) -> UIImage? {
let fileURL = getDocumentsDirectory().appendingPathComponent(fileName)
do {
let imageData = try Data(contentsOf: fileURL)
return UIImage(data: imageData)
} catch {
print("Error loading image: \(error)")
return nil
}
}
While this caching mechanism seems easy to implement, it requires some manual works. First, because you have to specify the file name before storing the file, you must adopt a consistent naming convention for cached files, potentially based on a hash of their content or URL, to avoid collisions and facilitate lookup. Second, you’ll need to monitor the size of the cache, track access times, or simply remove old and infrequently accessed files when the cache exceeds a certain size.
NSCache
NSCache is designed for fast access to objects you use regularly within your app. It can store any type of object conforming to NSObject such as images, parsed data model, etc. A key feature of NSCache is that it automatically evicts items under memory pressure. This helps prevent your app from consuming excessive resources. Unlike other caching mechanisms that we have just discussed, NSCache primarily stores data in memory, meaning the data in NSCache is not saved to disk. If your app terminates, the cache is cleared.
Let’s look at an example where we will implement a simple image cache.
class ImageCache {
private var cache: NSCache<NSString, UIImage> = NSCache()
static let shared = ImageCache()
private init() {
cache.countLimit = 100 // Maximum number of objects
cache.totalCostLimit = 1024 * 1024 * 50 // 50 MB
}
func setImage(_ image: UIImage, forKey key: String) {
cache.setObject(image, forKey: key as NSString)
}
func image(forKey key: String) -> UIImage? {
return cache.object(forKey: key as NSString)
}
func removeImage(forKey key: String) {
cache.removeObject(forKey: key as NSString)
}
}
NSCache allow you to set countLimit and totalCostLimit to controls its maximum size. These properties provide hints to the cache about when to start evicting items. In fact, when caching objects, you can assign a cost to each item, which helps NSCache make more informed decisions about which items to evict under memory pressure.
func setImage(_ image: UIImage, forKey key: String, cost: Int) {
cache.setObject(image, forKey: key as NSString, cost: cost)
}
This mechanism of automatically evicting items actually comes with a concern, though. While it helps to prevent your app from being terminated, it also means cached data may not always be available. If you need persistence, combine NSCache with a mechanism that saves cached data to disk (e.g., using FileManager).
The beauty of NSCache is that it is inherently thread-safe, meaning you can add, remove, and query items from multiple threads without additional lock mechanisms.
What about Error Handling?
Cache data can become corrupted due to various reasons, such as incomplete writes, logical errors in cache management code, network failures. In such cases, you need to ensure that users still have access to the content. Here are some approaches that I usually do in any networked iOS apps that I worked on:
- When implementing caching for network requests, your app should fallback to cached data if network requests fail. If users first launch the app, the network connectivity is unavailable, and there is no cached data, you can inform users that the app might not be fully functional because essential data couldn’t be fetched. Make sure you offer a manual retry option to let users attempt to fetch the data again once they ensure they have connectivity.
- Verifying the integrity of cached data is also crucial. The cache are useless if its data are corrupted. However, detecting corrupted cache data can be challenging, depending on the data type and storage mechanism. One of the common solutions is to store a checksum or hash value alongside with your cached data. Upon retrieval, recalculate the checksum or hash of the data and compare it with the stored value. Mismatches indicate corruption. Once corruption is detected, you can silently remove or replace the corrupted data, then perform a fresh data fetch.
Conclusion
Selecting the right caching mechanism depends on the specific needs of your iOS app, including the type and size of data you need to cache, the required persistence, and the complexity you’re willing to manage. It is common in an iOS app that we combine multiple solutions to achieve caching purposes. Remember, your caching solution should be justified by the specific needs it addresses, balancing flexibility with maintainability.
Thanks for reading! 🚀