Avoid queue jumping
We are often told that no matter where we end up, we shouldn't forget where we came from. Usually, this applies to people, but it can also apply to code. Specifically, code that runs on a different queue from the queue it was scheduled on.
queue-jumping 🦘
It's not unusual to see queue-jumping throughout a project:
DispatchQueue.main.async { in
//Update UI
}
Even with the syntax improvements of using GCD
introduced in Swift 3, having these GCD calls littered in our code acts as an interruption to its normal flow making the code harder to read, debug and test.
The above queue-jumping code is littered throughout our code because a lot of the 3rd party code we interact with doesn't guarantee the queue their callbacks happen on. So if that callback is triggering a UI update, then we need to wrap that UI update code in a GCD block like shown above. Things can get messy very quickly.
However, in the code that we write, we can avoid this queue-jumping by ensuring that the queue any callbacks are called on is known and configurable.
The approach you will see below is similar to the
delegateQueue
parameter on theURLSession
init'er but with a defaulted parameter twist.
Capturing the queue 🕸️
For this example, I'm going to use an OperationQueue
to schedule work on a background queue in the form of Operation
instances.
Before we get into the code, it's important to note that an operation is executed in parts with each part being executed on different queues:
- Setup is performed on the caller's queue.
- Execution is performed on the queue the operation was added to.
So we can capture the caller's queue during setup:
class CapturingOperation: Operation {
private let completion: ((_ result: Result) -> ())?
private let callbackQueue: OperationQueue?
//MARK: - Init
init(callbackQueue: OperationQueue? = OperationQueue.current, completion: ((_ result: Result) -> ())?) {
self.completion = completion
self.callbackQueue = callbackQueue
super.init()
}
}
The above subclass of Operation
has two private properties and takes two parameters in it's init'er:
callbackQueue
is the queue which any callbacks will be executed on.callbackQueue
defaults to capture the queue which theCapturingOperation
instance was scheduled on however this behaviour can be overridden by the user of this class explicitly passing in a queue instance.completion
is the closure that will be triggered when this operation's work is completed. It uses theResult
enum type that allows us to encapsulate both the success and failure outcomes in one closure.
You may be thinking "Why use a custom completion closure when
Operation
comes with a completion closure?". Well first, thecompletionBlock
closure is executed on a random background queue even if we use the GCD queue switching technique above to push it onto a specific, and secondly, thecompletionBlock
closure is very limited in that you can't pass data back through the closure. Due to both of these limitations, I always use a custom closure.
Now that we have our completion closure and callback queued stored as properties, it's time to perform the task that the operation was created for:
class CapturingOperation: Operation {
// Omitted properties and methods
override func main() {
super.main()
//Operation's actual work happens here
let result = Result.success(true)
if let callbackQueue = callbackQueue {
callbackQueue.addOperation {
self.completion?(result)
}
} else {
completion?(result)
}
}
}
In the above main
method, once the operation's task is complete, the completion
closure is triggered. If the callbackQueue
is non-nil, the triggering of the completion
closure happens on that queue; if it's nil, then the completion
closure is triggered directly from the queue that the operation is running on.
With the queue-jumping happening inside the operation itself, we can safely remove any queue-jumping in the code that uses this operation - simplifying multiple call-sites at the expense of making triggering this operation's completion closure more complex. Spock would be proud.
Calling home has never been so easy
This approach makes a sensible assumption that the queue that you scheduled an operation from is the queue that you want that operation to be called back on. By using a defaulted parameter (callbackQueue
) in the init'er if that assumption proves to be false, the callback queue can be overridden with a more appropriate queue.
To see this project in action head over to the repo and clone the project.
What do you think? Let me know by getting in touch on Twitter - @wibosco