Callback hell is a consequence of asynchronous tasks that every developer who has faced it hates.
It’s hard to read, it looks ugly and maintenance is a pain, especially when starting with a new codebase.
Thanks to Kotlin, there is a solution available to get rid of callback hell once and for all, and that’s where Kotlin Coroutines come into place.
Kotlin’s Coroutines are great. They are easy to learn, lightweight, are integrated in the language natively, and make asynchronous code readable like it were sequential code.
For me, one of its greatest feature, is that they allow developers to avoid the callback hell nightmare and this is what we are going to see how to do.
Getting rid of callbacks
In order to eliminate callback hell, we are going to use Kotlin Coroutines and suspend functions.
There are two types of callbacks that we are going to replace with this suspend functions. Callbacks that are trigger once and callbacks that are for listening for updates as a subscriber-consumer model.
One-shot callbacks
First, let’s start with the one-shot callbacks. For those, we need a suspend function that is going to call to a function called, suspendCoroutine
. This function is going to return the computed value or propagate the exception thrown if an error occurred.
The suspendCoroutine
method takes a lambda, which is where we are going to use the callback listener as usual, with the difference that once we have a result, we are going to take the continuation
parameter of the suspendCoroutine
lambda, and resume the execution of the Coroutine calling resume
with the value to return.
There are other options beside resume
like resumeWith
, which takes a Result
if you need to cover failure or success cases. There’s also resumeWithException
if you want to return an exception, which is calling resumeWith
with a failure Result
under the hood.
I like to implement this code as an extension function of the class which methods I’m converting from callback based to sequential code, like in the following example.
Here I’m using the suspendCoroutine
function to read data from the Firebase Realtime Database.
suspend fun DatabaseReference.read(): DataSnapshot =
suspendCoroutine { continuation ->
val valueEventListener = object : ValueEventListener {
override fun onCancelled(error: DatabaseError) {
continuation.resumeWithException(error.toException())
}
override fun onDataChange(snapshot: DataSnapshot) {
continuation.resume(snapshot)
}
}
addListenerForSingleValueEvent(valueEventListener)
}
KotlinNow to call this code the only thing needed is a Coroutine scope, so you have to remember to do this before calling the function. This can be considered as a drawback in comparison with a callback pattern that you can just call, but the compiler is going to tell you if you forget about the Coroutine scope, so I don’t feel like it’s differential.
Here you can see how to call the method created above, that now, prevents the developer from using callbacks.
suspend fun getGroups(): List<Group> {
return groupRef.read().mapToGroups()
}
KotlinSubscription based callbacks
Now, for callbacks that keep emitting data, for example, every time a value is changed in a database, we are going to rely on Kotlin Flow.
Kotlin Flow has a function, callbackFlow
, that returns a Flow
. This code is going to host the listener we want to eliminate, and emit a value for each time an update is noticed. It also takes care of executing code once the flow of data is no longer needed.
As with suspendCoroutine
, callbackFlow
takes a lambda where we are going to execute the listener, and for each time we want to emit data, a call to trySend
with the object to send is needed.
Inside this lambda, we also need to call awaitClose
, which also takes a lambda that is going to be executed once the Flow
is no longer needed. This is mandatory, and the code will crash if you don’t do it.
Sadly, the compiler is not going to alert us of this, so don’t forget to add it to avoid runtime errors.
In the following code snippet you can see how callbackFlow
is used to listen for changes in the Firebase Realtime Database, again, implemented in an extension function.
suspend fun DatabaseReference.subscribe(): Flow<Player> = callbackFlow {
val childEventListener = object : ChildEventListener {
override fun onChildAdded(snapshot: DataSnapshot, previousChildName: String?) {
trySend(
Player(
snapshot.key
)
)
}
override fun onChildChanged(snapshot: DataSnapshot, previousChildName: String?) {}
override fun onChildRemoved(snapshot: DataSnapshot) {}
override fun onChildMoved(snapshot: DataSnapshot, previousChildName: String?) {}
override fun onCancelled(error: DatabaseError) {}
}
addChildEventListener(childEventListener)
awaitClose {
removeEventListener(childEventListener)
}
}
KotlinAs this is returning a normal Flow
, we just need to call to collect
in our consumer to read the emitted data and do whatever we want with it, as in the following example where the flow
variable is the one returned previously by callbackFlow
.
flow.collect { result ->
_player.postValue(cachePlayers)
}
KotlinConclusions
It’s actually pretty easy to implement, and it finishes finally with callback hell. With these changes, you can call code that used to be callbacks, one after another sequentially, without infinite indentations and modernize some older libraries that were thought to be used with Java instead of Kotlin.
Also, if you are using this for Android, now you can implement Clean Architecture with a UseCase layer that manage errors easier and cleaner, and just use the viewModelScope
as your Coroutine scope in case you are using MVVM.
If you want to read more content like this without ads and support me, don’t forget to check my profile, or give Medium a chance by becoming a member to access unlimited stories from me and other writers. It’s only $5 a month and if you use this link I get a small commission.