Modern application development with Swift involves a lot of asynchronous (or "async") programming using closures and completion handlers, but these APIs are hard to use. This gets particularly problematic when many asynchronous operations are used, error handling is required, or control flow between asynchronous calls is required. Using the new concurrency model makes this a lot more natural and less error-prone.
With the Appwrite 0.14 release, the Apple and Swift SDKs have been updated with first-class support for async/await. To understand why this is important, and see how to implement the changes, let's make a comparison between the old and new APIs.
🏚️ The Old Way
Async programming with explicit callbacks (also referred to as closures or completion handlers) has many problems. The most obvious issue is that they make code more difficult to read and maintain. They also make it hard to write testable code. Let's take a look at how some of those problems manifest:
Problem 1: Pyramid of Doom
A sequence of simple asynchronous operations often requires deeply-nested closures:
func getUserProfileImage(userId: String, completion: (Result<ByteBuffer, AppwriteError>) -> Void) {
let database = Database(self.client)
let storage = Storage(self.client)
database.getDocument(collectionId: "profiles", userId: userId) { docResult in
switch docResult {
case .success(let document):
storage.getFileDownload(bucketId: "profiles", fileId: document.data["profileImageId"]) { fileResult in
switch result {
case .success(let bytes):
completion(.success(bytes))
}
}
}
}
}
This "pyramid of doom" makes it difficult to read and keep track of where the code is running. In addition, having to use a stack of closures leads to many second-order effects that we will discuss next.
Problem 2: Error Handling
Looking at the above example - we are only handling success cases. What if the user doesn't exist? What if the user has no profile image? Callbacks make error handling difficult and very verbose:
func getUserProfileImage(userId: String, completion: (Result<ByteBuffer, AppwriteError>) -> Void) {
let database = Database(self.client)
let storage = Storage(self.client)
database.getDocument(collectionId: "profiles", userId: userId) { docResult in
switch docResult {
case .success(let document):
storage.getFileDownload(bucketId: "profiles", fileId: document.data["profileImageId"]) { fileResult in
switch result {
case .success(let bytes):
completion(.success(bytes))
case .failure(let error):
completion(.failure(error))
}
}
case .failure(let error):
completion(docResult)
}
}
}
Problem 3: Missing completion calls or return statements
It's easy to bail-out of the asynchronous operation early by simply returning without calling the completion block. When forgotten, this issue can be very hard to debug:
func getUserProfileImage(userId: String, completion: (Result<ByteBuffer, AppwriteError>) -> Void) {
let database = Database(self.client)
let storage = Storage(self.client)
database.getDocument(collectionId: "profiles", userId: userId) { docResult in
switch docResult {
case .success(let document):
// ...
case .failure(let error):
return // <- Bail out early
}
}
}
Now if you call getUserProfileImage
and an error occurs, the function will never complete and the call site code will be deadlocked. Even when you do remember to call the block, you can still forget to return after that:
func getUserProfileImage(userId: String, completion: (Result<ByteBuffer, AppwriteError>) -> Void) {
let database = Database(self.client)
let storage = Storage(self.client)
database.getDocument(collectionId: "profiles", userId: userId) { docResult in
switch docResult {
case .success(let document):
// ...
case .failure(let error):
completion(.failure(error)) // <- No return, execution continues
}
doSomethingElse(userId) // <- Executes on error
}
}
✨ The New Way
Asynchronous functions (async/await) allow asynchronous code to be written as if it were synchronous code. This immediately addresses all the problems described above by allowing programmers to make full use of the same language constructs that are available to synchronous code.
Solution 1: No More Pyramid of Doom
The control flow simply reads from the top down, with no nesting:
func getUserProfileImage(userId: String) async throws -> ByteBuffer {
let database = Database(self.client)
let storage = Storage(self.client)
let document = try await database.getDocument(
collectionId: "profiles",
userId: userId
)
let bytes = try await storage.getFileDownload(
bucketId: "profiles",
fileId: document.data["profileImageId"]
)
return bytes
}
As you can see, this code is much more readable, concise and easier to reason about, while performing the same operations as before.
Solution 2: Do/catch error handling
Errors can be handled using the do/catch
syntax, the same way as synchronous code:
func getUserProfileImage(userId: String) async throws -> ByteBuffer {
let database = Database(self.client)
let storage = Storage(self.client)
do {
let document = try await database.getDocument(
collectionId: "profiles",
userId: userId
)
let bytes = try await storage.getFileDownload(
bucketId: "profiles",
fileId: document.data["profileImageId"]
)
} catch {
throw error
}
return bytes
}
Solution 3: Completions and nested returns are gone completely
The solution to the third problem is to now... do nothing. We no longer have to worry about missing completion handler calls or nested returns using async/await code.
🔮 Future Possibilities
Async code has the potential to change how we develop apps with Swift. Overall, code will become much more readable and robust, with less effort required to implement (increasingly) common async patterns. I am excited to see what developers can create with async/await.
📚 Resources
You can use the following resources to learn more and get help.
Top comments (0)