I have some hard to solve crashes. They happen in application from time to time not in regular way. I think the problem maybe with some race conditions and synchronizations.
I am using such pattern.
1) reloadData()
2) reloadSection1(), reloadSection2(), reloadSection2(), etc.
3) I have tap events that can do reload like reloadData()
4) There are also Socket.IO messages that can cause reloadData(), reloadSectionN()
5) I try to use debouncers to execute only last reload of given type.
6) debouncers use Serial Queue to execute task serially one by one in case that request-response-reload last longer then new socket.io event arrives
7) section/table reloadin must happen on UI thread so at the end I moves to it using DispatchQueue.main.async { }
8) I have even tried to wrap data excesses/ reloads into semaphores to block modification by other threads.
But errors can happens and app can crash in spite of this. And I have no idea what may cause it.
Below I place the most important parts of code.
I have such instance properties:
private let debouncer = Debouncer()
private let debouncer1 = Debouncer()
private let debouncer2 = Debouncer()
private let serialQueue = DispatchQueue(label: "serialqueue")
private let semaphore = DispatchSemaphore(value: 1)
Here Debouncer.debounce instance method
func debounce(delay: DispatchTimeInterval, queue: DispatchQueue = .main, action: @escaping (() -> Void) ) -> () -> Void {
return { [weak self] in
guard let self = self else { return }
self.currentWorkItem?.cancel()
self.currentWorkItem = DispatchWorkItem {
action()
}
if let workItem = self.currentWorkItem {
queue.asyncAfter(deadline: .now() + delay, execute: workItem)
}
}
}
Here are debounced reloads of table view and its sections
func debounceReload() {
let debounceReload = debouncer.debounce(delay: .milliseconds(500), queue: serialQueue) {
self.reloadData()
}
debounceReload()
}
func debounceReloadOrders() {
let debounceReload = debouncer1.debounce(delay: .milliseconds(500), queue: serialQueue) {
self.reloadOrdersSection(animating: false)
}
debounceReload()
}
This debounce methods can be invoked on tap, pull to refresh screen navigation, or Socket.IO events (here possible multiple events at the same time if there are multiple users).
Each reload called by debounce reload start and ends with such methods (in between there are synchronous requests to remote api that may take some time (and are executed on this serialqueue). All debouncers reuse the same serial queue (so they should not conflict/race with each other and caused data inconsistency while reloading tableview or its sections).
private func startLoading() {
print("startLoading")
activityIndicator.startAnimating()
activityIndicator.isHidden = false
tableView.isHidden = true
// cancel section reload
debouncer1.currentWorkItem?.cancel()
debouncer2.currentWorkItem?.cancel()
}
private func stopLoading() {
guard debouncer.currentWorkItem?.isCancelled != true else { return }
self.semaphore.wait()
print("stopLoading")
tableView.reloadData()
activityIndicator.isHidden = true
refreshControl.endRefreshing()
tableView.isHidden = false
self.semaphore.signal()
}
Above are added this additional semaphores as additional check to ensure data consistency.
func startLoading(section: Int, animating: Bool = true) {
self.semaphore.wait()
print("startLoading section \(section)")
tableView.beginUpdates()
if animating {
self.data[section] = .loadingSpinner
}
tableView.reloadSections([section], with: .none)
tableView.endUpdates()
self.semaphore.signal()
}
func stopLoading(section: Int, model: Model) {
self.semaphore.wait()
print("stopLoading section \(section)")
if section == 0 {
guard debouncer1.currentWorkItem?.isCancelled != true else { return }
} else if section == 1 {
guard debouncer2.currentWorkItem?.isCancelled != true else { return }
}
tableView.beginUpdates()
self.data[section] = model
tableView.reloadSections([section], with: .none)
tableView.endUpdates()
self.semaphore.signal()
}
private func clearData() {
self.semaphore.wait()
print("clearData")
data.removeAll()
self.semaphore.signal()
}
I think this extra semaphores should not be required as this is using serial queue so all request-response-reload are executed on serial queue. Maybe some problem is that I need to switch from serial queue to main queue in order to clear/add spinner-reload and then fill-data/reload table or section. But I think it should last shorter then next data replacement happens. I consider moving self.data.append(model1) to semaphore critical section in stopLoading() for reloadData() using self.data = data assignment in this critical section.
Example error that I have encountered:
Fatal Exception: NSInternalInconsistencyException Invalid update: invalid number of rows in section 0. The number of rows contained in an existing section after the update (3) must be equal to the number of rows contained in that section before the update (5), plus or minus the number of rows inserted or deleted from that section (0 inserted, 0 deleted) and plus or minus the number of rows moved into or out of
0x195bef098 +[_CFXNotificationTokenRegistration keyCallbacks]
3 Foundation 0x1966b2b68 -[NSAssertionHandler handleFailureInMethod:object:file:lineNumber:description:]
4 UIKitCore 0x1c23ecc78 -[UITableView _endCellAnimationsWithContext:]
5 UIKitCore 0x1c24030c8 -[UITableView endUpdates]
6 MyApplication 0x104b39230 MyViewController.reload(section:) + 168
> that section (0 moved in, 0 moved out).
I have also seen errors on cellForRow function, this errors happen several times a week, app is rather used multiple times a day so it is hard to repeat this error. I have tried to send simple refreshing sockets from POSTman but they do not change underling data (row count) and I think is way everything works then ok.
UPDATE I have updated stopLoading() to stopLoading(data:) to have datasource update and tableView.reload on main queue. So all startLoading, stopLoading, reload methods are performed on DispatchQueue.main.async { }.
private func stopLoading(data: [Model]) {
guard debouncer.currentWorkItem?.isCancelled != true else { return }
self.semaphore.wait()
print("stopLoading")
self.data = data
tableView.reloadData()
activityIndicator.isHidden = true
refreshControl.endRefreshing()
tableView.isHidden = false
self.semaphore.signal()
}