Published on Dec 27, 2018 in #swift
Today I wanted to look at using DispatchSourceUserDataAdd
to coalesce calls to (potentially expensive) functions like UITableView.reloadData()
.
If you want to follow along in Xcode, feel free to copy/paste this gist into an iOS Playground.
The example follows the patterns described in Apple’s documentation on Model-View-Controller.
Our “Model” layer is compromised of the TodoList
and TodoListItem
classes. In reaction to user input, we can mark items as completed by setting the TodoListItem.completed
property to true
. And, we can listen for changes by observing the TodoListItemUpdated
notification.
class TodoList {
static let shared = TodoList()
var items: [TodoListItem] = [ /*...*/ ]
// ...
}
extension NSNotification.Name {
static let todoListItemUpdated = NSNotification.Name("TodoListItemUpdated")
}
class TodoListItem {
let title: String
var completed: Bool = false {
didSet {
NotificationCenter.default.post(name: .todoListItemUpdated, object: self)
}
}
// ...
}
Our “Controller” layer is a subclass of UITableViewController
. It gets data from our model (TodoList
) and displays it in a table view.
class ViewController: UITableViewController {
// ...
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return TodoList.shared.items.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
let item = TodoList.shared.items[indexPath.row]
// configure cell ...
return cell
}
// ...
}
When a user taps an item, we toggle its completed
property.
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
TodoList.shared.items[indexPath.row].completed.toggle()
}
In turn, the TodoListItem
posts the TodoListItemUpdated
notification, and our view controller reloads our table view, updating the UI for the user.
class ViewController: UITableViewController {
override func viewDidLoad() {
NotificationCenter.default.addObserver(
self,
selector: #selector(todoListItemUpdated),
name: .todoListItemUpdated,
object: nil
)
// ...
}
@objc func todoListItemUpdated() {
tableView.reloadData()
}
}
And everything works great. Since our ViewController
observes changes to our model layer directly (via NotificationCenter
), we never have to worry about our UI getting out of sync with our data.
As a convenience to our users, we decide to add a shortcut to mark all items as completed. Firstly, we update our TodoList
class with a function to mark all items completed.
class TodoList {
// ...
func markAllAsCompleted() {
for item in items {
item.completed = true
}
}
}
And wire up a button to call markAllAsCompleted()
.
class ViewController: UITableViewController {
override func viewDidLoad() {
navigationItem.rightBarButtonItem = UIBarButtonItem(
title: "Complete All",
style: .plain,
target: self,
action: #selector(completeAll)
)
// ...
}
@objc func completeAll() {
TodoList.shared.markAllAsCompleted()
}
// ...
}
Since our ViewController
observes changes to our model via NotificationCenter
, it automatically updates the UI in response to the user tapping the ‘Complete All’ button.
Some of you may have already noticed one small issue.
markAllAsCompleted()
function sets completed = true
on all of our TodoListItem
sTodoListItem
posts a TodoListItemUpdated
notificationTodoListItemUpdated
notification, our ViewController
calls tableView.reloadData()
To confirm this, I’ve added a print
statement when tableView.reloadData()
is called.
Sure enough! Tapping ‘Complete All’ printed out five messages, one for each item we have.
Perhaps not a big issue if we have one, two, or even five items, but it could be if we have fifty or one hundred items. While our code is simple and fairly straightforward, we are unnecessary calling tableView.reloadData()
a lot! We should take a look into this before shipping our app.
Ideally we want to coalesce all of changes into a single call to tableView.reloadData()
. Thankfully, Grand Central Dispatch provides us the perfect tool for the job - DispatchSourceUserDataAdd
.
Dispatch Sources are special objects that schedule blocks to run when certain low level events happen. For example, DispatchSourceSignal
will enqueue a block on a given DispatchQueue
when the current process receives certain Unix signals. As many of these are wrapped up in higher level APIs in Foundation.framework
or elsewhere, it’s pretty rare to use Dispatch Sources directly. However, there are a couple Dispatch Sources that are quite useful for us:
DispatchSourceUserDataAdd
DispatchSourceUserDataOr
DispatchSourceUserDataReplace
- at the time of this writing, this one isn’t documented in the Objective-C documentationAll three of these operate in roughly the same way:
UInt
user datafunc add(data: UInt)
- increments the user data by data
func merge(data: UInt)
- bitwise ORs the user data with data
func replace(data: UInt)
- sets the user data to data
Of particular interest to us is the fact that a block is only enqueued when the user data becomes non-zero, not each time it is updated.
DispatchSourceUserDataAdd
Despite being advertised as “low-level”, DispatchSourceUserDataAdd
is fairly straightforward. First we create one via the DispatchSource.makeUserDataAddSource(queue:)
function. We’ll put this in our ViewController
class.
class ViewController: UITableViewController {
let dispatchSource = DispatchSource.makeUserDataAddSource(queue: .main)
// ...
Then we need to set an event handler, a.k.a. the block that will be queued when the user data becomes non-zero.
After that, we’ll call dispatchSource.resume()
to “start” the dispatch source (by default, dispatch sources started in a suspended state)
class ViewController: UITableViewController {
let dispatchSource = DispatchSource.makeUserDataAddSource(queue: .main)
override func viewDidLoad() {
dispatchSource.setEventHandler(handler: { [weak self] in
print("tableView.reloadData()")
self?.tableView.reloadData()
})
dispatchSource.resume()
// ...
}
Finally, we update our todoListItemUpdated()
function to use the dispatch source instead of calling tableView.reloadData()
directly.
class ViewController: UITableViewController {
// ...
@objc func todoListItemUpdated() {
// Previously:
//
// print("tableView.reloadData()")
// tableView.reloadData()
dispatchSource.add(data: 1)
}
Let’s give this a go in Xcode…
Perfect! Our code only called tableView.reloadData()
once, despite getting five TodoListItemUpdated
notifications.
I’ve put the final code up in this gist.
Before using this elsewhere in our app, we may want to extract this into an extension, base class, or even another class to make it a little more ergonomic. Rather than calling a function like add(data: 1)
to signal a reloadData()
is needed, it’d be much nicer to call a function named setNeedsReloadData()
or similar.
Also, depending on our use cases, we might want to take a look at DispatchSourceUserDataOr
. Since it combines data together via a bitwise OR, we could use a bitmask to signal different types of updates are needed, rather than doing a full reloadData()
each time.
Finally, I’d encourage you to read over Apple’s Dispatch documentation, Grand Central Dispatch has quite a few interesting APIs in it.