Xcoding with Alfian

Mobile development articles and tutorials

Fetching Remote Data With Core Data Background Context in iOS App

Alt text

Core Data is an object graph and persistence framework provided by Apple for developing iOS Apps. It handles object life cycle, object graph management, and persistence. It supports many features for handling model layer inside an app such as:

  1. Relationship management among objects.
  2. Change tracking with Undo Manager
  3. Lazy loading for objects and properties
  4. Validation
  5. Grouping, filtering, querying using NSPredicate
  6. Schema migration
  7. Uses SQLite as one of its option for backing store.

With so many advanced features provided automatically out of the box by Core Data, it has steep learning curve for developer to learn and use for the first time. Before iOS 10, to setup Core Data in our application there are many configuration and boilerplate code we need to perform to build a Core Data Stack. Luckily in iOS 10, Apple introduced NSPersistentContainer that we can use to initialize all the stack and get the NSManagedObject context with very little code.

In this article, we will build a simple demo app that fetch list of films from the remote Star Wars API and sync the data inside the Core Data store using background queue naively without synchronization strategy. What we will build:

  1. Managed Object Model Schema and Film Entity.
  2. Managed Object for Film Entity.
  3. CoreDataStack: Responsible for building the NSPersistentContainer using the schema.
  4. ApiRepository: A class responsible for fetching list of films data from StarWars API using URL Session Data Task.
  5. DataProvider: A class that provide interface to fetch list of film from data repository and sync it to the Core Data store using NSManagedObjectContext in background thread.
  6. FilmsViewController: View Controller that communicates with data provider and uses NSFetchedResultsController to fetch and observe change from Core Data View Context, then display list of films in a UITableView.

You can checkout the complete source code for this app in the project GitHub repository at alfianlosari/CoreData-Fetch-API-Background.

You can also checkout and try the StarWars API by clicking the link to the website at SWAPI-The Star Wars API.

Managed Object Model Schema and Film Entity

The first step we will perform is to create the Managed Object Model Schema that contains a Film Entity. Create New File from Xcode and select Data Model from Core Data Template. Name the file as StarWars, it will be saved with the .xcdatamodeld as the filename extension.

Alt text

Click on the Data Model file we just created, Xcode will open Data Model Editor where we can add Entity to the Managed Object Model Schema. Click Add Entity and Set the name of the new Entity as Film. Make sure to set the codegen is set to Manual/None so Xcode does not automatically generate the Model class. Then add all the attributes with the type like the image below:

Alt text

Create Managed Object for Film Entity

After we have created the schema with Film Entity, we need to create new file for Film class with NSManagedObject as the superclass. This class will be used when we insert Film Entity into NSManagedObjectContext. Inside we declare all the properties related to the entity with associated type, the property also need to be declared with @NSManaged keyword for the compiler to understand that this property will use Core Data at its backing store. We need to use NSNumber for primitive type like Int, Double, or Float to store the value in a ManagedObject. We also create a simple function that maps a JSON Dictionary property and assign it to the properties of Film Managed Object.

import CoreData

class Film: NSManagedObject {
    
    @NSManaged var director: String
    @NSManaged var episodeId: NSNumber
    @NSManaged var openingCrawl: String
    @NSManaged var producer: String
    @NSManaged var releaseDate: Date
    @NSManaged var title: String
    
    static let dateFormatter: DateFormatter = {
        let df = DateFormatter()
        df.dateFormat = "YYYY-MM-dd"
        return df
    }()
    
    func update(with jsonDictionary: [String: Any]) throws {
        guard let director = jsonDictionary["director"] as? String,
            let episodeId = jsonDictionary["episode_id"] as? Int,
            let openingCrawl = jsonDictionary["opening_crawl"] as? String,
            let producer = jsonDictionary["producer"] as? String,
            let releaseDate = jsonDictionary["release_date"] as? String,
            let title = jsonDictionary["title"] as? String
            else {
                throw NSError(domain: "", code: 100, userInfo: nil)
        }
        
        self.director = director
        self.episodeId = NSNumber(value: episodeId)
        self.openingCrawl = openingCrawl
        self.producer = producer
        self.releaseDate = Film.dateFormatter.date(from: releaseDate) ?? Date(timeIntervalSince1970: 0)
        self.title = title
    }

}

Setup Core Data Stack

To setup our Core Data Stack that uses the Managed Object Model Schema we have created, create a new file called CoreDataStack. It will be a Singleton class that exposes NSPersistentContainer public variable. To initialize the container, we just pass the filename of the Managed Object Model schema which is StarWars. We also set the view NSManagedObjectContext of the container to automatically merge changes from parent, so when we use the background context to save the data, the changes will also be propagated to the View Context.

import CoreData

class CoreDataStack {
    
    private init() {}
    static let shared = CoreDataStack()
    
    lazy var persistentContainer: NSPersistentContainer = {
       let container = NSPersistentContainer(name: "StarWars")
        
        container.loadPersistentStores(completionHandler: { (_, error) in
            guard let error = error as NSError? else { return }
            fatalError("Unresolved error: \(error), \(error.userInfo)")
        })
        
        container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
        container.viewContext.undoManager = nil
        container.viewContext.shouldDeleteInaccessibleFaults = true
        
        container.viewContext.automaticallyMergesChangesFromParent = true
        
        return container
    }()
    
}

ApiRepository as Network Service

Next, create a new file with the name of ApiRepository. This Singleton class acts as a Networking Coordinator that connects to the SWAPI to fetch list of films from the network. It provides public method to get films with completion closure as the parameter. The closure will be invoked with either of Array JSON Dictionary or an error in case of an error occurs when fetching or parsing the JSON data from the response.

import Foundation

class ApiRepository {
    
    private init() {}
    static let shared = ApiRepository()
    
    private let urlSession = URLSession.shared
    private let baseURL = URL(string: "https://swapi.co/api/")!
    
    func getFilms(completion: @escaping(_ filmsDict: [[String: Any]]?, _ error: Error?) -> ()) {
        let filmURL = baseURL.appendingPathComponent("films")
        urlSession.dataTask(with: filmURL) { (data, response, error) in
            if let error = error {
                completion(nil, error)
                return
            }
            
            guard let data = data else {
                let error = NSError(domain: dataErrorDomain, code: DataErrorCode.networkUnavailable.rawValue, userInfo: nil)
                completion(nil, error)
                return
            }
            
            do {
                let jsonObject = try JSONSerialization.jsonObject(with: data, options: [])
                guard let jsonDictionary = jsonObject as? [String: Any], let result = jsonDictionary["results"] as? [[String: Any]] else {
                    throw NSError(domain: dataErrorDomain, code: DataErrorCode.wrongDataFormat.rawValue, userInfo: nil)
                }
                completion(result, nil)
            } catch {
                completion(nil, error)
            }
        }.resume()
    }
    
}

DataProvider as Sync Coordinator

The next file wee need to create is the DataProvider class. This class responsibility is to act as Sync Coordinator to fetch data using the ApiRepository and store the data to the Core Data Store. It accepts the repository and NSPersistent container as the initializer parameters and store it inside the instance variable. It also exposes a public variable for the View NSManagedObjectContext that uses the NSPersistetContainer View Context.

The fetchFilms function can be used by the consumer of the class to trigger the synchronization to the API repository to get the films. After the data has been received, we initialize a Background NSManagedObjectContext using the NSPersistentContainer newBackgroundContext method.

We use the NSManagedObjectContext synchronous performAndWait function to perform our data synchronization. The synchronization just perform a naive synchronization technique by:

  1. Find all the films that match all the episode id we retrieve from the network inside our current Core Data Store using NSPredicate. To be efficient, we are not retrieving the actual object only the NSManagedObjectID.
  2. Delete the films found in our store using NSBatchDeleteRequest.
  3. Insert all the films using the response from the repository.
  4. Update the property of the films using the JSON Dictionary.
  5. Save the result to the Core Data Store.
  6. Changes will be automatically merged to the View Context.
class DataProvider {
    
    private let persistentContainer: NSPersistentContainer
    private let repository: ApiRepository
    
    var viewContext: NSManagedObjectContext {
        return persistentContainer.viewContext
    }
    
    init(persistentContainer: NSPersistentContainer, repository: ApiRepository) {
        self.persistentContainer = persistentContainer
        self.repository = repository
    }
    
    func fetchFilms(completion: @escaping(Error?) -> Void) {
        repository.getFilms() { jsonDictionary, error in
            if let error = error {
                completion(error)
                return
            }
            
            guard let jsonDictionary = jsonDictionary else {
                let error = NSError(domain: dataErrorDomain, code: DataErrorCode.wrongDataFormat.rawValue, userInfo: nil)
                completion(error)
                return
            }
            
            let taskContext = self.persistentContainer.newBackgroundContext()
            taskContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
            taskContext.undoManager = nil
            
            _ = self.syncFilms(jsonDictionary: jsonDictionary, taskContext: taskContext)
            
            completion(nil)
        }
    }
    
    private func syncFilms(jsonDictionary: [[String: Any]], taskContext: NSManagedObjectContext) -> Bool {
        var successfull = false
        taskContext.performAndWait {
            let matchingEpisodeRequest = NSFetchRequest<NSFetchRequestResult>(entityName: "Film")
            let episodeIds = jsonDictionary.map { $0["episode_id"] as? Int }.compactMap { $0 }
            matchingEpisodeRequest.predicate = NSPredicate(format: "episodeId in %@", argumentArray: [episodeIds])
            
            let batchDeleteRequest = NSBatchDeleteRequest(fetchRequest: matchingEpisodeRequest)
            batchDeleteRequest.resultType = .resultTypeObjectIDs
            
            // Execute the request to de batch delete and merge the changes to viewContext, which triggers the UI update
            do {
                let batchDeleteResult = try taskContext.execute(batchDeleteRequest) as? NSBatchDeleteResult
                
                if let deletedObjectIDs = batchDeleteResult?.result as? [NSManagedObjectID] {
                    NSManagedObjectContext.mergeChanges(fromRemoteContextSave: [NSDeletedObjectsKey: deletedObjectIDs],
                                                        into: [self.persistentContainer.viewContext])
                }
            } catch {
                print("Error: \(error)\nCould not batch delete existing records.")
                return
            }
            
            // Create new records.
            for filmDictionary in jsonDictionary {
                
                guard let film = NSEntityDescription.insertNewObject(forEntityName: "Film", into: taskContext) as? Film else {
                    print("Error: Failed to create a new Film object!")
                    return
                }
                
                do {
                    try film.update(with: filmDictionary)
                } catch {
                    print("Error: \(error)\nThe film object will be deleted.")
                    taskContext.delete(film)
                }
            }
            
            // Save all the changes just made and reset the taskContext to free the cache.
            if taskContext.hasChanges {
                do {
                    try taskContext.save()
                } catch {
                    print("Error: \(error)\nCould not save Core Data context.")
                }
                taskContext.reset() // Reset the context to clean up the cache and low the memory footprint.
            }
            successfull = true
        }
        return successfull
    }
}

Integration with View Controller (UI)

Inside the Main.storyboard drag a UITableViewController and create one prototype table view cell with “Cell” as the identifier and Subtitle as the style. Make sure to embed it inside UINavigationController and set it as the initial view controller.

Alt text

Create a new File with the name of FilmListViewController. The FilmListViewController inherits from UITableViewController as the superclass. Inside there are 2 instance properties we need to declare:

  1. DataProvider: The DataProvider class that we will use to trigger the synchronization of the films. It will be injected from the AppDelegate when the application launch.
  2. NSFetchedResultsController: NSFetchedResultsController is Apple Core Data class that acts a controller that you use to manage the results of a Core Data fetch request and display data to the user. It also provides delegation for the delegate to receive and react to the changes when the related entity in the store changes. In our case we use NSFetchRequest to fetch the Film entity, then tells it sort the result by episodeId in ascending order. We initialize the NSFetchedResultController with the FetchRequest and the DataProvider View Context. The FilmListViewController will also be assigned as the delegate so it can react and update the TableView when the underlying data changes.

The TableViewDataSource methods will ask the NSFetchedResultsController for its section, number of rows in a section, and the actual data for the table view cell at given IndexPath. We set the text label and detail text label of the cell with the title of the film and director of the film from the Film object.

For the NSFetchedResultController delegate we override the controllerDidChangeObject to just reload the TableView naively for the sake of this example. You can perform fine grained TableView update with animation here if you want using the indexPaths given.

At last, make sure to set the class of the UITableViewController inside the storyboard to use the FilmListViewController class. Build and run the project to test.

import UIKit
import CoreData

class FilmListViewController: UITableViewController {
    
    var dataProvider: DataProvider!
    lazy var fetchedResultsController: NSFetchedResultsController<Film> = {
        let fetchRequest = NSFetchRequest<Film>(entityName:"Film")
        fetchRequest.sortDescriptors = [NSSortDescriptor(key: "episodeId", ascending:true)]
        
        let controller = NSFetchedResultsController(fetchRequest: fetchRequest,
                                                    managedObjectContext: dataProvider.viewContext,
                                                    sectionNameKeyPath: nil, cacheName: nil)
        controller.delegate = self
        
        do {
            try controller.performFetch()
        } catch {
            let nserror = error as NSError
            fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
        }
        
        return controller
    }()

    
    override func viewDidLoad() {
        super.viewDidLoad()
        dataProvider.fetchFilms { (error) in
            // Handle Error by displaying it in UI
        }
    }
    
    override func numberOfSections(in tableView: UITableView) -> Int {
        return fetchedResultsController.sections?.count ?? 0
    }
    
    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return fetchedResultsController.sections?[section].numberOfObjects ?? 0
    }
    
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
        let film = fetchedResultsController.object(at: indexPath)
        cell.textLabel?.text = film.title
        cell.detailTextLabel?.text = film.director
        return cell
    }

}

extension FilmListViewController: NSFetchedResultsControllerDelegate {
    
    func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
        tableView.reloadData()
    }
}

Conclusion

Core Data is a very powerful framework that we can use when we want to build an app that want to synchronize data from cloud and has the feature to work offline. There are other solution for other persistence framework that has relational capabilities such as Realm Database that we can use as another options.