Understanding Caching
Data Caching and Optimization
As part of creating an app, you’ll come to the point where you need to retrieve data and use it to drive the app’s functionality. Data can come from many sources, whether it’s a file stored on a device, a request from a server, or a user input.
Sometimes, data can be requested many times in a single use. While this isn’t a problem at first, if your app grows and is making different requests repeatedly, it’s beginning to unnecessarily use the device’s CPU, battery, and possibly even cellular data. Do this too much, and your users will notice, making for unhappy users who may delete your app and leave a poor review.
The solution is to leverage Data Caching and Optimization techniques. These allow you to reduce the number of calls to retrieve data from external sources to your app and thereby improve app efficiency.
Take a look at these few techniques.
NSCache
The first technique you can try is to use NSCache
. NSCache is… as you might expect, a cache! A cache is a software component that stores data so it can be reused later and, therefore, provided quickly. You can expect to see caches employed in places where the app makes frequent network requests or performs expensive requests to a database.
The benefits of using a cache are that you can first check the cache to see if the response for a similar request is stored. If it is, then you can retrieve the response from the cache and return it back to the app. If the cache doesn’t contain the value then the app knows to perform the request because it needs the data. Once the response is retrieved, it can then be stored in the cache for use later.
It’s important to note that when the app is removed from memory, either by iOS or the user forcing the app to quit, the cache is cleared, causing the requests to have to be made again before they are cached.
Take a look at an example of NSCache
being used:
actor NetworkManager {
static let shared = NetworkManager()
private init() {}
// 1
private let cache = NSCache<NSString, CachedData>()
func fetchData(from url: URL) async throws -> Data {
// 2
if let cachedData = cache.object(forKey: url.absoluteString as NSString) {
if cachedData.isValid {
return cachedData.data
} else {
cache.removeObject(forKey: url.absoluteString as NSString)
}
}
// 3
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
throw NetworkError.invalidResponse
}
// 4
let cachedData = CachedData(data: data, timestamp: Date())
cache.setObject(cachedData, forKey: url.absoluteString as NSString)
return data
}
}
class CachedData {
let data: Data
let timestamp: Date
init(data: Data, timestamp: Date) {
self.data = data
self.timestamp = timestamp
}
var isValid: Bool {
return Date().timeIntervalSince(timestamp) < 300
}
}
enum NetworkError: Error {
case invalidResponse
}
@MainActor
func fetchDataExample() async {
let url = URL(string: "https://rickandmortyapi.com/api")!
do {
// 5
let data = try await NetworkManager.shared.fetchData(from: url)
print("Received data: \(String(decoding: data, as: UTF8.self))")
} catch {
print("Error: \(error.localizedDescription)")
}
}
Task {
await fetchDataExample()
}
Now go through the important parts of the code step by step:
- Inside
NetworkManager
. AnNSCache
is created, it uses anNSString
as the key and aCacheData
class as the value. - Inside
NetworkManager
, is a fetchData function. The function first checks theNSCache
to see if it holds a value from the sameURL
. If it does, it checks to see if the data is recent and returns the data without making a network call. If the data is stale it removes it from the cache. - If the function reaches this point, it makes a network request to retrieve the data and checks to see if the response status code is within the boundaries of an acceptable request. If it isn’t, an error is thrown.
- The successful response is placed into a
CachedData
, with a timestamp of when the data is saved. The data is then stored within theNSCache
. Finally, the data is returned outside of the method. - Outside of
NetworkManager
, another function,fetchDataExample()
, callsfetchData
and passes in a URL. It then converts the data to a String so it’s readable.
In a few lines of code, you created an efficient caching solution to reduce the number of network calls your app makes.
There are some things to note with NSCache
:
- The value stored must be a
class
, not astruct
. - The key must be an
NSString
.
With NSCache covered, let’s move on to another form of caching, App Storage.
App Storage
Sometimes, you’ll have use cases where data needs to be stored and persisted so it’s available when the app is launched. For simple data values, you can rely on something called App Storage.
App Storage is a SwiftUI property wrapper around an API called User Defaults. It allows you to read and write simple keys and values to User Defaults so they can be persisted within the app, while also providing a property that updates the UI when the value changes.
When values are inserted into App Storage, they’re not removed when the app is removed from memory, meaning the value is available with every launch. The only time values are removed is if the app is deleted, or if the app is part of an app group, when all apps in the app group are removed.
Here’s an example using App Storage:
struct ContentView: View {
// 1
@AppStorage("username") private var username: String = "Guest"
@AppStorage("isDarkMode") private var isDarkMode: Bool = false
var body: some View {
VStack {
// 2
Text("Welcome, \(username)!")
// 3
TextField("Enter username", text: $username)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding(.horizontal)
// 4
Toggle("Dark Mode", isOn: $isDarkMode)
.padding()
// 5
Button("Reset Username") {
username = "Guest"
}
}
// 6
.preferredColorScheme(isDarkMode ? .dark : .light)
}
}
Stepping through the code, you can see that:
- You declare two App Storage properties called
username
andisDarkMode
in a SwiftUI component calledContentView
. The properties have default values set, so if no values are stored, SwiftUI can rely on this default. - The
Text
component uses theusername
property wrapper to create a welcome message. If no value is available, it uses the default “Guest” values. - The
TextField
component provides a way for the user to add a new username. It updates theusername
property wrapper in real-time, updating the UI and also storing the value in User Defaults. - The
Toggle
component allows the user to toggle whether the SwiftUI View uses dark or light mode. When you change it, the value is stored inisDarkMode
. - The
Button
component resets theusername
property wrapper back toGuest
when the user taps it. - The
.preferredColorScheme
modifier uses theisDarkMode
property wrapper to work out whether the view uses dark or light mode.
App Storage is great for small values that need to be persisted across app launches and need to be used to update your SwiftUI views. If you need to store larger values, or even whole collections of values then you’re going to need something more suited.
For that, take a look at Swift Data in the next section.
Swift Data
Swift Data combines Core Data, a well-used Apple database technology, with Swift’s Concurrency features to create a modern framework that adds persistence quickly and easily in a few lines of code.
Swift Data is a great choice if you have a number of use cases for persistence. Similar to NSCache, it can be used as a caching layer to save network requests. It also provides support for offline experiences and the ability to save user data if a user is in the middle of a task. Think about cases where autosave would be useful, and that’s what Swift Data can help with.
As well as being easy to implement, it also provides features like syncing data across a user’s device via iCloud. Meaning your users can save their data on one device, and easily continue what they were doing on another device they’re logged into.
Take a look at how to use Swift Data:
import SwiftUI
import SwiftData
// 1
@Model
class Todo {
var title: String
var isCompleted: Bool = false
init(title: String) {
self.title = title
}
}
struct ContentView: View {
// 2
@Environment(\.modelContext) private var context
// 3
@Query private var todos: [Todo]
@State private var newTodoTitle = ""
var body: some View {
NavigationStack {
List {
// 4
TextField("New todo", text: $newTodoTitle)
.onSubmit(addTodo)
ForEach(todos) { todo in
HStack {
Text(todo.title)
Spacer()
Image(systemName: todo.isCompleted ? "checkmark.circle.fill" : "circle")
.onTapGesture { todo.isCompleted.toggle() }
}
}
// 5
.onDelete(perform: deleteTodos)
}
.navigationTitle("Todos")
.toolbar { EditButton() }
}
}
private func addTodo() {
guard !newTodoTitle.isEmpty else { return }
context.insert(Todo(title: newTodoTitle))
newTodoTitle = ""
}
private func deleteTodos(at offsets: IndexSet) {
for index in offsets {
context.delete(todos[index])
}
}
}
@main
struct TodoApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
// 6
.modelContainer(for: Todo.self)
}
}
Take a closer look at the Swift Data parts of the code:
-
You create a
Todo
class with properties for the TODO and track if it’s completed. The class is annotated with@Model
, a Swift macro to generate an internal schema that is used to persist the class by conforming it to thePersistentModel
protocol. It also conforms the class to theObservable
to allow tracking of changes to the class so it can update SwiftUI. -
You add an Environment property from the WindowGroup to the
ContentView
component, receiving a.modelContext
. The context is used by SwiftUI to read and write changes to Swift Data. You’ll see how to add and use the property soon. -
You add a property wrapper called
@Query
. This provides a way for SwiftUI to query objects from Swift Data based on the type of objects expected and can be customized to sort by order, filter, and other requirements. In this case, the app asks for all storedTodo
s. -
When you submit the
TextField
, a TODO is created and added to Swift Data usingcontext.insert(todo)
. This will cause the@Query
property added earlier to provide the new TODO to SwiftUI. -
The
.onDelete
modifier, when triggered, callsdeleteTodos
, which iterates through eachindexSet
being deleted and usescontext.delete
to delete the TODO at the correct index from Swift Data. This will trigger SwiftUI to update the view, since@Query
receives the changes. -
The
WindowGroup
uses the.modelContainer
modifier to create storage for SwiftData to use. You can configure the storage for your own needs, such as an in-memory database. In this case, the defaults work well, so no changes are passed in. You do need to pass in the objects you want SwiftData to persist, in this case, theTodo
class.
Swift Data is a versatile technology, giving you access to a wide range of use cases and techniques to apply in your own apps. It’s more complex than NSCache and App Storage, so some thinking ahead of time will be required before deciding to use it.
Signs that Swift Data is a good candidate to use in your app are:
- You have multiple requirements to temporarily cache and persist data.
- Your app requires data to be stored for offline use.
- Your app requires data to be synced across users’ devices using iCloud.
Now that you know more about data caching and when to pick the right tool for your needs, the next section will take you through implementing some of these tools in an app.