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
In Swift, custom operators are operators which are defined by the developer. An
infix
operator is an operator which goes beween two items. For example in the
code let amount = 2 + 3
, +
is an infix
operator since it is combining the
two things it is in the middle of in order to produce a result.
Declaring something an infix
operator means allowing you to combine the left
and right sides for whatever overrides of the operator you give it. Swift
limits the available characters for custom operators,
but there's still a reasonable amount of flexibility.
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 -->
function tells the compiler, "Here is an implementation of
this operator when the left hand side is the first function, the right hand side
is the second function, and the desired return value is a third function."
Giving this override generic types of T
and U
allows you to make this
flexible enough to handle having a second function which may or may not return a
different type.
The firstFunction
parameter takes a function which eventually returns Void
.
That function should have one parameter, which is a (Result<T>) -> Void
completion block. Remember:
func fetchUser(completion: (Result<User>) -> Void)
? That's what will kick off
this chain.
The secondFunction
parameter takes another function which eventually returns
Void
. That function should have two parameters - one of which is the T
type
of the result of the first function, the other of which is a completion block
for the second function, which is looking for type U
.
The fetchImage
function takes a User
as the first parameter and a
(Result<UIImage) -> Void
completion block as the second closure -so we're off
to a good start!
Now, we can create a chained equation:
let chain = fetchUser -> fetchImage
The chain
constant will have the type
(@escaping (Result<UIImage>) -> Void)) -> Void
- This is similar to declaring
a function like this:
func chain(completion: @escaping(Result<UIImage>) -> Void))
, but it assigns
the function to the chain
constant instead of creating a reusable function.
Now, you can call chain
as if it were the function:
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
that was returned from
chaining the first two functions is now passed in as the left hand side of the
-->
operator to the third function, and another
(@escaping (Result<UIImage>) -> Void)) -> Void
functioned that encompasses
what has happened with all three functions will be assigned to chain
.
You can still call chain
as if it were a function, but now it will have the
result of all three operations in one place:
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:
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
essentially tells you that "at some point, I am expecting to get an
object of this type - and I will let you know when that happens so you can
proceed." If errors are received, they're piped through a separate system and
are handled separately.
With PromiseKit
, instead of taking a completion closure of
(Result<T>) -> Void
as a parameter, a function should return a 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
. The
seal
will tell PromiseKit
's chaining system when the operation is complete,
either fulfill
ing the promise with the object you promised would be there, or
reject
ing the promise with an error.
If you wrap all three methods in this example with Promise
s, their function
signatures would be:
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 @escaping
completion closures.
Next, you can use a bunch of built-in methods in PromiseKit
to set up a chain
of requests:
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
by default expects a Promise
to be returned - so if you're
only adding one line, and that line is a function which returns a Promise
,
then it will automatically infer that as the return type.
If you have to do other work (like validating that self
hasn't been
deallocated, as in the 2nd then
above), then you have to give the compiler a
few more hints: You need to explicitly declare the return type, and explicitly
return the promise from resizeImage...
.
This code reads much more synchronously, even though it's being executed
asynchronously. Each then
or done
block doesn't execute until the Promise
it's chained to fulfills. If a Promise
is rejected anywhere in the chain, it's
piped to the catch
block and the other then
blocks and the done
block
don't execute.
You can see a clear path from one step in the chain to another. The done
block
returns a Promise<Void>
so if you want to wrap the whole chain in a function,
you can return there. This is really useful for building small chains of
promises into increasingly large chains that can power your whole app.
Alternately, you can add a catch
block to the end of your chain to handle any
error that happens locally. You can't chain anything on to a catch
block, so
it's always going to be the last thing in a chain.
By default, then
and done
functions will fire on the main thread. However,
those methods (and many others within PromiseKit) also accept a DispatchQueue
as an argument for where to perform the work within the promise.
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
style of programming. The
idea is that almost everything in your application is a reaction to something
else which has happened, and that you should chain reactions to actions rather
than needing to explicitly tell the computer "When x happens, do y."
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
here is an Observable
which you can subscribe to and get information
about what happened along the chain. In this case, it's a particular type of
Observable
called a Single
, which basically means that it will fire once
instead of repeatedly.
When you subscribe to a Single
, you are able to take actions based on whether
that Single
fails or succeeds:
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
, which can interact with your UI
automatically.
For instance, if you want to show a progress indicator while the image
operation is running, you can create a secondary observable that tracks whether
the first observable is running, then convert that into a Driver:
let running = Observable
.from([
image.map { _ in false }.asObservable()
])
.merge()
.startWith(true)
.asDriver(onErrorJustReturn: false)
Note that this Driver
starts with a given value since the operation would
already be running. It also doesn't actually do anything but return false
when
there's an error since the error would be handled elsewhere, and the main thing
you want to know - is the operation running or not? - can now be answered.
This bit of code also points up one of Rx's disadvantages: Its verboseness and
density. Why is there a merge
there? I have to be honest, I don't completely
understand. The learning curve on making things work can be pretty brutal.
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
driver is told to drive
the rx.isAnimating
property of
your loading indicator, it will automatically handle flipping that on and off.
And if you have something potentially combining several operations that needs to
drive your UI, this is a dead-simple way of making sure all operations have
completed before hiding your loading indicator.
Rx also makes heavy use of Scheduler
objects to handle threading issues for
you - you can easily specify what Scheduler
you want a particular operation to
run on if you'd like, similar to PromiseKit. Some Observable types will come
with a default to a particular Scheduler
- for instance, since Driver
s are
used to drive UI, they're always on the main thread's `Scheduler.
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.