Igor Kasuan
Igor Kasuan

Reputation: 842

CoreData performance using NSFetchedResultsController. UITableView freezes during data import

I have the app similar to (almost the same code): https://developer.apple.com/documentation/coredata/loading_and_displaying_a_large_data_feed

The app loads and creates/updates 100000+ records from JSON to CoreData at launch.

App only consists of the screen with single tableview (with data from import). NSFetchedResultsController is used to show data in table view.

When app imports data in database the UI freezes, I try to scroll the tableview it also freezes even at small scrolls.

I see 100% load of the main thread during the import, so UI will freeze.

PrivateQueueConcurrencyType contexts(used for bg batch saves) have mainQueueConcurrencyType as parent. mainQueueConcurrencyType have privateQueueConcurrencyType as parent with persistentStoreCoordinator connected to it. (as described https://medium.com/soundwave-stories/core-data-cffe22efe716)

Is it possible somehow to make import process in background(on the other thread) and not to block main thread in this case?

How not to affect main thread almost at all during large import?

Code:

Core data stack

private lazy var managedObjectModel: NSManagedObjectModel = {
    guard let modelURL = Bundle.main.url(forResource: "Earthquakes", withExtension: "momd") else {
        fatalError("Unable to Find Data Model")
    }
    guard let managedObjectModel = NSManagedObjectModel(contentsOf: modelURL) else {
        fatalError("Unable to Load Data Model")
    }

    return managedObjectModel
}()

private lazy var persistentStoreCoordinator: NSPersistentStoreCoordinator = {
    let persistentStoreCoordinator = NSPersistentStoreCoordinator(managedObjectModel: managedObjectModel)
    let fileManager = FileManager.default
    let storeName = "test.sqlite"
    let documentsDirectoryURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0]
    let persistentStoreURL = documentsDirectoryURL.appendingPathComponent(storeName)

    do {
        try persistentStoreCoordinator.addPersistentStore(ofType: NSSQLiteStoreType,
                                                          configurationName: nil,
                                                          at: persistentStoreURL,
                                                          options: nil)
    } catch {
        fatalError("Unable to Load Persistent Store")
    }

    return persistentStoreCoordinator
}()

 lazy var parentContext: NSManagedObjectContext = {
    let moc = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
    moc.persistentStoreCoordinator = persistentStoreCoordinator
    return moc
}()

 lazy var context: NSManagedObjectContext = {
    let moc = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType)
    moc.parent = parentContext
    return moc
}()



 /**
 Fetches the earthquake feed from the remote server, and imports it into Core Data.

 Because this server does not offer a secure communication channel, this example
 uses an http URL and adds "earthquake.usgs.gov" to the "NSExceptionDomains" value
 in the apps's info.plist. When you commmunicate with your own servers, or when
 the services you use offer a secure communication option, you should always
 prefer to use https.
*/
func fetchQuakes(completionHandler: @escaping (Error?) -> Void) {

    // Create a URL to load, and a URLSession to load it.
    guard let jsonURL = URL(string: earthquakesFeed) else {
        completionHandler(QuakeError.urlError)
        return
    }
    let session = URLSession(configuration: .default)

    // Create a URLSession dataTask to fetch the feed.
    let task = session.dataTask(with: jsonURL) { data, _, error in

        // Alert the user if no data comes back.
        guard let data = data else {
            completionHandler(QuakeError.networkUnavailable)
            return
        }

        // Decode the JSON and import it into Core Data.
        do {
            // Decode the JSON into codable type GeoJSON.
            let decoder = JSONDecoder()
            var geoJSON = try decoder.decode(GeoJSON.self, from: data)

            geoJSON.quakePropertiesArray.append(contentsOf: geoJSON.quakePropertiesArray)
            geoJSON.quakePropertiesArray.append(contentsOf: geoJSON.quakePropertiesArray)
            geoJSON.quakePropertiesArray.append(contentsOf: geoJSON.quakePropertiesArray)
            geoJSON.quakePropertiesArray.append(contentsOf: geoJSON.quakePropertiesArray)
            print(geoJSON.quakePropertiesArray.count)

            // Import the GeoJSON into Core Data.
            try self.importQuakes(from: geoJSON)

        } catch {
            // Alert the user if data cannot be digested.
            completionHandler(QuakeError.wrongDataFormat)
            return
        }
        completionHandler(nil)
    }
    // Start the task.
    task.resume()
}

/**
 Imports a JSON dictionary into the Core Data store on a private queue,
 processing the record in batches to avoid a high memory footprint.
*/
private func importQuakes(from geoJSON: GeoJSON) throws {

    guard !geoJSON.quakePropertiesArray.isEmpty else { return }

    // Create a private queue context.
    //let taskContext = persistentContainer.newBackgroundContext()
    let taskContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
    taskContext.parent = context


    taskContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
    // Set unused undoManager to nil for macOS (it is nil by default on iOS)
    // to reduce resource requirements.
    taskContext.undoManager = nil

    // Process records in batches to avoid a high memory footprint.
    let batchSize = 256
    let count = geoJSON.quakePropertiesArray.count

    // Determine the total number of batches.
    var numBatches = count / batchSize
    numBatches += count % batchSize > 0 ? 1 : 0

    for batchNumber in 0 ..< numBatches {

        // Determine the range for this batch.
        let batchStart = batchNumber * batchSize
        let batchEnd = batchStart + min(batchSize, count - batchNumber * batchSize)
        let range = batchStart..<batchEnd

        // Create a batch for this range from the decoded JSON.
        let quakesBatch = Array(geoJSON.quakePropertiesArray[range])

        // Stop the entire import if any batch is unsuccessful.
        if !importOneBatch(quakesBatch, taskContext: taskContext) {
            return
        }

    }
}

/**
 Imports one batch of quakes, creating managed objects from the new data,
 and saving them to the persistent store, on a private queue. After saving,
 resets the context to clean up the cache and lower the memory footprint.

 NSManagedObjectContext.performAndWait doesn't rethrow so this function
 catches throws within the closure and uses a return value to indicate
 whether the import is successful.
*/
private func importOneBatch(_ quakesBatch: [QuakeProperties], taskContext: NSManagedObjectContext) -> Bool {

    var success = false

    // taskContext.performAndWait runs on the URLSession's delegate queue
    // so it won’t block the main thread.
    taskContext.performAndWait {
        // Create a new record for each quake in the batch.
        for quakeData in quakesBatch {

            // Create a Quake managed object on the private queue context.
            guard let quake = NSEntityDescription.insertNewObject(forEntityName: "Quake", into: taskContext) as? Quake else {
                print(QuakeError.creationError.localizedDescription)
                return
            }
            // Populate the Quake's properties using the raw data.
            do {
                try quake.update(with: quakeData)
            } catch QuakeError.missingData {
                // Delete invalid Quake from the private queue context.
                print(QuakeError.missingData.localizedDescription)
                taskContext.delete(quake)
            } catch {
                print(error.localizedDescription)
            }
        }

        // Save all insertions and deletions from the context to the store.
        if taskContext.hasChanges {
            do {
                try taskContext.save()
                context.performAndWait {
                    try? context.save()
                }

            } catch {
                print("Error: \(error)\nCould not save Core Data context.")
                return
            }
            // Reset the taskContext to free the cache and lower the memory footprint.
            taskContext.reset()


        }

        success = true
    }
    return success
}

    // MARK: - NSFetchedResultsController

/**
 A fetched results controller delegate to give consumers a chance to update
 the user interface when content changes.
 */
weak var fetchedResultsControllerDelegate: NSFetchedResultsControllerDelegate?

/**
 A fetched results controller to fetch Quake records sorted by time.
 */
lazy var fetchedResultsController: NSFetchedResultsController<Quake> = {

    // Create a fetch request for the Quake entity sorted by time.
    let fetchRequest = NSFetchRequest<Quake>(entityName: "Quake")
    fetchRequest.sortDescriptors = [NSSortDescriptor(key: "time", ascending: false)]

    // Create a fetched results controller and set its fetch request, context, and delegate.
    let controller = NSFetchedResultsController(fetchRequest: fetchRequest,
                                                managedObjectContext: context,
                                                sectionNameKeyPath: nil, cacheName: nil)

    controller.delegate = fetchedResultsControllerDelegate

    // Perform the fetch.
    do {
        try controller.performFetch()
    } catch {
        fatalError("Unresolved error \(error)")
    }

    return controller
}()

ViewController code:

// MARK: - UITableViewDataSource

extension QuakesViewController {

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {

        guard let cell = tableView.dequeueReusableCell(withIdentifier: "QuakeCell", for: indexPath) as? QuakeCell else {
            print("Error: tableView.dequeueReusableCell doesn'return a QuakeCell!")
            return QuakeCell()
        }
        guard let quake = dataProvider.fetchedResultsController.fetchedObjects?[indexPath.row] else { return cell }

        cell.configure(with: quake)
        return cell
    }

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return dataProvider.fetchedResultsController.fetchedObjects?.count ?? 0
    }
}

// MARK: - NSFetchedResultsControllerDelegate

extension QuakesViewController: NSFetchedResultsControllerDelegate {

    /**
     Reloads the table view when the fetched result controller's content changes.
     */
    func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
        tableView.reloadData()
    }
}

Upvotes: 1

Views: 635

Answers (1)

Tiago Chaves
Tiago Chaves

Reputation: 78

All the UI are performed at main thread, so you can try to use a global queue to perform this fetch.

I think that a global queue with .utility QoS will be enough to solve this problem.

Here a sample:

DispatchQueue.global(qos: .utility).async {
            //do the fetch here
}

The global queues are concurrent, the utility QoS (quality of service) tells the GCD that this task should not perform in main thread and the .async make the execution doesn't block this global queue.

If it's not totally clear i can post a most complete sample code for you :)

Good luck!

Upvotes: 1

Related Questions