Originally written by Manuel Vivo
Exceptions in Coroutines
Translator: Jingpingcheng
In Part 2 we learned about the importance of canceling Coroutines in a timely manner. On Android, you can use CoroutineScopes provided by Jetpack, such as viewModelScope or lifecycleScope. Associated with the Activity/Fragment Lifecycle in the end of the will automatically cancel the running of Coroutines. If you are using a custom CoroutineScope, be sure to use the Job handle to control cancellations.
But in some cases, we don’t want running Coroutines to be canceled even if the user leaves the page. (such as writing data to the database or sending data to the server)
This article describes how to handle this situation.
Coroutines or WorkManager?
Coroutines has a life span as long as your app. But use WorkManager when you want to perform an operation that has a longer lifetime (such as sending logs to your server). WorkManager is a tool that ensures that certain critical operations will perform as expected. Coroutines can effectively perform actions and cancellations within the current process, even if the user forcibly closes your application.
I generally recommend using WorkManager when performing important operations, such as sending in-app purchase information to the server.
Coroutines best practices
1. Inject Dispatchers into classes
Don’t kill Dispatchers in code when creating new coroutines or calling withContext, use injection instead.
✅ Benefits: ease of testing as you can easily replace them for both unit and instrumentation tests.
2. The ViewModel/Presenter layer should create coroutines
The UI layer should only deal with uI-related logic. No coroutines should be created. Coroutines should belong only to the ViewModel/Presenter layer.
✅ Benefits: The UI layer should not deal with the business logic, the ViewModel/Presenter layer is used to handle the business logic. The test UI layer requires an emulator to run instrumentation tests.
3. The layers below the ViewModel/Presenter layer should expose suspend functions and Flows
Coroutines are created using the coroutineScope or container container. If you need a different scope, this article explains the solution, so read on!
✅ Benefits: The caller (generally the ViewModel layer) can control the execution and lifecycle of the work happening in those layers, being able to cancel when needed.
Operations that shouldn’t be cancelled in Coroutines
Suppose we have a ViewModel and a Repository as follows:
class MyViewModel(private val repo: Repository) : ViewModel() {
fun callRepo(a) {
viewModelScope.launch {
repo.doWork()
}
}
}
class Repository(private val ioDispatcher: CoroutineDispatcher) {
suspend fun doWork(a) {
withContext(ioDispatcher) {
doSomeOtherWork()
veryImportantOperation() // This shouldn’t be cancelled}}}Copy the code
We don’t want veryImportantOperation() to be controlled by viewModelScope because the viewModelScope can be cancelled at any time.
We can use a custom and application life cycle as long CoroutineScope to solve this problem, the custom CoroutineScope advantage is that you can use custom CoroutineExceptionHandler and custom thread pool (translator note: You can customize a CoroutineContext that fits your needs exactly.
We’re using the applicationScope to name our custom CoroutineScope, and it must contain a SupervisorJob() so that the failure of any child coroutine will not affect any other child coroutines within that scope. Nor does it cause applicationScope itself to exit. (See Part 3)
class MyApplication : Application() {
// No need to cancel this scope as it'll be torn down with the process
val applicationScope = CoroutineScope(SupervisorJob() + otherConfig)
}
Copy the code
We don’t need to handle the exit of applicationScope because it has the same lifetime as our app, so we don’t need a handle that’s holding the container for the container job (). We can use applicationScope to launch Coroutines that need to last as long as our App.
We can use applicationScope for coroutines that we don’t want to cancel.
When you create a new Repository instance, you pass in applicationScope.
Which Coroutine Builder should we use?
Depending on the veryImportantOperation, you can use launch or async to start a new coroutine:
- If you need a return value, use
async
Start and callawait
Methods. - If you do not need a return value, use
launch
Start and calljoin
Methods. Note handling exceptions, seepart 3
The code is as follows:
class Repository(
private val externalScope: CoroutineScope,
private val ioDispatcher: CoroutineDispatcher
) {
suspend fun doWork(a) {
withContext(ioDispatcher) {
doSomeOtherWork()
externalScope.launch {
// if this can throw an exception, wrap inside try/catch
// or rely on a CoroutineExceptionHandler installed
// in the externalScope's CoroutineScope
veryImportantOperation()
}.join()
}
}
}
Copy the code
The externalScope parameter in the Repository constructor is a custom CoroutineScope with a lifetime as long as the App, such as the applicationScope we just created. The other parameter ioDispatcher is mentioned in the first Coroutines best practice of this article, always use CoroutineDispatcher as an injection rather than dead in code. VeryImportantOperation () may throw exceptions, We want to use try/catch or use externalScope set in the CoroutineExceptionHandler (premise is you need to create CoroutineExceptionHandler instance and Settings to applicationScope) .
The async code is as follows:
class Repository(
private val externalScope: CoroutineScope,
private val ioDispatcher: CoroutineDispatcher
) {
suspend fun doWork(a): Any { // In actual use, change Any to the required type
withContext(ioDispatcher) {
doSomeOtherWork()
return externalScope.async {
// Exceptions are exposed when calling await, they will be
// propagated in the coroutine that called doWork. Watch
// out! They will be ignored if the calling context cancels.
veryImportantOperation()
}.await()
}
}
}
Copy the code
VeryImportantOperation () may throw an exception, which is propagated to the coroutine that calls the doWork() method. If the coroutine is canceled, the exception may be ignored. In Part 3 we learned that if async is a root coroutine (scope.async) then we can call await() with a try/catch and catch the exception correctly.
Using this method, our ViewModel layer code doesn’t need to change at all, and even if the viewModelScope is destroyed, the coroutines running in externalScope will continue to run until the end. In fact, doWork() hangs until veryImportantOperation() completes.
Is there an easier way to write it?
Another way to do this is to use withContext:
class Repository(
private val externalScope: CoroutineScope,
private val ioDispatcher: CoroutineDispatcher
) {
suspend fun doWork(a){ withContext(ioDispatcher) { doSomeOtherWork() withContext(externalScope.coroutineContext) { veryImportantOperation() } }}}Copy the code
However, this implementation has the following disadvantages:
-
If the doWork coroutine is cancelled, the veryImportantOperation will run until the next Cancellation point. The cancellation point of the veryImportantOperation is required.
-
CoroutineExceptionHandler cannot work normally, because in withContext, exception will be thrown again.
Translator’s note: For all the drawbacks, let’s not implement it this way.
More on testing, and why not use GlobalScope, ProcessLifecycleOwner’s Scope, and NonCancellabl types (which can only be used for cleanup).