Kotlin Coroutines: Escaping Callback Hell
Scenario Description
Let’s imagine a business use case like this:
- Step 0: The user selects an image locally.
- Step 1: Since the image might be large and inefficient for HTTP transmission or in-app usage, it needs to be compressed — call this
PictureCompress
. - Step 2: After compression, the image needs background removal or matting to eliminate unnecessary parts — call this
PictureMatting
. - Step 3: Once matting is done, apply a visual filter — call this
PictureFilter
. - Finally, display the processed image to the user.
Each of these steps can succeed or fail independently (e.g., compression success/failure, matting success/failure, etc.). Here’s a simple process diagram:
Note: All code below is pseudo-code — it’s only meant to illustrate the core idea. In a real project, you must follow the Single Source of Truth principle and proper MVVM layer separation.
Using Java Callbacks
First, define each operation’s state model. Below is the one for image compression; similar models would exist for matting and filtering:
public static abstract class CompressImageUIState {
public static class SuccessUIState extends CompressImageUIState {
// Use specific variables to store required data, e.g. a Bitmap
@NotNull
private final Bitmap afterCompressBitmap;
}
public static class FailUIState extends CompressImageUIState {
// Use specific variables to store exceptions
@NotNull
private final Throwable throwable;
}
}
Define functional interfaces for callback handling:
// Single Abstract Method interface
public interface Consumer<T> {
void accept(T data);
}
public interface ImageOperation {
void compressImage(@NotNull Bitmap bitmap, Consumer<PicutureCompressImageUIState> consumer);
void mattingImage(@NotNull Bitmap bitmap, Consumer<PicutureMattingImageUIState> consumer);
void filterImage(@NotNull Bitmap bitmap, Consumer<PictureFilterImageUIState> consumer);
}
Main workflow using callbacks:
final Bitmap originBitmap = new Bitmap();
// Step 1: Compress
compressImage(originBitmap, compressImage -> {
if (compressImage instanceof PicutureCompressImageUIState.SuccessUIState) {
// Compression succeeded → proceed to matting
final Bitmap afterCompressBitmap = ((PicutureCompressImageUIState.SuccessUIState) compressImageUIState).afterCompressBitmap;
mattingImage(afterCompressBitmap, mattingImageUIState -> {
if (mattingImageUIState instanceof PicutureMattingImageUIState.SuccessUIState) {
// Matting succeeded → proceed to filtering
final Bitmap mattingBitmap = ((PictureMattingImageUIState.SuccessUIState) mattingImageUIState).afterMattingBitmap;
filterImage(mattingBitmap, pictureFilterUIState -> {
if (pictureFilterUIState instanceof PictureFilterImageUIState.SuccessUIState) {
// Display final image
final pictureFilterBitmap = ((PictureFilterImageUIState.SuccessUIState) pictureFilterUIState).afterPictureFilterBitmap;
pictureFilterBitmap.show();
} else {
Toast.makeText(this, "Failed to apply filter").show();
}
});
} else if (pictureMattingImageUIState instanceof PicutureMattingImageUIState.FailedUIState) {
Toast.makeText(this, "Matting failed").show();
}
});
} else if (compressImage instanceof PicutureCompressImageUIState.FailedUIState) {
Toast.makeText(this, "Image compression failed").show();
}
});
This is a typical Java callback pattern for handling asynchronous tasks. However, as more asynchronous steps are added, nested callbacks pile up — leading to the infamous callback hell.
Using RxJava
Define the reactive interfaces:
public interface ImageOperation {
/**
* Success: returns compressed Bitmap
* Failure: throws PictureCompressException
*/
Single<Bitmap> compressImage(@NotNull Bitmap bitmap);
/**
* Success: returns matted Bitmap
* Failure: throws PictureMattingException
*/
Single<Bitmap> mattingImage(@NotNull Bitmap bitmap);
/**
* Success: returns filtered Bitmap
* Failure: throws PictureFilterException
*/
Single<Bitmap> filterImage(@NotNull Bitmap bitmap);
}
Chaining with RxJava:
final var originBitmap = new Bitmap();
imageOperation
// Compress
.compressImage(originBitmap)
// Matte
.flatMap(compressBitmap -> mattingImage(it))
// Filter
.flatMap(mattingBitmap -> filterImage(it))
// Thread scheduling
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
// Subscription
.subscribe(new SingleObserver<>() {
@Override
public void onSubscribe(@NotNull Disposable disposable) {
mCompositeDisposable.add(disposable);
}
@Override
public void onSuccess(@NotNull Bitmap bitmap) {
bitmap.show();
}
@Override
public void onError(@NotNull Throwable e) {
if (e instanceof PictureCompressException) {
Toast.makeText(this, "Image compression failed").show();
} else if (e instanceof PictureMattingException) {
Toast.makeText(this, "Matting failed").show();
} else if (e instanceof PictureFilterException) {
Toast.makeText(this, "Filter application failed").show();
}
}
});
This approach is more elegant than callbacks, but still a bit verbose — especially for simple linear workflows.
Using Kotlin Coroutines
With Kotlin Coroutines, you can write asynchronous code in a synchronous style:
interface ImageOperation {
/**
* Success: returns compressed Bitmap
* Failure: throws PictureCompressException
*/
suspend fun compressImage(bitmap: Bitmap): Bitmap
/**
* Success: returns matted Bitmap
* Failure: throws PictureMattingException
*/
suspend fun mattingImage(bitmap: Bitmap): Bitmap
/**
* Success: returns filtered Bitmap
* Failure: throws PictureFilterException
*/
suspend fun filterImage(bitmap: Bitmap): Bitmap
}
Main process:
val originBitmap = Bitmap()
scope.launch {
val compressImageBitmap = try {
compressImage(originBitmap)
} catch (e: PictureCompressException) {
Toast.makeText(this, "Image compression failed").show()
return
}
val mattingImageBitmap = try {
mattingImage(compressImageBitmap)
} catch (e: PictureMattingException) {
Toast.makeText(this, "Matting failed").show()
return
}
val filterImageBitmap = try {
filterImage(mattingImageBitmap)
} catch (e: PictureFilterException) {
Toast.makeText(this, "Filter application failed").show()
return
}
filterImageBitmap.show()
}
Handling Exceptions More Elegantly
You can also centralize exception handling using a CoroutineExceptionHandler
:
val originBitmap = Bitmap()
scope.launch(CoroutineExceptionHandler { _, throwable ->
when (throwable) {
is PictureCompressException -> Toast.makeText(this, "Image compression failed").show()
is PictureMattingException -> Toast.makeText(this, "Matting failed").show()
is PictureFilterException -> Toast.makeText(this, "Filter application failed").show()
}
}) {
val compressImageBitmap = compressImage(originBitmap)
val mattingImageBitmap = mattingImage(compressImageBitmap)
val filterImageBitmap = filterImage(mattingImageBitmap)
filterImageBitmap.show()
}
Using Kotlin Extension Functions for Builder-style Chaining
You can further improve readability using extension functions:
private suspend fun Bitmap.compressImageBitmap(): Bitmap {
return service.compressImage(this)
}
private suspend fun Bitmap.mattingImageBitmap(): Bitmap {
return service.mattingImage(this)
}
private suspend fun Bitmap.filterImageBitmap(): Bitmap {
return service.filterImage(this)
}
Then, the main flow becomes beautifully concise:
val originBitmap = Bitmap()
scope.launch(CoroutineExceptionHandler { _, throwable ->
when (throwable) {
is PictureCompressException -> Toast.makeText(this, "Image compression failed").show()
is PictureMattingException -> Toast.makeText(this, "Matting failed").show()
is PictureFilterException -> Toast.makeText(this, "Filter application failed").show()
}
}) {
// Perfect chain call
originBitmap
.compressImageBitmap()
.mattingImageBitmap()
.filterImageBitmap()
.show()
}
✅ Summary: By using Kotlin Coroutines, we can:
- Eliminate deeply nested callbacks,
- Write asynchronous logic in a clean, sequential style,
- Centralize error handling,
- Improve readability and maintainability,
- And even achieve fluent, builder-like workflows via extension functions.