Skip to content

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:

java
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:

java
// 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:

java
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:

java
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:

java
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:

kotlin
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:

kotlin
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:

kotlin
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:

kotlin
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:

kotlin
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.

Just something casual. Hope you like it. Built with VitePress