Technical blog from Craig Russell.
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 Activity
or 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.
class TabSwitcherActivity : CoroutineScope by MainScope() {
fun onTabDeleted(tab: TabEntity) {
launch { viewModel.onTabDeleted(tab) }
}
}
class TabSwitcherViewModel {
suspend fun onTabDeleted(tab: TabEntity) {
tabRepository.delete(tab)
}
}
class TabRepository {
suspend fun delete(tab: TabEntity) {
deleteOldPreviewImages(tab.tabId)
tabsDao.deleteTabAndUpdateSelection(tab)
}
}
Activity
Activity
launches a coroutine, and calls delete method on ViewModel
ViewModel
calls delete method on TabRepository
TabRepository
deletes the tab from the databaseThere 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.
Activity
which is now destroyed.ViewModel
instead of the Activity
.
Activity
, the Activity
, ViewModel
and coroutine will all be destroyed and cancelled and we’ll still fail to honor the user’s action.GlobalScope.launch
from the Repository
.
Snackbar
when the tab was deleted for instance, it would be trickier to achieve with this approach.Thread
and firing that off to do the deletion. ❌Repository
, we can use NonCancellable
to ensure the deletion cannot be cancelled once started. ✅NonCancellable
✨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.
suspend fun delete(tab: TabEntity) {
withContext(Dispatchers.IO + NonCancellable) {
deleteTabImagePreview(tab)
tabsDao.deleteTab(tab)
}
}
Within a coroutine, we can mark a block as NonCancellable
to ensure it runs to completion once started.
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 Snackbar
)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)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.