Nowadays most of our asynchronous code is handled via callbacks. But there are times where it’s a lot easier to simply wait for an asynchronous task to finish. My latest example of this was an NSOperation
subclass that needed to do some network communication before it could finish. I could have made that custom operation asynchronous but it was a lot simpler to just override -main
and wait for the network result.
What I needed was something akin to XCTestExpectation
for generic use. I needed an object, the expectation, I could pass to the asynchronous method (or capture in a block) and simply wait on it until it finished – meaning the expectation has been fulfilled.
How it works
I quite like NSCondition
. I use it a lot and it makes building this Expectation class pretty easy. So let’s start of with some state:
public class Expectation: NSObject {
public private(set) var isFulfilled = false
private let state = NSCondition()
}
We’ll use state
as our NSCondition
and isFulfilled
to flag wether or not this expectation has been fulfilled.
Onwards to the logic! Lets get fulfillments out of the way first:
public func fulfill() {
state.lock()
isFulfilled = true
state.broadcast()
state.unlock()
}
If you’ve used NSCondition
before this should look very familiar. We need to lock the condition first before altering our state and call broadcast to wake up all threads locked on our expectation.
Finally, we add the wait function:
public func wait(until limit: Date = Date.distantFuture) {
state.lock()
while !isFulfilled {
if Date() > limit {
break;
}
state.wait(until: limit)
}
state.unlock()
}
This is just the usual boiler plate for waiting on an NSCondition
as per the documentation.
Going the extra mile
So far we’ve built a simple but useful wrapper around NSCondition
but if we add just a couple of other features it can be much more powerful.
Custom conditional expression
In the custom operation example above it would be nice if while we’re waiting on the expectation we also handled the cancelation of the operation. We can accommodate this by augmenting the wait
method above with another parameter:
public func wait(until limit: Date,
while condition: @autoclosure () -> Bool = true) {
state.lock()
while condition() && !isFulfilled {
if Date() > limit {
break;
}
let nextTick = Date().addingTimeInterval(0.1)
state.wait(until: nextTick)
}
state.unlock()
}
Instead of just testing for isFulfilled
we also need to test for condition
. But for this to work we can’t just wait on the condition until the limit date like we did before or our while loop could block for the duration of allowed time. So we just wait for a small amount of time let the while loop run more often.
Using this new optional parameter in an NSOperation
subclass is short & sweet:
expectation.wait(until: timeout, while: !isCancelled)
The @autoclosure
annotation in the function declaration is what allows us to pass that !isCancelled
expression without explicitly creating a closure.
Running the Runloop
You probably know that XCTExpectation
doesn’t work exactly like what we’ve done so far. Namely, it needs to keep the runloop running to process events while it waits. This is super useful when writing tests but it’s not usually needed when waiting on a background thread.
Having said that, it’s quite easy to add support for this so we might as well do it:
public func wait(until limit: Date = Date.distantFuture,
runRunloop: Bool = false,
while condition: @autoclosure () -> Bool = true) {
state.lock()
while condition() && !isFulfilled {
if Date() > limit {
break;
}
let nextTick = Date().addingTimeInterval(waitGranularity)
if runRunloop {
state.unlock()
let ranRunloop = RunLoop.current.run(mode: .default, before: nextTick)
state.lock()
// if the runloop did not run at all, then wait on then condition to prevent a live loop
if !ranRunloop {
state.wait(until: nextTick)
}
}
else {
state.wait(until: nextTick)
}
}
state.unlock()
}
Above we added another optional parameter to control wether or not we should run the current runloop. If true, then instead of waiting on the condition, we unlock it1, run the runloop for a short amount of time and lock it again before continuing our while loop.
That’s a wrap
If you find this useful, check the accompanying repo on GitHub.
-
The condition’s wait method actually unlocks the condition and locks it again before returning. ↩︎