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:

  1. Inside NetworkManager. An NSCache is created, it uses an NSString as the key and a CacheData class as the value.
  2. Inside NetworkManager, is a fetchData function. The function first checks the NSCache to see if it holds a value from the same URL. 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.
  3. 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.
  4. The successful response is placed into a CachedData, with a timestamp of when the data is saved. The data is then stored within the NSCache. Finally, the data is returned outside of the method.
  5. Outside of NetworkManager, another function, fetchDataExample(), calls fetchData 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 a struct.
  • 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:

  1. You declare two App Storage properties called username and isDarkMode in a SwiftUI component called ContentView. The properties have default values set, so if no values are stored, SwiftUI can rely on this default.
  2. The Text component uses the username property wrapper to create a welcome message. If no value is available, it uses the default “Guest” values.
  3. The TextField component provides a way for the user to add a new username. It updates the username property wrapper in real-time, updating the UI and also storing the value in User Defaults.
  4. 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 in isDarkMode.
  5. The Button component resets the username property wrapper back to Guest when the user taps it.
  6. The .preferredColorScheme modifier uses the isDarkMode 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:

  1. 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 the PersistentModel protocol. It also conforms the class to the Observable to allow tracking of changes to the class so it can update SwiftUI.

  2. 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.

  3. 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 stored Todos.

  4. When you submit the TextField, a TODO is created and added to Swift Data using context.insert(todo). This will cause the @Query property added earlier to provide the new TODO to SwiftUI.

  5. The .onDelete modifier, when triggered, calls deleteTodos, which iterates through each indexSet being deleted and uses context.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.

  6. 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, the Todo 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.

See forum comments
Download course materials from Github
Previous: Introduction: Data Caching & Optimization Next: Demo