This post describes a pattern of launching coroutines which cancel when the Activity or ViewModel is destroyed, but support allowing important parts of the coroutine to run uncancelled.
When you have an
Activity and a
ViewModel, you need to pay attention to the scopes in which you launch coroutines. Launching a coroutine from an
Activity will result in it having a different lifecycle than if launched from
ViewModel. And what happens if you have async work that you don’t want to cancel if the
ViewModel are destroyed?
In our codebase, we typically marked our
ViewModel functions with
suspend when they would involve asynchronous work, and launched the coroutine from the
Activity. Part of the rationale for this was that it made testing much easier. We knew how to write a test for a suspend function.
Let’s consider this example; we have the
TabSwitcherActivity which is used to show users their open tabs and let them switch, add, and close tabs.
For brevity, let’s just focus on the close tab functionality.
Key points to note
- User chooses to delete a tab from the
Activity launches a coroutine, and calls delete method on
ViewModel calls delete method on
TabRepository deletes the tab from the database
There is a problem looming here. The reason we are using coroutines at all in this flow is because deleting from the database is an asynchronous operation; it might not happen immediately since it involves disk IO.
What happens if the user navigates away from the
Activity immediately after hitting the delete button but before the tab is deleted? Consider this sequence 👇
If the Activity is destroyed, the coroutine will be cancelled and we might fail to honor the user’s decision to delete the tab. This is dangerous! ⚠️ If the user thinks the tab will be deleted, we can’t disregard that just because the user hit the back button or rotated their phone.
Solving the lifecyle problem
- We could choose to not cancel the coroutine.
- This could cause leaks and crashes as code executes on an
Activity which is now destroyed.
- This is bad ❌
- We could choose to launch a coroutine from the
ViewModel instead of the
- This moves the coroutine to a longer-lived component, but we haven’t really solved the problem; merely kicked the can further down the road.
- If the user navigates back from the tab switcher
ViewModel and coroutine will all be destroyed and cancelled and we’ll still fail to honor the user’s action.
- This is bad ❌
- We could use
GlobalScope.launch from the
- This would ensure the deletion takes place
- However, we’ve now disregarded structured concurrency and won’t be able to tell when the operation has finished from the calling code. If we wanted to show a
Snackbar when the tab was deleted for instance, it would be trickier to achieve with this approach.
- This is little better than creating a new
Thread and firing that off to do the deletion. ❌
- In the
Repository, we can use
NonCancellable to ensure the deletion cannot be cancelled once started. ✅
A non-cancelable job that is always active. It is designed for
withContext function to prevent cancellation of code blocks that need to be executed without cancellation.
Well now, that sounds pretty handy for our scenario. Let’s explore NonCancellable further, and see what the code looks like for our
TabRepository. As it turns out, it requires just a tiny change to our
TabRepository to get this new behavior.
Within a coroutine, we can mark a block as
NonCancellable to ensure it runs to completion once started.
- If the
Activity which launched the coroutine is still around, it’ll be able to take action to confirm deletion with the user (e.g., show a
- If the
Activity is destroyed, the coroutine it launched should be cancelled too. However, with
NonCancellable, we ensure that the tab will be deleted (as long as the app itself isn’t killed)
- If the
Activity is already dead, nothing in the coroutine after the block marked with
NonCancellable will execute; the refusal to be stopped applies only to code within the block.
- It’s all just as testable as before 😌