Cancellations and exceptions in the coroutine | Detailed explanation of resident tasks

In the second article of this series , Cancellation and Exceptions in Coroutines | Detailed Explanation of Cancellation Operations , we learned that when a task is no longer needed, it is very important to exit correctly. In Android, you can use two CoroutineScopes provided by Jetpack: viewModelScope and lifecycleScope, which can exit running tasks when Activity, Fragment, and Lifecycle are completed. If you are creating your own CoroutineScope, remember to bind it to a task and cancel it when needed.

  • viewModelScope

    https://developer.android.google.cn/reference/kotlin/androidx/lifecycle/package-summary#(androidx.lifecycle.ViewModel).viewModelScope:kotlinx.coroutines.CoroutineScope

  • lifecycleScope

    https://developer.android.google.cn/reference/kotlin/androidx/lifecycle/package-summary#lifecyclescope

 

However, in some cases, you will want the operation to be completed even if the user leaves the current interface. Therefore, you don't want tasks to be cancelled, for example, writing data to the database or sending specific types of requests to your server.

 

Now we will introduce the mode to realize this kind of situation.

Coroutine or WorkManager?

The coroutine will be executed during the activity of your application. If you need to perform an operation that can be active outside the application process (such as sending logs to a remote server), it is recommended to use WorkManager on the Android platform. WorkManager is an extended library for important operations that are expected to be performed at some point in the future.

  • WorkManager

    https://developer.android.google.cn/topic/libraries/architecture/workmanager

 

Please use the coroutine for those operations that are valid in the current process, and ensure that the operation can be cancelled when the user closes the application (for example, make a network request that you want to cache). So, what are the best practices for implementing this type of operation?

Best practices for coroutines

Since the pattern introduced in this article is implemented on the basis of other best practices of coroutines, we can take this opportunity to review:

1. Inject the scheduler into the class

Don't hard code the scheduler when creating a coroutine or calling withContext.

 

benefits : ease of testing. You can easily replace them during unit testing or instrument testing.

 

2. The coroutine should be created at the ViewModel or Presenter layer

If the operation is only related to the UI, it can be performed on the UI layer. If you think this best practice is not feasible in your project, it is very likely that you did not follow the first best practice (testing a ViewModel without a scheduler will become more difficult; in this case, expose The suspend function will make the test feasible).

 

benefits : UI layer should be as concise as possible, and do not directly trigger any business logic. Instead, the responsiveness should be transferred to the ViewModel or Presenter layer implementation. In Android, instrumentation testing is required to test the UI layer, and an emulator is required to perform instrumentation testing.

 

3. At the level below ViewModel or Presenter, suspend functions and Flow should be exposed

If you need to create a coroutine, please use coroutineScope or supervisorScope. And if you want to limit the coroutine to other scopes, please continue reading, and this article will discuss it.

 

benefits : the caller (usually a ViewModel layer) can control these levels in the execution of the task and life cycle, these tasks can also be canceled if needed.

  • coroutineScope

    https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/coroutine-scope.html

  • supervisorScope

    https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/supervisor-scope.html

Operations in the coroutine that should not be cancelled

Assuming that our application has a ViewModel and a Repository, their related logic is as follows:

class MyViewModel(private val repo: Repository) : ViewModel() {
  fun callRepo() {
    viewModelScope.launch {
      repo.doWork()
    }
  }
}


class Repository(private val ioDispatcher: CoroutineDispatcher) {
  suspend fun doWork() {
    withContext(ioDispatcher) {
      doSomeOtherWork()
     veryImportantOperation() // 它不应当被取消
    }
  }
}

We don't want to use viewModelScope to control veryImportantOperation(), because viewModelScope may be cancelled at any time. We want this operation to run longer than viewModelScope. How can we achieve this goal?

 

We need to create our own scope in the Application class, and call these operations in the coroutine started by it . This scope should be injected into those classes that need it.

 

Compared to other solutions that will be seen later in this article, such as GlobalScope, the advantage of creating your own CoroutineScope is that you can configure it according to your own ideas. Whether you need a CoroutineExceptionHandler or want to use your own thread pool as a scheduler, these common configurations can be placed in the CoroutineContext of your CoroutineScope.

  • CoroutineExceptionHandler

    https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-coroutine-exception-handler/

You can call it applicationScope. The applicationScope must contain a SupervisorJob(), so that faults in the coroutine will not be propagated between levels (see the third article in this series: Cancellation and exceptions in coroutines | Detailed explanation of exception handling ):

class MyApplication : Application() {
  // 不需要取消这个作用域,因为它会随着进程结束而结束
   val applicationScope = CoroutineScope(SupervisorJob() + otherConfig)
}

Since we want it to remain active during the lifetime of the application process, we don't need to cancel the applicationScope, and we don't need to keep the reference of SupervisorJob either. When the required lifetime of the coroutine is longer than the lifetime of the calling scope, we can use applicationScope to run the coroutine.

 

Call those operations that should not be cancelled from the coroutine created by application CoroutineScope

 

Whenever you create a new Repository instance, pass in the applicationScope created above . For testing, you can refer to the Testing section below.

Which coroutine constructor should be used?

You need to use launch or async to start a new coroutine based on the behavior of veryImportantOperation:

  • If you need to return the result, please use async and call await to wait for it to complete;

  • If not, use launch and call join to wait for it to complete. Note that, as described in the third part of this series , you must manually handle exceptions inside the launch block.

Here is how to use launch to start the coroutine:

class Repository(
  private val externalScope: CoroutineScope,
  private val ioDispatcher: CoroutineDispatcher
) {
  suspend fun doWork() {
    withContext(ioDispatcher) {
      doSomeOtherWork()
      externalScope.launch {
        //如果这里会抛出异常,那么要将其包裹进 try/catch 中;
        //或者依赖 externalScope 的 CoroutineScope 中的 CoroutineExceptionHandler 
        veryImportantOperation()
      }.join()
    }
  }
}

Or use async:

class Repository(
  private val externalScope: CoroutineScope,
  private val ioDispatcher: CoroutineDispatcher
) {
  suspend fun doWork(): Any { // 在结果中使用特定类型
    withContext(ioDispatcher) {
      doSomeOtherWork()
      return externalScope.async {
        // 异常会在调用 await 时暴露,它们会在调用了 doWork 的协程中传播。
        // 注意,如果正在调用的上下文被取消,那么异常将会被忽略。
        veryImportantOperation()
    }.await()
    }
  }
}

In any case, there is no need to change the code of the above ViewModel. Even if the ViewModelScope is destroyed, tasks using externalScope will continue to run. Just like other suspend functions, doWork() will return only after veryImportantOperation() has completed.

 

Is there a simpler solution?

Another solution that can be used in some use cases (probably the solution that anyone would think of first) is to encapsulate veryImportantOperation into the context of externalScope with withContext as follows:

class Repository(
  private val externalScope: CoroutineScope,
  private val ioDispatcher: CoroutineDispatcher
) {
  suspend fun doWork() {
    withContext(ioDispatcher) {
      doSomeOtherWork()
      withContext(externalScope.coroutineContext) {
        veryImportantOperation()
      }
    }
  }
}

However, this method has the following precautions, which should be paid attention to when using:

  • If the coroutine that calls doWork() is exited when veryImportantOperation starts to execute, it will continue to execute until the next exit node, instead of exiting after the veryImportantOperation ends;

  • CoroutineExceptionHandler will not work as you expect, because the exception will be rethrown when using the context in withContext.

test

Since we may need to inject the scheduler and CoroutineScop at the same time, what needs to be injected in these scenarios?

What to inject when testing

???? Documentation: 

  • TestCoroutineDispatcher

    https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/-test-coroutine-dispatcher/index.html

  • MainCoroutineRule

    https://github.com/android/plaid/blob/master/test_shared/src/main/java/io/plaidapp/test/shared/MainCoroutineRule.kt

  • TestCoroutineScope

    https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/-test-coroutine-scope/

  • AsyncTask.THREAD_POOL_EXECUTOR.asCoroutineDispatcher()

    https://github.com/google/iosched/blob/adssched/mobile/src/androidTest/java/com/google/samples/apps/iosched/tests/di/TestCoroutinesModule.kt#L36

alternative plan

In fact, there are some other ways that we can use coroutines to achieve this behavior. However, these solutions cannot be implemented in an orderly manner under all conditions. Let's take a look at some alternatives, why they are applicable or not, and when to use them or not.

 

GlobalScope

Here are a few reasons why you should not use GlobalScope:

  • Induce us to write hard-coded values . Using GlobalScope directly may make us tend to write hard-coded schedulers, which is a poor practice.

  • Make the test very difficult . Since your code will execute in an uncontrolled scope, you will not be able to manage the tasks initiated from it.

  • As we did for applicationScope, you cannot provide a common CoroutineContext built in scope for all coroutines . Instead, you must pass a common CoroutineContext to all coroutines started by GlobalScope.

Recommendation: Do not use it directly.

  • GlobalScope

    https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-global-scope/

❌ ProcessLifecycleOwner scope in Android

In the androidx.lifecycle:lifecycle-process library in Android, there is an applicationScope, you can use ProcessLifecycleOwner.get().lifecycleScope to call it.

 

When using it, you need to inject a LifecycleOwner to replace the CoroutineScope we injected earlier. In a production environment, you need to pass in ProcessLifecycleOwner.get(); in a unit test, you can use LifecycleRegistry to create a virtual LifecycleOwner.

 

Note that the default CoroutineContext of this scope is Dispatchers.Main.immediate, so it may not be suitable for performing background tasks. Just like when using GlobalScope, you also need to pass a common CoroutineContext to all coroutines started through GlobalScope.

 

For the above reasons, this alternative is more troublesome than creating a CoroutineScope directly in the Application class. Moreover, I personally don't like to establish a relationship with Android lifecycle under the ViewModel or Presenter layer. I hope these layers are platform-independent.

 

Recommendation: Do not use it directly.

⚠️ Special instructions

If you set the CoroutineContext in your applicationScope to GlobalScope or ProcessLifecycleOwner.get().lifecycleScope, you can use it directly as follows:

class MyApplication : Application() {
  val applicationScope = GlobalScope
}

You still get all the advantages described above , and you can easily make changes as needed in the future.

❌ ✅ Use NonCancellable

As you saw in the second article of this series , Cancellation and Exceptions in Coroutines | Detailed Explanation of Cancellation Operations , you can use withContext(NonCancellable) to call the suspend function in the cancelled coroutine. We recommend that you use it for hangable code cleanup, but you should not abuse it.

 

The risk of doing so is high, because you will not be able to control the execution of the coroutine. Indeed, it can make the code more concise and more readable, but at the same time, it may also cause some unpredictable problems in the future.

 

Examples of usage are as follows:

class Repository(
  private val ioDispatcher: CoroutineDispatcher
) {
  suspend fun doWork() {
    withContext(ioDispatcher) {
      doSomeOtherWork()
    withContext(NonCancellable){
        veryImportantOperation()
      }
    }
  }
}

Although this scheme is tempting, you may not always know the logic behind someImportantOperation(). It may be an extension library; it may also be the implementation behind an interface. It can cause various problems:

  • You will not be able to end these operations in the test;

  • The infinite loop using delay will never be cancelled;

  • Collecting Flow from it will cause Flow to become impossible to cancel from the outside;

  • …... 

These problems can lead to subtle and very difficult to debug errors.

 

Suggestion: Use it only to suspend code related to cleanup operations.

 

Whenever you need to perform some work beyond the scope of the current scope, we recommend that you create a custom scope in your own Application class, and execute the coroutine in this scope. At the same time, pay attention to avoid using GlobalScope, ProcessLifecycleOwner scope or NonCancellable when performing such tasks.


Recommended reading




 Click the screen at the end read read the original article  | View Android official Chinese documents - use Kotlin write better applications faster Android  


Guess you like

Origin blog.csdn.net/jILRvRTrc/article/details/109108006