Caching Network Request in SwiftUI — with Async/Await & Core Data

Fernando Putra
7 min readJan 7, 2024

--

In the world of App development, crafting a responsive and performant app often involves dealing with asynchronous network requests. One pivotal aspect of this process involves implementing data caching, ensuring frequently accessed data is readily available without the overhead of repeated network calls. In this article, we will delve into a step-by-step approach to caching a network request in your SwiftUI app, leveraging the old-fashioned local storage of Core Data alongside the modern asynchronous programming paradigm of Async/Await.

The Objective

The objective is to develop a SwiftUI project that fetches products from the FakeStoreAPI, displays them in a grid, and uses Core Data for data caching. The wireframe is illustrated below:

We’ll go through six sections in order, providing brief explanations for each as well as the code snippets and their explanations.

Set Up a Caching Stack

Before establishing the caching logic in your SwiftUI application, it’s crucial to choose a suitable stack tailored to the unique demands of your application. The following options cater to a range of needs:

  • UserDefaults: A straightforward solution for simple key-value storage.
  • Core Data/Swift Data: Robust and suitable for managing complex data models and relationships.
  • File System: Ideal for file-based storage and is particularly useful for handling larger or structured data.
  • Third-party Libraries: Explore solutions like AlamofireImage or SDWebImage for pre-built caching features and tailored optimizations.

Let’s set up the Core Data as our chosen caching stack:

1. Create a Data Model

Open Xcode, create a new SwiftUI project, and generate a new file of type Data Model from the Core Data section. Name the file with your project name, concluding with “DataModel” to it.

2. Add Core Data Entities

Inside Data Model file, add a new entities:

  1. ProductEntity with attributes: id (Integer 16), Image (String), price (Double), and title (String).
  2. RatingEntity with attributes: rate (Double) and count (Integer 16).

Then, establish a one-to-one relationship named rating from ProductEntity to RatingEntity and vice versa, specifying the inverse relationship.

3. Create a Core Data Stack

A Core Data stack is a class that manages and persists your app’s objects. It includes the necessary components for managing the Core Data model, the persistent store coordinator, and the managed object context. Here’s a basic implementation of a Core Data stack in Swift:

With this code, you:

  1. Implement a singleton pattern to ensure a single instance for managing the Core Data stack across the entire app.
  2. Initialize a managed object context associated with the main queue, where most Core Data operations are performed.
  3. Initialize the Core Data container lazily, which acts as the central hub for the Core Data stack. Make sure to replace it with the actual name of your Core Data model
  4. Provide a saveContext method to persist changes made in the viewContext to the Core Data stack. This method guards against unnecessary saves when no changes are present.
  5. Implement a fetch method for retrieving managed objects of a specified type based on a given fetch request. The method returns an empty array if fetching fails, ensuring graceful handling of potential errors.

Define a Struct Model

It’s essential to define separate struct models for Product and Rating even if you’ve already established them in Core Data. This step brings several benefits, such as:

  1. Network Interactions: Struct models excel in encoding and decoding data, facilitating efficient communication with external services.
  2. Flexibility and Clarity: Struct models can be designed to match the requirements of your application precisely, enhancing readability and maintainability.
  3. Decoupling for Adaptability: Struct models separate data representations for network requests and the persistence layer, fostering a modular and resilient architecture.

Create a new Swift file named Product.swift and Rating.swift:

Implement a Network Service

A dedicated network service is essential in SwiftUI for making network requests and accessing remote APIs. By creating a dedicated layer, we can encapsulate the logic for making network requests, handling responses, and any necessary data mapping. This approach maintains a clean and modular codebase, facilitating easier testing and reusing networking logic across the app.

We’ll leverage Async/Await, introduced in Swift 5.5, as a new concurrency technique. Async/Await transforms how we write asynchronous code, making it appear synchronous and easier to understand and maintain. Compared to other Swift concurrency techniques like completion handlers and dispatch queues, Async/Await offers various benefits:

  1. Conciseness and Readability: Async/Await provides a concise and readable way to handle asynchronous operations, avoiding the complexity of nested closures and the “callback hell” problem.
  2. Synchronous-Looking Code: Async/Await allows the creation of asynchronous code that resembles synchronous patterns, simplifying the development of concurrent tasks for easier comprehension.
  3. Single Result Guarantee: Async/Await ensure a single result, eliminating the uncertainty of completion closures being called multiple times or not at all.
  4. Effective and Responsive Software: Async/Await enhances software effectiveness and responsiveness, enabling the development of applications that execute time-consuming processes in the background while remaining responsive to user interactions.
  5. Developer Experience: Async/Await improves the developer experience by making asynchronous code more accessible, especially for those accustomed to writing synchronous code.

Create a new Swift file named FakeStoreAPIService.swift and add the following code:

With this code, you’ve created a FakeStoreAPIService conforming to the FakeStoreAPIServiceProtocol, utilizing async/await to fetch products from the FakeStoreAPI.

Implement a Repository with Caching Logic

The repository class is responsible for abstracting data access and performing data operations, including managing caching for network responses.

We’ll initially prioritize retrieving cached data, aiming to minimize the need for network requests. If no data is retrieved from Core Data, the repository will initiate network requests through the network service. Upon a successful network response, the retrieved data will be stored in Core Data, optimizing subsequent retrieval processes.

With this code, you:

  1. Attempts to load cached products using loadCachedProducts(). If cached products are found, they are mapped to the model and returned. This step prioritizes retrieving data from the cache, reducing the reliance on network requests.
  2. In the absence of cached products, the repository initiates a network request using service.fetchProducts(). Upon a successful network response, the obtained products are stored in Core Data using saveToCoreData(_:). This ensures the cache is updated with fresh data, optimizing subsequent retrieval processes.

Implement a View Model

The ViewModel class is responsible for orchestrating the dance between the repository and the UI. We’ll implement a fetchData() method. This method efficiently retrieves data from the repository and integrates it with the UI using the @Published property wrapper.

Create a new Swift file named ProductGridViewModel.swift:

With this code, you:

  1. Create a fetchData() method marked with @MainActor for automatic dispatching of UI updates on the main queue, eliminating the need for manual dispatching.

Note: @MainActor is effective in asynchronous code using Swift Concurrency, but not in code that uses other concurrency patterns like completion handlers.

Integrate with SwiftUI

In the final step, we’ll create a SwiftUI view named ProductGridView and initialize the associated ProductGridViewModel using @StateObject.

We’ll then trigger the asynchronous fetchData() method during the view's task, ensuring data retrieval upon view creation. This enables the view to dynamically respond to the ViewModel's state, presenting loading indicators, product cards upon successful fetching, or an error message in case of an error.

Here is the app demo of what we’ve developed:

App Demo

Next Phase

While the current project enables you to cache network requests and efficiently update SwiftUI views, It’s important to address various edge cases you might need to handle, such as:

  1. Cache Expiration: Ensure up-to-date data by implementing cache expiration. Associate a timestamp with cached data and re-fetch it if it’s older than a predefined time threshold, known as time-based expiration. Alternatively, consider version-based expiration, invalidating the cache when a new version is available if the API provides version information.
  2. Manually Refreshing Data: Users can manually trigger a refresh, especially if your app has a pull-to-refresh mechanism or a dedicated refresh button. You can also implement automatic background refreshes, but be mindful of battery life and data usage.
  3. Clearing the Cache: Allow users to clear the cache explicitly, giving them control over the stored data. Implement logic to automatically clear the cache in certain scenarios, such as a significant app update or changes in user settings.
  4. Optimizing Storage: Implement a cache size limit to prevent it from growing indefinitely. When the cache reaches a certain size, remove older or less frequently accessed items. Consider compressing or optimizing the stored data to minimize storage usage.

It’s important to note that the specific implementation details may vary based on the app’s requirements and the chosen caching mechanism. By considering and addressing these edge cases, the project can maintain data freshness and provide a seamless user experience.

If you found this article helpful, kindly share it with your friends and follow my profile for future updates. Your support is much appreciated!

You can access the project repository below:

PS: I’m always welcome for pizza 🍕🍕🍕.

--

--