Realm observe with UICollectionView - race conditions - ios

I am using Realm as a caching layer so that whenever data is presented to the user, it is first fetched from the database and displayed to the user. Subsequently, a server request is sent to fetch the newest version of the data, sync it with the Realm database and display the changes in a UICollectionView.
The problem is that when the cached data is retrieved from the Realm database and the UICollectionView is getting updated, there is a chance that the server request for update finished before the UICollectionView loaded all the cells and since the Results list is a live collection of data, it could have been modified. Now for example, if an item was removed on the server-side, the live collection would hold one item less and therefore cause out of bounds exception.
This being said, even the code provided in official Realm documentation is not thread-safe considering the fact that the results can be changed while the UITableView is asking for each row one by one:
class ViewController: UITableViewController {
var notificationToken: NotificationToken? = nil
override func viewDidLoad() {
super.viewDidLoad()
let realm = try! Realm()
let results = realm.objects(Person.self).filter("age > 5")
// Observe Results Notifications
notificationToken = results.observe { [weak self] (changes: RealmCollectionChange) in
guard let tableView = self?.tableView else { return }
switch changes {
case .initial:
// Results are now populated and can be accessed without blocking the UI
tableView.reloadData()
case .update(_, let deletions, let insertions, let modifications):
// Query results have changed, so apply them to the UITableView
tableView.beginUpdates()
tableView.insertRows(at: insertions.map({ IndexPath(row: $0, section: 0) }),
with: .automatic)
tableView.deleteRows(at: deletions.map({ IndexPath(row: $0, section: 0)}),
with: .automatic)
tableView.reloadRows(at: modifications.map({ IndexPath(row: $0, section: 0) }),
with: .automatic)
tableView.endUpdates()
case .error(let error):
// An error occurred while opening the Realm file on the background worker thread
fatalError("\(error)")
}
}
}
deinit {
notificationToken?.invalidate()
}
}
The only way I can think of fixing this is to create a deep copy of the results as well as synchronize the body of the observe function using Semaphore or similar to make sure the data will not get in an inconsistent state, which I consider very inefficient. (Note that tableView.endUpdates() does not mean the UITableView has reloaded all the data, however it is just dispatched to a queue and ready to be processed in async.)
I would like to hear any suggestions how to implement this in an efficient way such that the mentioned race conditions are eliminated.

You need to do all of your UI updates on the main thread. If you do this an the first sets of results updates the collection view on the main thread, when the next set of results also comes it it will be queued on the main thread so it updates after the first set is done.

Based on:
The problem is that when the cached data is retrieved from the Realm database and the UICollectionView is getting updated, there is a chance that the server request for update finished before the UICollectionView loaded all the cells and since the Results list is a live collection of data, it could have been modified.
I do not think that that will happen, since as soon as your live collection will be changed update notification will be triggered and collection will be rebuild/updated accordingly. However, as I said you in PM it was some time ago when I worked with realm.
It is quite easy to test your hypothesis: decrease speed of your simulator's internet, or make huge table, etc. I am really curious if you can actually create a problem which you think you will have.

Related

Without reloading Tableview, update the changed index cell values using Observer in iOS Swift

In the first time I collect the array values from API response and displayed it in tableview, again I will get the same response from socket and reload the values in table, But here I don't need to reload entire table, I want update the cell's which value has been changed.
Here Compare the two array's, from which index has changes, just need to update that index row cells only, without reload entire table view.old and new array, CodeSample
But you should be careful if will change some indexPaths in you data stack, if it gonna happen - use tableView.deleteRows or tableView.deleteSections. This updates should be qual in table and in dataStack or crash
let indexPaths = [IndexPath]()
tableView.performBatchUpdates {
self.tableView.reloadRows(at: indexPaths, with: .none)
}
or
tableView.beginUpdates()
tableView.reloadRows(at: indexPaths, with: .none)
tableView.endUpdates()
btw, you can make your indexPaths to update like let indexPaths = tableView.indexPathsForVisibleRows - method's name is speechful, in case if you have socket I suppose it would be helpful since u've got dynamic

Keep timestamp label updated in UITableView

I have a UIViewController that has a UITableView which presents comments fetched form a live Firebase database.
Every time a new comment arrives, I call
tableView.beginUpdates()
tableView.insertRows(at: [IndexPath(row: self.liveComments.count-1, section: 0)], with: .fade)
tableView.endUpdates()
to insert the latest comment with a fade animation. This works fine.
However, each cell has a label that shows when it was posted, in the form of "seconds, minutes or hours ago". The problem is that when many comments arrive, the age label does not get updated, since the existing cells are not updated, and it looks to the user like the comment ages are wrong.
I've tried calling
tableView.reloadRows(at: self.tableView.indexPathsForVisibleRows ?? [], with: .none)
inside my tableView updated block, but the animation is all messed up, since all of the visible cells seem to get animated in a weird, "jumpy" way.
I've also tried getting all of the visible cells, and calling a method on them to update their timestamp labels manually, but I get a crash when I do this, so I guess it's not recommended:
if let visibleCells = self.tableView.visibleCells as? [LiveCommentTableViewCell] {
visibleCells.forEach { cell in
cell.updateCommentAgeLabel()
}
How can I approach this? I just need to reload all visible cells without an animation, and the last cell with a fade in animation. Thank you!
I would just reload all the data, as long the cellForRowAt sets the timestamp label correctly it should work fine:
// still do your nice animation
tableView.beginUpdates()
tableView.insertRows(at: [IndexPath(row: self.liveComments.count-1, section: 0)], with: .fade)
tableView.endUpdates()
// now just refresh the entire table
tableView.reloadData()
of course you're going to want to make sure that whatever collection feeds the numberOfItemsInSection is also updated before calling reloadData() im assuming you're already doing this as well or you'd be running into a lot of bugs and crashes
make sure that code that edits UI is on the main thread too, obviously.
That being said what does your cell.updateCommentAgeLabel() function look like bc that would work in theory as well unless potentially its not being called on the main thread again or the cast isn't working.
Perhaps try telling the system you want it to do a layout pass:
if let visibleCells = self.tableView.visibleCells as? [LiveCommentTableViewCell] {
visibleCells.forEach { cell in
cell.updateCommentAgeLabel()
cell.layoutIfNeeded() // either this
}
tableView.layoutIfNeeded() // OR this at the end, I dont expect you'll need to do both but not sure if both work

Problems with asynchronous data, UITableView, and reloadRowsAt

I'm trying to implement a tableView that has 4 different possible prototype cells. They all inherit from base UITableViewCell class and implement its protocol.
For two of the cells there's asynchronous data fetching but one in particular has been giving me fits. The flow is as follows:
1) Dequeue reusable cell
2) Call configure
func configure(someArguments: ) {
//some checks
process(withArguments: ) { [weak self in] in
if let weakSelf = self {
weakSelf.reloadDelegate.reload(forID: id)
}
}
}
3) If the async data is in the cache, configure the cell using the image/data/stuff available and be happy
4) If the async data is NOT in the cache, fetch it, cache it, and call the completion
func process(withArguments: completion:) {
if let async_data = cache.exists(forID: async_data.id) {
//set labels, add views, etc
} else {
fetch_async_data() {
//add to cache
//call completion
}
}
}
5) If the completion is called, reload the row in question by passing the index path up to the UITableViewController and calling reloadRows(at:with:)
func reload(forID: ) {
tableView.beginUpdates()
tableView.reloadRows(at: indexPath_matching_forID with: .automatic)
tableView.endUpdates()
}
Now, my understanding is that reloadRows(at:with:) will trigger another dataSource/delegate cycle and thus result in a fresh resuable cell being dequeued, and the configure method being called again, thereby making step #3 happy (the async data will now be in the cache since we just fetched it).
Except...that's not always happening. If there are cells in my initial fetch that require reloading, it works - they get the data and display it. Sometimes, though, scrolling down to another cell that requires fetching DOES NOT get the right data...or more specifically, it doesn't trigger a reload that populates the cell with the right data. I CAN see the cache being updated with the fresh data, but it's not...showing up.
If, however, I scroll completely past the bad cell, and then scroll back up, the correct data is used. So, what the hell reloadRows?!
I've tried wrapping various things in DispatchQueue.main.async to no avail.
reloadData works, ish, but is expensive because of potentially many async requests firing on a full reload (plus it causes some excessive flickering as cells come back)
Any help would be appreciated!
Reused cells are not "fresh". Clear the cell while waiting for content.
func process(withArguments: completion:) {
if let async_data = cache.exists(forID: async_data.id) {
//set labels, add views, etc
} else {
fetch_async_data() {
// ** reset the content of the cell, clear labels etc **
//add to cache
//call completion
}
}
}

Reloading data from a tableView within a collectionViewCell

I have a tableView inside a collectionViewCell and I get an error when I try to reload the data.
*** Assertion failure in -[UITableView _endCellAnimationsWithContext:], /BuildRoot/Library/Caches/com.apple.xbs/Sources/UIKit_Sim/UIKit-3600.6.21/UITableView.m:1610
I tried using Dispatch.main.async and it seems to get rid of the problem. The only thing is that it doesn't reload the data and nothing changes in the tableView
func cleanItems(completion: #escaping (Bool) -> Void) {
Dispatch.main.async {
cell.tableView.beginUpdates()
// Make changes in the Data Source
cell.tableView.deleteRows(at: selectedItems, with: .fade)
cell.tableView.endUpdates()
// Reloading sections accordingly depending on what the user has deleted
// Do I need to reload data here again? It used to work without Dispatch, but it wasn't stable
cell.tableView.reloadData()
// Updating items with if statements to reload data in Firebase
completion(true)
}
}
This doesn't reload the data at all and nothing seems to change. The good thing is that I don't get a random crash, which was the case before implementing Dispatch.main.async
I've retrieved the numberOfRows in each section to see how many rows there are after ending updates.
print(cell.tableView.numberOfRows(inSection: 1))
and I get the same number of rows that are in the current view.
This is crucial, because if the tableView sections are all zero, the collectionViewCell should disappear. And we never get here in the completion block as it says that the numberOfRows has never changed. Leaving us with a non updated tableView.
I solved this by moving Dispatch.main.async outside the function call.
Dispatch.main.async {
cleanItems(completion: { (success) in
etc.
})
}

App crashes after updating CoreData model that is being displayed in a UITableView

I have a strange bug that is extremely rare but causes the app to crash. I can't reproduce it but I finally found a crash report documenting this.
(I posted the stack trace below. I used a screenshot, as the quotes function here messed up the formatting. That would be unreadable)
So the problem begins after tapping a button, which calls the method closeButtonTapped.
This method is supposed to fade out a popup-view (called ExtendBitPopupView) and save the text the user entered (details attribute of one of my data models).
That's the closeButtonTapped method:
func closeButtonTapped(tap: UITapGestureRecognizer) {
fadeOut { // fadeOut(completion:) just fades out the UI
if self.infoTextView.text != "Enter details..." {
self.entry.info = self.infoTextView.text
self.appDelegate.saveContext()
}
}
}
So it takes the text the user entered and saves it as entry.info to the database.
Now, a little bit of context: The ExtendBitPopupView is a popup that fades in above a UITableView that displays all entry objects there are in the database. It's using a NSFetchedResultsController to manage the data. The table does not show the entry.info attribute. That is only visible inside the ExtendBitPopupView
According to the stack trace, the app crashes while calling the controllerDidChange method. I guess it calls this method because an entry has changed.
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
switch type {
case .insert:
tableView.insertRows(at: [newIndexPath!], with: .automatic)
case .delete:
tableView.deleteRows(at: [indexPath!], with: .automatic)
case .update: // I guess this case is being used
let cell = tableView.cellForRow(at: indexPath!) as! BitCell
let entry = fetchedResultsController.object(at: indexPath!)
cell.configure(entry: entry)
case .move:
tableView.deleteRows(at: [indexPath!], with: .automatic)
tableView.insertRows(at: [newIndexPath!], with: .automatic)
}
}
Line 224 is mentioned in the crash log. It's this line:
let cell = tableView.cellForRow(at: indexPath!) as! BitCell
I can't figure out why the app could crash at this moment. Also, it does work correctly 99% of the time.
My only observation is that when it happens, I typed in quite a lot of text. But I'm not sure about this, as it only happened like 3-4 times so far.
Does anyone have any ideas? I don't know what I can try and I don't know how to reproduce this bug.
If you need any more code, let me know. I just posted the code that is mentioned in the crash log.
Thanks in advance!
indexPath is the index BEFORE the deletes and inserts are applied; newIndexPath is the index AFTER the deletes and inserts are applied.
For updates you don't care where it was BEFORE the inserts and delete - only after - so use newIndexPath not indexPath. This will fix crashes that can happen when you an update and insert (or update and delete) at the same time.
For move the delegate is saying where it moved from BEFORE the inserts and where it should be inserted AFTER the inserts and deletes. This can be challenging when you have a move and insert (or move and delete). I fixed this by saving all the changes from controller:didChangeObject:atIndexPath:forChangeType:newIndexPath: into three different indexPath arrays: insert, delete and update. When you get a move add an entry for it in both the insert array and in the delete array. In controllerDidChangeContent: sort the delete array descending and the insert array ascending. Then apply the changes - first delete, then insert, then update. This will fix crashes that can happens when you have a move and insert (or move and delete) at the same time.
It is the same principle for sections. Save the sections changes in arrays, and then apply the changes in order: deletes (descending), sectionDelete (descending), sectionInserts (ascending), inserts(ascending), updates (any order). Sections can't move or be updated.

Resources