Canceling a Coroutine Simplified

Waqas Younis
3 min readSep 14, 2024

--

Picture taken by me ;)

Just like making a function main safe, you are also responsible for making it cancelable. It won’t do it itself.

In lifecycle dependent environment like Android, you should try to make each suspend function cancelable. Because any job can be cancelled at any time.

Why is that important?

Allow me to explain via an example, suppose you wrote a nice-shinny main-safe function that compresses the image to show it to user.

Take this code snippet as an example:


class BitmapCompressor(
private val context: Context
) {

suspend fun compressImage(
contentUri: Uri,
compressionThreshold: Long
): Bitmap? {
return withContext(Dispatchers.IO) {
val inputBytes = context
.contentResolver
.openInputStream(contentUri)?.use { inputStream ->
inputStream.readBytes()
} ?: return@withContext null


withContext(Dispatchers.Default) {
val bitmap = BitmapFactory.decodeByteArray(inputBytes, 0, inputBytes.size)


var outputBytes: ByteArray
var quality = 100
do {
ByteArrayOutputStream().use { outputStream ->
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
outputBytes = outputStream.toByteArray()
quality -= (quality * 0.1).roundToInt()
}
} while (outputBytes.size > compressionThreshold && quality > 5)


BitmapFactory.decodeByteArray(outputBytes, 0, outputBytes.size).also{
println("Compression Finished")
}
}
}
}
}

Don’t get overwhelmed, it’s just a function that compresses the image to a provided compressionThreshold

Now, suppose user opened the screen, which had a viewModel which triggered this function, so that a compressed image can be shown.

Before the function could finish compressing the image, user navigated back, at this point, there is no use of the compressed image. So ideally the function should cancel it’s execution.

But if you actually implement it, you will see it won’t cancel itself, it will execute all the way to last statement which prints “Compression Finished

Don’t believe me?

Check out this manual cancelation


class MainActivity(): AppCompactActivity(){

override onCreate(..){
//some other stuff

lifecycleScope.launch{
val job = launch{
BitmapCompressor(this@MainActivity).compressImage(
//pass URI of the image & threshold
)
println("Inner Job Finished")
}

delay(1000)
println("Canceling the job")
job.cancel()

}


}


}

The compression takes around 5 seconds, on my testing device when provided a good size image. And in code, I am canceling the coroutine after one second.

This is my output:

Canceling the job
Compression Finished

It did not print Inner Job Finished because the job was canceled BUT strangely enough, it printed Compression Finished

BUT WHY??

Let me explain, coroutine cancelation works in the form of “check point”, that’s what we are calling them for the sake of simplicity.

When the job.cancel() was called, compressImage was already fired, so cancelation missed that checkpoint,

Now it waited for the compressImage function to finish,

Then checked if the current coroutine is active or not, as it was not active, so it stopped the execution.

That’s why we did not see Inner Job Finished in the logcat.

How to fix it?

You have to implement “check points” in your main function to make it cancelable.

Before each blocking call, in compressImage function, you need to check if the coroutine is active or not, you can use isActive check.

Or even better, just call ensureActive() function, this will exit the function if coroutine is not active.

Here is the updated main-safe, cancelable suspend function:

class BitmapCompressor(
private val context: Context
) {

suspend fun compressImage(
contentUri: Uri,
compressionThreshold: Long
): Bitmap? {
return withContext(Dispatchers.IO) {
val inputBytes = ...

ensureActive()

withContext(Dispatchers.Default) {
val bitmap = ...

ensureActive()

var outputBytes: ByteArray
var quality = 100
do {
...
} while (isActive && outputBytes.size > compressionThreshold && quality > 5)

ensureActive()

BitmapFactory.decodeByteArray(outputBytes, 0, outputBytes.size).also{
println("Compression Finished")
}
}
}
}
}

I removed the code that was not changed,

Look at how the activity of coroutine was ensured,

And in while loop, you can add the isActive check.

Now cancelling the job will exit the function at next checkout point, which could be ensureActiveor isActive . Hence won’t reach till end and won’t print Compression Finished

Hope it helps,

Also checkout this detailed article that explains the inner working of Dispatchers.IO and Dispatchers.Default

Cheers.

--

--