Combine Framework use case: Managing continuous updates and reloading of UITableView
In this article, I’ll discuss a common challenge encountered when dealing with rapidly changing data, such as Bluetooth status updates, and how the Combine framework can effectively address it.
The Issue
The task involves observing Bluetooth status updates, which can occur rapidly or not at all, and subsequently reloading UITableView cells based on these updates. The problem arises when attempting to reload the table view after receiving multiple notifications in quick succession, leading to application crashes.
Symptoms:
- Bluetooth status updates arrive from a separate thread.
- Reloading the tableView after each notification can lead to multiple updates within milliseconds, causing crashes.
To illustrate this issue, consider the following code snippet, which simulates the problem using a Timer.
Note: This is the code trying to mimic the issue faced in my production project.
//
// ViewController.swift
// CombineUseCase1
//
// Created by KPIT on 06/02/24.
//import UIKit
import Darwin
class ViewController: UIViewController {
private var bluetoothOnStatus: Bool = false
s
private var tableView: UITableView!
private var bluetoothConnectedFields = ["Device 1", "Device 2", "Device 3"]
private var bluetoothDisConnectedFields = ["Connect to bluetooth"]
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
// tableView
tableView = UITableView.init(frame: .zero)
tableView.dataSource = self
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
tableView.translatesAutoresizingMaskIntoConstraints = false
self.view.addSubview(tableView)
NSLayoutConstraint.activate([
tableView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
tableView.topAnchor.constraint(equalTo: self.view.topAnchor, constant: 0),
tableView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor)
])
Timer.scheduledTimer(withTimeInterval: 0.0001, repeats: true) { [weak self] timer in
DispatchQueue.global().async {
// bluetooth update status is coming in this thread.
self?.bluetoothOnStatus = Bool.random()
}
// tableView need to be reloaded from main thread.
self?.tableView.reloadData()
}
}
}
extension ViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
if bluetoothOnStatus == true {
return bluetoothConnectedFields.count
} else {
return bluetoothDisConnectedFields.count
}
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
if bluetoothOnStatus == true {
let fieldName = bluetoothConnectedFields[indexPath.row]
cell.textLabel?.text = fieldName
} else {
let fieldName = bluetoothDisConnectedFields[indexPath.row]
cell.textLabel?.text = fieldName
}
return cell
}
}
Explanation:
bluetoothOnStatus
: A boolean variable tracking Bluetooth connection status.- Arrays holding data to be displayed in the tableView based on the Bluetooth status.
- Setup of the tableView and its dataSource.
- Use of Timer to mimic real-time updates.
- TableView dataSource methods determining which array to use based on
bluetoothOnStatus
.
Running this code multiple times will result in crashes due to index out-of-range errors, as the Bluetooth status can change between checking the status and updating the tableView cell.
Now if run this code multiple time you will observe the crash at this line
let fieldName = bluetoothDisConnectedFields[indexPath.row]
or in this line
let fieldName = bluetoothConnectedFields[indexPath.row]
and Crash message will be Fatal error: Index out of range
So at method numberOfRowsInSection
the bluetooth status was different and changed from different thread before the cell was prepared in method func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell
Solution Approach
To address this issue:
- Ensure Bluetooth status updates and tableView reloads occur on the same thread, but don’t change the
DispatchQueue.global().async
code inside timer as it tries to create the issue. - Avoid excessive tableView reloads for performance reasons.
We’ll achieve this using Combine framework.
Implementation
Replace private var bluetoothOnStatus: Bool = false
with private var isBluetoothConnectedPublisher = CurrentValueSubject<Bool, Never>(false)
to use a CurrentValueSubject publisher for tracking Bluetooth status.
Create a new variable private var subscriptions: Set<AnyCancellable> = []
to hold subscriptions to the isBluetoothConnectedPublisher
.
Implement a function bindPublisher()
to bind the publisher to the tableView reload operation.
Modify the Timer code to update the Bluetooth status via the isBluetoothConnectedPublisher
.
Call the bindPublisher()
function from viewDidLoad()
.
Updated Code Snippets:
// Inside ViewController: below viewDidLoad()
private func bindPublisher() {
isBluetoothConnectedPublisher
.throttle(for: 0.1, scheduler: DispatchQueue.main, latest: true)
.sink(receiveValue: { [weak self] isConnected in
self?.tableView.reloadData()
}).store(in: &subscriptions)
}
and Modify the code inside timer as
// Inside ViewController: update timer code
Timer.scheduledTimer(withTimeInterval: 0.0001, repeats: true) { [weak self] timer in
DispatchQueue.global().async {
// bluetooth update status is coming in this thread.
self?.isBluetoothConnectedPublisher.send(Bool.random())
}
}
Above bluetooth is status is updated from background thread only but now using Combine CurrentValueSubject.
Finally call the bindPublisher()
function from viewDidLoad()
just above the timer code.
Now run the application the crash is gone, as we are not taking updates every time its coming we are using the throttle operator to take update that came within 0.1 seconds you can adjust the time as per your requirement.
Bonus: Ignoring Duplicate Updates
To further optimize, use the removeDuplicates()
operator to ignore duplicate status updates. This reduces unnecessary tableView reloads.
private func bindPublisher() {
isBluetoothConnectedPublisher
.removeDuplicates()
.throttle(for: 0.1, scheduler: DispatchQueue.main, latest: true)
.sink(receiveValue: { [weak self] isConnected in
self?.tableView.reloadData()
}).store(in: &subscriptions)
}
If you would like to test and want to know how this removeDuplicates
operator work
please go through this article and you can try the below code in playground for better understanding.
import Combine
import UIKit
let publisher = CurrentValueSubject<Bool, Never>(false)
var subscription = Set<AnyCancellable>()
for index in 0...10 {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
let value = Bool.random()
print("value sent:\(value)")
publisher.send(value)
}
}
publisher
.removeDuplicates()
.sink { value in
print("value received:\(value)") // 1,3,6,10,15,21,28,36,45,55
}
.store(in: &subscription)
If you found this article insightful and want to stay updated on more iOS development tips and tricks, consider following me on:
Your support and connection are greatly appreciated, and I look forward to sharing more valuable insights with you!.