Bug with FetchedResultsController after setting it to nil in viewWillDisappear - ios

In my iOS (Swift 3, Xcode 8) Core Data app I have 2 View Controllers (CategoriesViewController and ObjectsViewController) (Both are inside the same navigation controller).
Each ViewController has it's own tableView and it's own fetchResultsController to manage the results returned from Core Data request (Fetching entities titled Category in CategoriesViewController and entities titled Object in ObjectsViewController).
In my CategoriesViewController I have this variable:
var fetchResultsController: NSFetchedResultsController<Category>!
I've added the following code to CategoriesViewController to avoid having errors when opening another view :
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(true)
self.fetchedResultsController.delegate = nil
self.fetchedResultsController = nil
}
In CategoriesViewController I've added these methods for fetchedResultsController :
func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
self.tableView.beginUpdates()
}
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
self.tableView.endUpdates()
}
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
switch type {
case .update:
guard let path = indexPath
else { return }
tableView.reloadRows(at: [path], with: .automatic)
case .delete:
guard let path = indexPath
else { return }
tableView.deleteRows(at: [path],
with: .automatic)
case .insert:
guard let path = newIndexPath
else { return }
tableView.insertRows(at: [path],
with: .automatic)
case .move:
guard let _ = indexPath,
let _ = newIndexPath
else { return }
// tableView.moveRow(at: fromPath, to: toPath)
if indexPath != newIndexPath {
tableView.deleteRows(at: [indexPath!], with: .none)
tableView.insertRows(at: [newIndexPath!], with: .none)
}
}
}
To fetch Core Data objects I wrote a coreData_fetchAll_Categories(). I've placed it into a viewWillAppear method of CategoriesViewController. After that i'm reloading data of a tableView.
func coreData_fetchAll_Categories(handleCompleteFetching:#escaping (()->())) {
let context = CoreDataManager.sharedInstance.viewContext
let fetchRequest: NSFetchRequest<Category> = Category.fetchRequest()
var sortDescriptors = [NSSortDescriptor]()
let indexSortDescriptior = NSSortDescriptor(key: "indexOrder", ascending: true)
sortDescriptors.append(indexSortDescriptior)
fetchRequest.sortDescriptors = sortDescriptors
self.fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: context!, sectionNameKeyPath: nil, cacheName: nil)
self.coreDataFetchedResultsController.delegate = self
do { try self.fetchedResultsController.performFetch()
} catch {
print("performFetch() finished with error")
}
}
With the above code, after i'm returning back from my ObjectsViewController (where I also have all the methods with fetchedResultsController for Object entity and I also set fetchedResultsController to nil there in viewWillDisappear) my tableView in CategoriesViewController freezes. If I delete these 2 lines from viewWillDisappear of CategoriesViewController, everything works fine, but I need these lines to avoid another bugs.
self.fetchedResultsController.delegate = nil
self.fetchedResultsController = nil
Code in ViewWillAppear looks like this:
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(true)
self.tableView.register(UINib.init(nibName: “CategoriesTableViewCell", bundle: nil), forCellReuseIdentifier: “categoriesTableViewCell")
self.tableView.delegate = self
self.tableView.dataSource = self
self.coreData_fetchAll_Categories {
DispatchQueue.main.async { // Submit to the main Queue async
self.tableView.reloadData()
}
}
}
After a CategoriesViewController appears a VC creates new version of fetchedResultsController (I've checked , it is not nil). Also i've noticed that tableView doesn't call cellForRowAt indexPath: . Seems Strange. delegates of tableView set to self in viewWillAppear.
I don't understand why can this bug happen, because i don't receive any errors.
Any opinions for this bug welcome. Working in Swift 3 (XCode 8).

You have to make sure that the tableview and the fetchedResultsController are never out of sync. Here are a few places that it can happen in your code:
When you nil out the fetchedResultsController you must also reload the tableview, because now it should have zero section and zero rows
coreData_fetchAll_Categoriesis already running on the main thread - there is no reason for a completion hander like that. Furthermore the use of DispatchQueue.main.async can cause real harm as there is a period of time before reloadData is called when the tableview and fetchedResultsController are out of sync
Also (unrelated to your core data problems) when you call super.viewWillDisappear and super.viewWillAppear you should pass along the animated parameter - and not always pass true
Hope that helps

Related

Is it possible for NSFetchResultsController to perform move and update operation, when we change the section of an item?

Currently, I have a UICollectionView which consists of 2 sections
Pinned
Normal
They looks as following.
Overview
== Pinned ===========
|------|
|NOTE0 |
|------|
== Normal ===========
|------| |------|
|NOTE1 | |NOTE2 |
|------| |------|
|------|
|NOTE3 |
|------|
NSManagedObject
This is the NSManagedObject
extension NSPlainNote {
#nonobjc public class func fetchRequest() -> NSFetchRequest<NSPlainNote> {
return NSFetchRequest<NSPlainNote>(entityName: "NSPlainNote")
}
#NSManaged public var title: String?
#NSManaged public var body: String?
#NSManaged public var pinned: Bool
#NSManaged public var uuid: UUID
}
NSFetchResultsController
We use the Bool field, to decide whether an item should belong to Pinned section, or Normal section
This is how our NSFetchResultsController looks like
lazy var fetchedResultsController: NSFetchedResultsController<NSPlainNote> = {
// Create a fetch request for the Quake entity sorted by time.
let fetchRequest = NSFetchRequest<NSPlainNote>(entityName: "NSPlainNote")
fetchRequest.sortDescriptors = [
NSSortDescriptor(key: "pinned", ascending: false)
]
// Create a fetched results controller and set its fetch request, context, and delegate.
let controller = NSFetchedResultsController(fetchRequest: fetchRequest,
managedObjectContext: CoreDataStack.INSTANCE.persistentContainer.viewContext,
sectionNameKeyPath: "pinned",
cacheName: nil
)
controller.delegate = fetchedResultsControllerDelegate
// Perform the fetch.
do {
try controller.performFetch()
} catch {
fatalError("Unresolved error \(error)")
}
return controller
}()
Move and update operation
We then perform the following operations
Either move the item from Normal section to Pinned section, or move the item from Pinned section to Normal section.
Update the content.
func updatePinned(_ objectID: NSManagedObjectID, _ pinned: Bool) {
let coreDataStack = CoreDataStack.INSTANCE
let backgroundContext = coreDataStack.backgroundContext
// TODO: Can we optimize the code, to avoid fetching the entire model object?
backgroundContext.perform {
let nsPlainNote = try! backgroundContext.existingObject(with: objectID) as! NSPlainNote
// This will trigger "move". The cell shall move to different section.
nsPlainNote.pinned = pinned
// Can we trigger "update" as well?
if nsPlainNote.pinned {
nsPlainNote.body = nsPlainNote.title! + "(Pinned)"
} else {
nsPlainNote.body = nsPlainNote.title
}
RepositoryUtils.saveContextIfPossible(backgroundContext)
}
}
NSFetchedResultsControllerDelegate
extension ViewController: NSFetchedResultsControllerDelegate {
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
if type == NSFetchedResultsChangeType.insert {
print("Insert Object: \(newIndexPath)")
blockOperations.append(
BlockOperation(block: { [weak self] in
if let this = self {
this.collectionView!.insertItems(at: [newIndexPath!])
}
})
)
}
else if type == NSFetchedResultsChangeType.update {
print("Update Object: \(indexPath)")
blockOperations.append(
BlockOperation(block: { [weak self] in
if let this = self {
this.collectionView!.reloadItems(at: [indexPath!])
}
})
)
}
else if type == NSFetchedResultsChangeType.move {
print("Move Object: \(indexPath) to \(newIndexPath)")
blockOperations.append(
BlockOperation(block: { [weak self] in
if let this = self {
this.collectionView!.moveItem(at: indexPath!, to: newIndexPath!)
}
})
)
}
else if type == NSFetchedResultsChangeType.delete {
print("Delete Object: \(indexPath)")
blockOperations.append(
BlockOperation(block: { [weak self] in
if let this = self {
this.collectionView!.deleteItems(at: [indexPath!])
}
})
)
}
}
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange sectionInfo: NSFetchedResultsSectionInfo, atSectionIndex sectionIndex: Int, for type: NSFetchedResultsChangeType) {
if type == NSFetchedResultsChangeType.insert {
print("Insert Section: \(sectionIndex)")
blockOperations.append(
BlockOperation(block: { [weak self] in
if let this = self {
this.collectionView!.insertSections(IndexSet(integer: sectionIndex))
}
})
)
}
else if type == NSFetchedResultsChangeType.update {
print("Update Section: \(sectionIndex)")
blockOperations.append(
BlockOperation(block: { [weak self] in
if let this = self {
this.collectionView!.reloadSections(IndexSet(integer: sectionIndex))
}
})
)
}
else if type == NSFetchedResultsChangeType.delete {
print("Delete Section: \(sectionIndex)")
blockOperations.append(
BlockOperation(block: { [weak self] in
if let this = self {
this.collectionView!.deleteSections(IndexSet(integer: sectionIndex))
}
})
)
}
}
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
collectionView!.performBatchUpdates({ () -> Void in
for operation: BlockOperation in self.blockOperations {
operation.start()
}
}, completion: { (finished) -> Void in
self.blockOperations.removeAll(keepingCapacity: false)
})
}
}
We want to move and update an item from Normal section to Pinned section.
We execute
updatePinned(objectId, true)
Only the following is printed
Move Object: Optional([1, 12]) to Optional([0, 0])
We expect besides NSFetchedResultsChangeType.move, NSFetchedResultsChangeType.update should happen too. But, it doesn't. Only NSFetchedResultsChangeType.move is happen.
Workaround (This is a wrong approach! Do NOT apply this!)
I try to reloadData after the end of animation.
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
collectionView!.performBatchUpdates({ () -> Void in
for operation: BlockOperation in self.blockOperations {
operation.start()
}
}, completion: { (finished) -> Void in
self.blockOperations.removeAll(keepingCapacity: false)
// Do not do this! As, it will cause NSFetchedResultsController malfuntion after some time.
// You will soon realize NSFetchedResultsController is wrongly placing a pinned
// note in normal section.
// Or even worst, it will issue didChange callback with wrong NSFetchedResultsChangeType value
self.collectionView.reloadData()
})
}
It looks like thing works fine at first sight. However, if you perform pin and unpin operation for several times, you will notice that NSFetchedResultsController is placing note in wrong section. It will place the pinned note in normal section, and a normal note in pinned section.
Or even worst, it will issue didChange callback with wrong NSFetchedResultsChangeType value
Demo
The following is the demo code to illustrate the mentioned problem.
https://github.com/yccheok/UICollectionView-02/tree/stackoverflow
As you can see, after move, update is not performed. We can observe
Blue pin icon is not drawn
Orange background color body text is not update
The update will only be performed, if we perform scrolling explicitly.
May I know, what is the correct way for me to change an item's section (So that there is move animation), and update item's content (So that cellForItemAt function will be called)?
(Crossporting my answer over at r/iOSProgramming so Stackoverflow users find it too)
NSFRC moves always imply an update. The only reason an object would “move” is if its value for the sort key changes, which means that object has been updated as well.
Edit:
I see, I think you've hit one of the common edge cases in UICollectionView reloading in tandem with NSFRCs. You shouldn't use reloadItems() here, regardless of how unintuitive that is. Instead, use cellForRow(at:) and create a method that updates the data for that cell. This is true for both UICollectionView and UITableView.
Check how everybody else does it:
JSQDataSourcesKit
CoreStore

NSFetchedResultsController ignoring fetchLimit after update/deletion

I am creating a todo list with CollectionView(VC1) and FRC.
I am first displaying VC1 on my homepage(in a section of another CVC) with a limit of items set to 3(Like a preview). Then I have a button which presents VC1 fullscreen and displays the entire count of items.
So far fetch limit is working correctly. However when I am on VC1 fullscreen I can update/delete items, now when I perform collectionView.performBatchUpdates I delete/update items and both instances of the views and updated accordingly. But...
If I add 3 more items, VC1 on the home page: limit is not 3 anymore but 6.
If I delete 2 items, VC1 on the home page: is now only displaying 1 item even if in coreData I have another 20.
It seems that no matter what the limit is set to, how many ever I delete or insert the collectionView updates accordingly. Now should I recall fetch items every time?
Here is some code of how I set the limits and update collectionView
lazy var fetchedResultsController: NSFetchedResultsController<TodoItem> = {
let context = CoreDataManager.shared.persistentContainer.viewContext
let request: NSFetchRequest<TodoItem> = TodoItem.fetchRequest()
request.sortDescriptors = [
NSSortDescriptor(key: "date", ascending: false)
]
request.fetchLimit = mode == .fullscreen ? .max : 3
let frc = NSFetchedResultsController(fetchRequest: request, managedObjectContext: context, sectionNameKeyPath: nil, cacheName: nil)
frc.delegate = self
do {
try frc.performFetch()
} catch let err {
print(err)
}
return frc
}()
// Number of items in CV
override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
let allItems = fetchedResultsController.sections?[section].numberOfObjects ?? 0
let numberOfRows = mode == .fullscreen ? allItems : min(allItems, 3)
return numberOfRows
}
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
itemChanges.append((type, indexPath, newIndexPath))
}
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
collectionView?.performBatchUpdates({
for change in self.itemChanges {
switch change.type {
case .insert:
self.collectionView?.insertItems(at: [change.newIndexPath!])
case .delete:
self.collectionView?.deleteItems(at: [change.indexPath!])
case .update:
self.collectionView?.reloadItems(at: [change.indexPath!])
case .move:
self.collectionView?.deleteItems(at: [change.indexPath!])
self.collectionView?.insertItems(at: [change.newIndexPath!])
#unknown default:
return
}
}
}, completion: { finished in
self.itemChanges.removeAll()
})
}
Thank you for any help in advance.
The closure of the lazy instantiated variable is executed only once.
You have to change the fetch request with appropriate fetch limit somewhere else.

NSFetchResultsController throws error during update two elements simultaneosly

I am trying two update elements in collection view. When I pass just one message it works nicely
func simulate() {
let delegate = UIApplication.shared.delegate as! AppDelegate
let context = delegate.persistentContainer.viewContext
FriendsController.createMessageWithText(text: "Here's a text message that was sent a few minutes ago...", friend: friend!, minutesAgo: 1, context: context)
//second message
FriendsController.createMessageWithText(text: "Another message to give you hard time.", friend: friend!, minutesAgo: 1, context: context)
do {
try context.save()
}
catch let error as NSError {
print("Error \(error.localizedDescription)")
}
}
but when I do same steps except passing two messages it throws error:
CoreData: error: Serious application error. Exception was caught
during Core Data change processing. This is usually a bug within an
observer of NSManagedObjectContextObjectsDidChangeNotification.
Invalid update: invalid number of items in section 0. The number of
items contained in an existing section after the update (8) must be
equal to the number of items contained in that section before the
update (6), plus or minus the number of items inserted or deleted from
that section (1 inserted, 0 deleted) and plus or minus the number of
items moved into or out of that section (0 moved in, 0 moved out).
with userInfo (null)
Maybe someone have any clue?
There are: NSFetchController and delegate function I implemented:
lazy var fetchedResultsController: NSFetchedResultsController = { () -> NSFetchedResultsController<Message> in
let fetchRequest = NSFetchRequest<Message>(entityName: "Message")
fetchRequest.sortDescriptors = [NSSortDescriptor(key:"date",ascending: true)]
fetchRequest.predicate = NSPredicate(format: "friend.name = %#", (self.friend?.name!)!)
let delegate = UIApplication.shared.delegate as! AppDelegate
let context = delegate.persistentContainer.viewContext
let frc = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: context, sectionNameKeyPath: nil, cacheName: nil)
frc.delegate = self
return frc
}()
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
if type == .insert {
collectionView?.insertItems(at: [newIndexPath!])
collectionView?.scrollToItem(at: newIndexPath!, at: .bottom, animated: true)
}
}
If you want to insert(or delete, reload) more than one cell, than you need to make it in performBatchUpdates method of UICollectionView instance. You need to call it in func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>).
Look at this example: https://github.com/NoFearJoe/Timmee/blob/master/Timmee/Timmee/Sources/Core%20layer/Database/StorageObservable.swift
or:
https://gist.github.com/tempire/debbabb2cccafa90320e

Updates to core data object from SyncTable pull, not calling NSFeatchedResults delegate methods

I am using NSFetchedResultsController to track and load UITableView with two sections(based on available true or false) using data from coredata entity. Coredata entity is populated by azure syncTable pull method. I call fetchedDataController?.performFetch() from ViewDidLoad() method in VC.
Data is divided into two sections - section 1 available is false, section2- avaliable is true in the entity data.
Here is code snippet of NSFRC initialization:
lazy var fetchedDataController: NSFetchedResultsController<Product>? = {
let req = NSFetchRequest<NSFetchRequestResult>(entityName: "Product")
req.sortDescriptors = [NSSortDescriptor(key: "available", ascending: true), NSSortDescriptor(key: "createdAt", ascending: false)]
guard let dbContext = self.appDelegate?.managedObjectContext else { return nil }
let controller = NSFetchedResultsController(fetchRequest: req, managedObjectContext: dbContext, sectionNameKeyPath: "available", cacheName: nil) as? NSFetchedResultsController<Product>
controller?.delegate = self
return controller
}()
Issues:
1. Even there is update in entity row data FRC delegate methods are not fired automatically. Like I changed available from true to false in the backend and did a SyncTable.pull, I can see that coredata table is updated with latest data( I opened the .sql file and able to see the data)
To overcome workaround I called perform fetch every time I do a pull, in this case when I scroll down to section 2 app is crashing with index error, that means it is not updating the table section and rows properly.
Delegate methods implementation:
func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
self.tableView.beginUpdates()
}
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
self.tableView.endUpdates()
}
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange sectionInfo: NSFetchedResultsSectionInfo, atSectionIndex sectionIndex: Int, for type: NSFetchedResultsChangeType) {
let sectionIndexSet = IndexSet(integer: sectionIndex)
switch type {
case .insert:
self.tableView.insertSections(sectionIndexSet, with: .fade)
case .update:
self.tableView.reloadSections(sectionIndexSet, with: .fade)
case .delete:
self.tableView.deleteSections(sectionIndexSet, with: .fade)
default: break
}
}
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
switch type {
case .insert:
if let newIndex = newIndexPath {
self.tableView.insertRows(at: [newIndex], with: .fade)
}
case .update:
if let index = indexPath {
self.tableView.reloadRows(at: [index], with: .fade)
}
case .move:
if let index = indexPath {
self.tableView.deleteRows(at: [index], with: .fade)
if let newIndex = newIndexPath {
self.tableView.insertRows(at: [newIndex], with: .fade)
}
}
case .delete:
if let index = indexPath {
self.tableView.deleteRows(at: [index], with: .fade)
}
default: break
}
}
I am stuck into this any help is appreciated.
Adding the observer for managedobjectcontext save solves this issue:
NotificationCenter.default.addObserver(self, selector: #selector(refreshContent), name: NSNotification.Name.NSManagedObjectContextDidSave, object: nil)
func refreshContent(notif: Notification)
{
self. fetchedDataController?.managedObjectContext.mergeChanges(fromContextDidSave: notifn)
}
A fetchedResultsController monitors a particular context - not the sql file. If you have more than one contexts in the app, then changes from one may not be merged into the other. So even if the sql files is updated the context will not be. When you create the context make sure to set the viewContext's automaticallyMergesChangesFromParent = true.

Core Data NSFetchedResultsController not updated after a batchUpadate on the device but ok on simulator

I have a NSFetchedResultsController that managed my UITableView data source.
I am trying to modify a property of a NSManagedObject called amountToCompute using a NSBatchUpdateRequest. So I create the batch update:
let batchUpdate = NSBatchUpdateRequest(entityName: "MyEntity")
batchUpdate.propertiesToUpdate = ["amountToCompute" : newAmount]
batchUpdate.resultType = .UpdatedObjectIDsResultType
I execute it:
var batchError: NSError?
let batchResult = managedContext.executeRequest(batchUpdate, error: &batchError) as! NSBatchUpdateResult?
And to update my current managed context, I update each managedObject in the managedContext and perform a new fetch of fetchedResultsController:
if let result = batchResult {
let objectIDs = result.result as! [NSManagedObjectID]
for objectID in objectIDs {
let managedObject: NSManagedObject = managedContext.objectWithID(objectID)
if !managedObject.fault {
managedContext.refreshObject(managedObject, mergeChanges: true)
}
if !fetchedResultsController.performFetch(&error) {
println("error: + \(error?.localizedDescription), \(error!.userInfo)")
}
}
}
I implemented some methods of the delegate NSFetchedResultsControllerDelegate to manage changes in the results sent by the NSFetchedResultsController:
func controllerWillChangeContent(controller: NSFetchedResultsController) {
tableView.beginUpdates()
}
func controller(controller: NSFetchedResultsController, didChangeObject anObject: AnyObject, atIndexPath indexPath: NSIndexPath?, forChangeType type: NSFetchedResultsChangeType, newIndexPath: NSIndexPath?) {
switch type {
...
case .Update:
reloadRowsAtIndexPaths([indexPath!], animation: reloadRowsWithAnimation)
let myManagedObject = anObject as! MyManagedObject
println("update : \(myManagedObject.amountToCompute)")
...
}
}
func controllerDidChangeContent(controller: NSFetchedResultsController) {
tableView.endUpdates()
}
I run the app on my 8.4 iOS simulator and everything goes fine.
println("update : \(myManagedObject.amountToCompute)") prints the new value.
I run the app on my iPhone 6 8.4.1 and the value is not updated, println("update : \(myManagedObject.amountToCompute)") prints the old value. The new value is saved properly but the changes don't appear in my table view while they do on the simulator.
What's wrong? How come it can be different whether I'm on the simulator or on my device. The versions are not exactly the same but I doubt Apple touched Core Data architecture in their last update.
This is a an unusual, but known problem that I ran into myself and spent a couple of days pulling out my hair until found this blog post, which worked for me.
https://stevenpsmith.wordpress.com/2011/08/12/nsfetchedresultscontroller-and-core-data-managed-object-updates/
It boiled down to adding this single line of code that set the staleness interval to zero:
[context setStalenessInterval:0];
If I'm reading your post right you're having the same issue. :)

Resources