Wrapping Existing Async and Closure Based code in async-await structure (Advanced Swift Topic)
If you haven't yet read couple of my posts that introduce a new async-await construct in Swift, below are the links you can follow to know more about it
With the introduction of async-await
construct in Swift 5.5, it's easier to structure your code with simply async-await keyword and eliminate bug-prone delegate or closure-based callback mechanism. This has the added benefit of not only making your code readable but the resulting code is also cleaner and free from bugs.
The Swift community strongly recommends using async-await construct for new projects as well as wherever iOS and Swift versions allow, refactor the legacy async code handling to use new APIs. In order to make it easy for developers to migrate to these new APIs, Apple has provided async-await variants of many of their existing APIs so that the developers don't have to write it on their own.
However, there are still some APIs that haven't been converted to use async-await yet. In such cases, what do you do? Don't convert them to async-await at all? What are our options? How do we handle this legacy code then? Is there any support Apple provides to make this migration easy?
The answer is async Alternatives and Continuations. With these APIs, you can wrap the existing legacy asynchronous code - Whether it is closure-based or delegate-based to use the async-await construct so that the caller won't know they use legacy code under the hood. Let's see how to use them with examples - One with closure and the other with delegate callbacks.
- Converting closure-based legacy asynchronous code to use async-await construct
- Converting delegate-based legacy asynchronous code to use async-await construct
Converting closure-based legacy asynchronous code to use async-await construct
I have the following code that uses a closure-based API to perform the asynchronous operations. Since this is a third-party API, I cannot modify it by hand to directly use async-await
, but I also don't want to wait forever until someone converts this API to use modern syntax.
override func viewDidLoad() {
super.viewDidLoad()
performExpensiveOperation(id: 100) { output in
print("Operation successful with result \(output)")
}
}
func performExpensiveOperation(id: Int, completion: (String) -> Void) {
ThirdPartyUtility.performOperation(with: id, completion: completion)
}
Please note how performOperation
resides in the third-party code and there is no way for client to directly modify it by hand
With the Swift API, withCheckedThrowingContinuation
we can easily add a wrapper to use async-await
on top of closure-based async code.
withCheckedThrowingContinuation
API returns a continuation
object of type CheckedContinuation
. This object provides a resume
function into which we can either place a result of the successful operation or an error if the operation fails.
resume
API also provides a missing link to resume the suspended awaitable
object returned by the withCheckedThrowingContinuation
API. As soon as the closure returns either an error or a result, we call the resume method which in turn returns the result or the error back to the waiting caller of async
function.
With these concepts clear, let's write a wrapper around the legacy function to use async-await
API to perform async tasks,
func performExpensiveOperation(id: Int) async throws -> String {
return try await withCheckedThrowingContinuation { continuation in
ThirdPartyUtility.performOperation(with: id) { output, error in
if let error = error {
continuation.resume(throwing: error)
} else {
continuation.resume(returning: output)
}
}
}
}
override func viewDidLoad() {
super.viewDidLoad()
Task(priority: .medium) {
do {
let output = try await performExpensiveOperation(id: 100)
} catch {
print("Error Occurred \(error.localizedDescription)")
}
}
}
Converting delegate-based legacy asynchronous code to use async-await construct
Using async-await
construct is not limited to legacy closure-based APIs. You can also use them to replace legacy delegate-based APIs. Let's take a look at a similar legacy example as before, but this example uses delegates instead of closures to perform the async operations.
protocol ExpensiveOperationPerformable: AnyObject {
func didSucceed(with output: String)
func didFail(with error: Error)
}
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
performExpensiveOperation(id: 100)
}
func performExpensiveOperation(id: Int) {
let thirdPartyUtility = ThirdPartyUtility()
thirdPartyUtility.delegate = self
thirdPartyUtility.performOperation(with: id)
}
}
extension ViewController: ExpensiveOperationPerformable {
func didSucceed(with output: String) {
print("Succeeded with output \(output)")
}
func didFail(with error: Error) {
print("Failed with error \(error.localizedDescription)")
}
}
In the above example, before calling performOperation
method in third-party utility, we set up the current class as its delegate. Once the third-party finishes doing heavy work, it will call the delegate methods - Either didSucceed
or didFail
depending on whether the data loading operation succeeded or failed.
Similar to the previous example, we will use withCheckedThrowingContinuation
API to migrate to using async-await
. Since the delegate callback is scattered in the file, we will store the object of type CheckedContinuation
in the local variable and use its resume method to resume the suspended async task with either error or a returned result when respective delegate methods are called after the operation succeeds.
class ViewController: UIViewController {
var activeContinuation: CheckedContinuation<String, Error>?
....
...
..
override func viewDidLoad() {
super.viewDidLoad()
Task(priority: .medium) {
do {
let result = try await performExpensiveOperation(id: 100)
} catch {
print("Error Occurred \(error.localizedDescription)")
}
}
}
func performExpensiveOperation(id: Int) async throws -> String {
let thirdPartyUtility = ThirdPartyUtility()
return try await withCheckedThrowingContinuation { continuation in
self.activeContinuation = continuation
thirdPartyUtility.delegate = self
thirdPartyUtility.performOperation(with: id)
}
}
}
extension ViewController: ExpensiveOperationPerformable {
func didSucceed(with output: String) {
activeContinuation?.resume(returning: output)
}
func didFail(with error: Error) {
activeContinuation?.resume(throwing: error)
}
}
Caveats
Although this is an extremely useful API to convert legacy APIs to use modern syntax, there are a few caveats you should be mindful of before starting using it.
CheckedContinuation instance must call the resume
with the result or the error of the operation
withCheckedThrowingContinuation
method returns an instance of CheckedContinuation
. When the completion handler completes with the result or the delegate methods are called either with the result or the error of the operation, we must call the result
method associated with CheckedContinuation
object. If this method is not called, the calling function will suspend forever blocking the execution and wasting system resources.
Make sure the resume
method is called under any circumstances. If you aren't sure about the result value to pass to this method, either pass the default value or raise an error. If async
method returns without calling resume
on CheckedContinuation
object, the console will throw a warning with the following message,
SWIFT TASK CONTINUATION MISUSE: <your_function_name> leaked its continuation!
<project_name>[9735:622352] SWIFT TASK CONTINUATION MISUSE: <your_function_name> leaked its continuation!
If you see a message like this, review the code again and make sure you're callingresume
onCheckedContinuation
object in all the code paths
Don't call the resume
method multiple times
In order to prevent app data from corrupting, you must not call resume
method on CheckedContinuation
object more than once. If you end up calling this method multiple times from the same code block, the Swift runtime will throw a fatal error at the second entry point halting the program execution.
Referring to our delegate example above, we can prevent this from happening by setting activeContinuation
object to nil
as soon as the resume
method is called.
extension ViewController: ExpensiveOperationPerformable {
func didSucceed(with output: String) {
activeContinuation?.resume(returning: output)
activeContinuation = nil
}
func didFail(with error: Error) {
activeContinuation?.resume(throwing: error)
activeContinuation = nil
}
}
Summary
So that was all about a painless way to convert legacy asynchronous code to use async-await
API. My initial qualms and excuses about using this API were how am I doing to make the legacy code outside of my control to use this new construct.? But as soon as I came to know about this API, I breathed a sigh of relief and it pulverized all the doubts.
It's extraordinary and a bit surprising to see Apple taking care of this for developers and pushing them to use new API. The interface is straightforward, the readability is great, and the WWDC talk was full of related examples and deep explanations on how to use it and its benefits.
While you may be tempted to use this API, also pay attention to a couple of caveats I mentioned at the end of this article. Make sure to always call the resume
method and call it just once for a given case of either success or error. In case you still miss them, Apple still warns you about failing to follow these guidelines so they won't make it in the production code.
That's all from me on this topic. If you still have any questions, feedback, or comment about this article, feel free to reach out to me on Twitter @jayeshkawli.