The Great Callback Hell Bake-Off | Ellen Shapiro
Working with asynchronous code on iOS when using closures to deal with completion blocks can lead to a tremendous amount of nested callbacks. This is a lovely style of code known as Callback Hell.
NOTE: Code which has been simplified for this blog post can be found at https://github.com/designatednerd/OperationComparison. If you'd rather watch a video of a talk based on this article, it's at Swift.Amsterdam.
For example, let's say you have several asynchronous operations: One which fetches a user, one which fetches an image from a given URL, and one which resizes a passed-in image. The basic method signatures would look something like this:
enum Result<T> {
case success(_ item: T)
case error(_ error: Error)
}
func fetchUser(completion: @escaping (Result<User>) -> Void) {
...
}
func fetchImage(for user: User, completion: @escaping (Result<UIImage>) -> Void) {
...
}
func resizeImage(_ image: UIImage, to size: CGSize, completion: @escaping (Result<UIImage>) -> Void) {
...
}
If you just want to call a single one of these methods, the code isn't too bad:
fetchUser { [weak self] result in
switch result {
case .success(let user):
self?.usernameTextField = user.username
case .error(let error):
debugPrint("Error fetching user: \(error)")
}
}
You have a fairly clear idea of what you're requesting, and what you're getting back.
The problem comes when you want to chain several of these asynchronous methods together. Here, just using callbacks, is the code to fetch a user, get their avatar, then resize their avatar:
fetchUser { userResult in
switch userResult {
case .success(let user):
fetchImage(from: user) { [weak self] imageResult in
switch imageResult {
case .success(let image):
guard let self = self else { return }
resizeImage(image, to: self.imageView.frame.size) { resizedImageResult in
switch resizedImageResult {
case .success(let resizedImage):
self?.imageView.image = resizedImage
case .error(let error):
debugPrint("Error resizing image: \(error)")
case .error(let error):
debugPrint("Error fetching image: \(error)")
}
}
case .error(let error):
debugPrint("Error fetching user: \(error)")
}
}
}
This deep nesting of callbacks is what's known as Callback Hell. It's awful to read and can be an enormous pain to debug, so iOS developers are always looking for ways to be able to chain asynchronous operations more clearly.
There are a few solutions possible to this problem, all of which have their trade-offs. There are three I'll cover here, each involving a larger investment of time to learn something you haven't work with before, but each giving you more power.
Important Caveat!
Most of this article was written before Apple's Combine reactive framework was announced at WWDC 2019, so it does not cover that framework. Looking forward to messing around with it over the summer though!
Solution #1: Custom infix
Operators
infix
In Swift, custom operators are operators which are defined by the developer. An
infix
let amount = 2 + 3
+
infix
Declaring something an
infix
Vincente Pradilles gave a great lightning talk at NSSpain where he used his functional programming skillz to come up with a really interesting solution to this.
In Swift, functions are first-class types. This means can be both parameters and return values from functions. Normally, this is used as you saw in the callback hell examle - to allow passing a completion type.
However, you can also define a particular operator, and pass a function that takes a completion closure to another function which takes the result of the first closure and a second completion closure - returing a third closure function:
infix operator -->: MultiplicationPrecedence
func --><T,U>(_ firstFunction: @escaping (@escaping (Result<T>) -> Void) -> Void,
_ secondFunction: @escaping (T, @escaping (Result<U>) -> Void) -> Void)
-> (@escaping (Result<U>) -> Void) -> Void {
return { completion in
firstFunction { result in
switch result {
case .success(let item):
secondFunction(item) { result2 in
completion(result2)
}
case .error(let error):
completion(.error(error))
}
}
}
}
At first glance, this looks like a salad of generics and functions. What on earth is going on here? First, let's look at the function signature, which is by far the most complicated part.
Overriding the
-->
Giving this override generic types of
T
U
The
firstFunction
Void
(Result<T>) -> Void
func fetchUser(completion: (Result<User>) -> Void)
The
secondFunction
Void
T
U
The
fetchImage
User
(Result<UIImage) -> Void
Now, we can create a chained equation:
let chain = fetchUser -> fetchImage
The
chain
(@escaping (Result<UIImage>) -> Void)) -> Void
func chain(completion: @escaping(Result<UIImage>) -> Void))
chain
Now, you can call
chain
chain({ [weak self] result in
switch result {
case .success(let userImage):
// TODO: Get resized image?
case .error(let error):
debugPrint("Error occurred: \(error)")
}
})
This is a great place to start. One key problem with this method though: If you want to chain multiple functions, they always have to have a very similar signature. This doesn't help us with resizing the image, because that method takes three parameters: The image to resize, the size to make it, and the completion closure.
In order to make that work with chaining, you have to shoehorn it into another method with the same signature, presumably in the view controller where you wish to display it and where you have access to the size of the view where it's being displayed:
private func resizeImageForImageView(_ image: UIImage,
completion: @escaping (Result<UIImage>) -> Void) {
resizeImage(image, to: self.imageView.frame.size, completion: completion)
}
Once that's done, you can do this:
let chain = fetchUser -->
fetchImage -->
self.resizeImageToFitImageView
The
(@escaping (Result<UIImage>) -> Void)) -> Void
-->
(@escaping (Result<UIImage>) -> Void)) -> Void
chain
You can still call
chain
chain({ [weak self] result in
switch result {
case .success(let resizedImage):
self?.imageView.image = resizedImage
case .error(let error):
debugPrint("An error occurred: \(error)
}
})
You can lose some context around exactly where an error occurred with this method, but if you don't really care in the end, this is a really, really helpful model.
Custom infix
TL;DR:
infix
Advantages
No need for a third party framework
You get to feel like a magician with functional programming
If it doesn't do what you need, just override the operator function again.
Disadvantages
Method signatures can get really ugly and hard to parse
Locks you in to a particular style of completion closure unless you do a ton of overrides
No way of accounting for threading in this model
No way to cancel chains once they're in-flight
Not necessarily something other developers will have worked with before.
Solution #2: PromiseKit
PromiseKit is one of several Swift implementations of the Future/Promise pattern, a concept which allows you to declare what will happen when a specified asynchronous operation completes in a way that's more chainable and easier to read.
A
Promise
With
PromiseKit
(Result<T>) -> Void
Promise<T>
It's reasonably easy to wrap an existing which takes a completion closure with a promise. For example, fetching the user returns something like this:
func fetchUser() -> Promise<User> {
return Promise { seal in
fetchUser { // <-- existing method
switch result {
case .success(let user):
seal.fulfill(user)
case .error(let error):
seal.reject(error)
}
}
}
}
You create the promise, which will be kicked off eventually by
PromiseKit
seal
PromiseKit
fulfill
reject
If you wrap all three methods in this example with
Promise
func fetchUser() -> Promise<User> {
...
}
func fetchImage(for user: User) -> Promise<UIImage> {
...
}
func resizeImage(_ image: UIImage, to size: CGSize) -> Promise<UIImage> {
...
}
Already, this is a bit easier to read and understand, because we've abstracted away the
Next, you can use a bunch of built-in methods in
PromiseKit
fetchUser()
.then { user in
fetchImage(for: user)
}
.then { [weak self] image -> Promise<UIImage> in
guard let self = self else { throw GenericException.selfWasDeallocated }
return resizeImage(image, to: self.imageView.frame.size)
}
.done { [weak self] resizedImage in
self?.imageView.image = resizedImage
}
.catch { error in
debugPrint("Error occurred: \(error)")
}
Note that
then
Promise
Promise
If you have to do other work (like validating that
self
then
resizeImage...
This code reads much more synchronously, even though it's being executed asynchronously. Each
then
done
Promise
Promise
catch
then
done
You can see a clear path from one step in the chain to another. The
done
Promise<Void>
Alternately, you can add a
catch
catch
By default,
then
done
DispatchQueue
This gives you fine-grained control over threading in a way that's just not possible with the custom operator method. You can choose from the call site whether something is doing a big enough operation that it needs to be fired off from a background thread, rather than having to do that work within the method as you would with custom operators.
PromiseKit TL;DR:
Advantages
Way more readable than traditional callback hell both at the call site and at the declaration of the function.
Has built-in methods of firing requests off on a specified thread (though by default the result is always on the main thread).
Automatic piping of errors and tons of methods around recovering from/reacting to errors.
Disadvantages
Third-party framework, which means while you're theoretically not responsible for maintenance, you have way more you have to try to understand when something breaks.
Designed primarily for handling sequential async events, so if you need to handle two concurrent async events it's probably not a big enough hammer.
Cancellation is not really possible in the current version (although it is in progress!)
Solution #3: RxSwift/RxCocoa
RxSwift is a Swift implementation of the
ReactiveX
RxSwift is particularly powerful because you can combine all of its observable methods and power your UI directly with them.
The actual call to set things up to get the image is not that different from what it looks like in PromiseKit:
let image = FakeAPI.rxFetchUser()
.flatMap { user in
RealAPI.rxFetchImage(for: user)
}
.flatMap { image in
ImageResizer.rxResizeImage(image, to: self.imageView.frame.size)
}
image
Observable
Observable
Single
When you subscribe to a
Single
Single
image
.subscribe(
onSuccess: { [weak self] resizedImage in
guard let self = self else { return }
self.imageView.image = resizedImage
self.operationLabel.text = "Complete (RxSwift) in \(self.formattedSecondsSince(start))!"
},
onError: { [weak self] error in
self?.operationLabel.text = "Error occurred (RxSwift): \(error)"
}
)
.disposed(by: bag)
With a more complex operation, you're able to continue listening to a stream of events - and react to each one in turn, or handle any error which occurs.
You're also able to combine observables in a way that allows you to use a related library called RxCococa to bind these actions to your UI. RxCocoa provides the concept of a
Driver
For instance, if you want to show a progress indicator while the
image
let running = Observable
.from([
image.map { _ in false }.asObservable()
])
.merge()
.startWith(true)
.asDriver(onErrorJustReturn: false)
Note that this
Driver
false
This bit of code also points up one of Rx's disadvantages: Its verboseness and density. Why is there a
merge
But the reward comes when you're able to easily set up your loading indicator to automatically show and hide based on whether your request is running or not:
running .drive(activityIndicator.rx.isAnimating) .disposed(by: bag)
Once the
running
drive
rx.isAnimating
Rx also makes heavy use of
Scheduler
Scheduler
Scheduler
Driver
RxSwift/RxCocoa TL;DR:
Advantages
Smooth handling of both asynchronous and
Ability to easily bind properties to changing values.
Ability to compose
Disadvantages
Same third-party framework disadvantages as PromiseKit, only more so because it's a larger, more complex project.
Very steep learning curve
Having to learn schedulers vs. threads vs. dispatch queues to deal with threading
This is a pretty significant hammer, and a goodly percentage of the time, your nail isn't actually that big.
Conclusion: What would I use?
I really love the idea of the custom operators, but it has some significant limitations around cancellation and threading. In the end, my personal opinion is that this is a problem best solved by the community, because it is such a monstrously complex problem. (I'd really be interested in seeing community-based custom operator solutions, though!) That leaves me with RxSwift and PromiseKit.
RxSwift is extremely powerful, but in my experience I've found it to be a hammer that's way too big for most of the nails it's applied to. It makes some complex things very simple, but it makes some simple things unreasonably complex. If I've got something super-complicated going on, I will definitely use it. But personally, I usually find it to be total overkill.
I started using PromiseKit on a side project that had a severe problem with callback hell, and it's been a huge help. I've been able to have a much clearer chain of what's happening, facilitating debugging.
I've also found that when working with PromiseKit, I have way fewer modifications that I need to make to existing code to take advantage of what PromiseKit can do, and for me, I think that's it's greatest advantage.