Dispatch

Project Url: RBusarow/Dispatch
Introduction: Utilities for kotlinx.coroutines
More: Author   ReportBugs   OfficialWebsite   
Tags:

CI License

Utilities for kotlinx.coroutines which make them type-safe, easier to test, and more expressive. Use the predefined types and factories or define your own, and never inject a Dispatchers object again.

val presenter = MyPresenter(MainCoroutineScope())

class MyPresenter @Inject constructor(
  /**
  * Defaults to the Main dispatcher
  */
  val coroutineScope: MainCoroutineScope
) {

  fun loopSomething() = coroutineScope.launchDefault {  }

  suspend fun updateSomething() = withMainImmediate {  }
}
class MyTest {

  @Test
  fun `no setting the main dispatcher`() = runBlockingProvidedTest {

    // automatically use TestCoroutineDispatcher for every dispatcher type
    val presenter = MyPresenter(coroutineScope = this)

    // this call would normally crash due to the main looper
    presenter.updateSomething()
  }

}

Contents

Injecting dispatchers

Everywhere you use coroutines, you use a CoroutineContext. If we embed the CoroutineDispatchers settings we want into the context, then we don't need to pass them around manually.

The core of this library is DispatcherProvider - an interface with properties corresponding to the 5 different CoroutineDispatchers we can get from the Dispatchers singleton. It lives inside the CoroutineContext, and gets passed from parent to child coroutines transparently without any additional code.

interface DispatcherProvider : CoroutineContext.Element {

  override val key: CoroutineContext.Key<*> get() = Key

  val default: CoroutineDispatcher
  val io: CoroutineDispatcher
  val main: CoroutineDispatcher
  val mainImmediate: CoroutineDispatcher
  val unconfined: CoroutineDispatcher

  companion object Key : CoroutineContext.Key<DispatcherProvider>
}

val someCoroutineScope = CoroutineScope(
  Job() + Dispatchers.Main + DispatcherProvider()
)

The default implementation of this interface simply delegates to that Dispatchers singleton, as that is what we typically want for production usage.

Types and Factories

A CoroutineScope may have any type of CoroutineDispatcher. What if we have a View class which will always use the Main thread, or one which will always do I/O?

There are marker interfaces and factories to ensure that the correct type of CoroutineScope is always used.

Type Dispatcher
DefaultCoroutineScope Dispatchers.Default
IOCoroutineScope Dispatchers.IO
MainCoroutineScope Dispatchers.Main
MainImmediateCoroutineScope Dispatchers.Main.immediate
UnconfinedCoroutineScope Dispatchers.Unconfined
val mainScope = MainCoroutineScope()

val someUIClass = SomeUIClass(mainScope)

class SomeUIClass(val coroutineScope: MainCoroutineScope) {

  fun foo() = coroutineScope.launch {
    // because of the dependency type,
    // we're guaranteed to be on the main dispatcher even though we didn't specify it
  }

}

Referencing dispatchers

These dispatcher settings can then be accessed via extension functions upon CoroutineScope, or the coroutineContext, or directly from extension functions:

Builder Extensions

| | Default | IO | Main | Main.immediate | Unconfined | | ------------ | --------------- | ---------- | ------------ | --------------------- | ------------------ | | Job | launchDefault | launchIO | launchMain | launchMainImmediate | launchUnconfined | Deferred | asyncDefault | asyncIO | asyncMain | asyncMainImmediate | asyncUnconfined | suspend T | withDefault | withIO | withMain | withMainImmediate | withUnconfined | Flow<T> | flowOnDefault | flowOnIO | flowOnMain | flowOnMainImmediate | flowOnUnconfined

class MyClass(val coroutineScope: IOCoroutineScope) {

  fun accessMainThread() = coroutineScope.launchMain {
    // we're now on the "main" thread as defined by the interface
  }

}

Android Lifecycle

The AndroidX.lifecycle library offers a lifecycleScope extension function to provide a lifecycle-aware CoroutineScope, but there are two shortcomings:

  1. It delegates to a hard-coded Dispatchers.Main CoroutineDispatcher, which complicates unit and Espresso testing by requiring the use of Dispatchers.setMain.
  2. It pauses the dispatcher when the lifecycle state passes below its threshold, which leaks backpressure to the producing coroutine and can create deadlocks.

Dispatch-android-lifecycle and dispatch-android-lifecycle-extensions completely replace the AndroidX version.

import dispatch.android.lifecycle.*
import dispatch.core.*
import kotlinx.coroutines.flow.*

class MyActivity : Activity() {

  init {
    dispatchLifecycleScope.launchOnCreate {
          viewModel.someFlow.collect {
            channel.send("$it")
          }
        }
  }
}

The DispatchLifecycleScope may be configured with any dispatcher, since MainImmediateCoroutineScope is just a marker interface. Its lifecycle-aware functions cancel when dropping below a threshold, then automatically restart when entering into the desired lifecycle state again. This is key to preventing the backpressure leak of the AndroidX version, and it's also more analogous to the behavior of LiveData to which many developers are accustomed.

There are two built-in ways to define a custom LifecycleCoroutineScope - by simply constructing one directly inside a Lifecycle class, or by statically setting a custom LifecycleScopeFactory. This second option can be very useful when utilizing an IdlingCoroutineScope.

Android Espresso

Espresso is able to use IdlingResource to infer when it should perform its actions, which helps to reduce the flakiness of tests. Conventional thread-based IdlingResource implementations don't work with coroutines, however.

IdlingCoroutineScope utilizes IdlingDispatchers, which count a coroutine as being "idle" when it is suspended. Using statically defined factories, service locators, or dependency injection, it is possible to utilize idling-aware dispatchers throughout a codebase during Espresso testing.

class IdlingCoroutineScopeRuleWithLifecycleSample {


  val customDispatcherProvider = IdlingDispatcherProvider()

  @JvmField
  @Rule
  val idlingRule = IdlingDispatcherProviderRule {
    IdlingDispatcherProvider(customDispatcherProvider)
  }

  /**
  * If you don't provide CoroutineScopes to your lifecycle components via a dependency injection framework,
  * you need to use the `dispatch-android-lifecycle-extensions` and `dispatch-android-viewmodel` artifacts
  * to ensure that the same `IdlingDispatcherProvider` is used.
  */
  @Before
  fun setUp() {
    LifecycleScopeFactory.set {
      MainImmediateCoroutineScope(customDispatcherProvider)
    }
    ViewModelScopeFactory.set {
      MainImmediateCoroutineScope(customDispatcherProvider)
    }
  }

  @Test
  fun testThings() = runBlocking {

    // Now any CoroutineScope which uses the DispatcherProvider
    // in TestAppComponent will sync its "idle" state with Espresso

  }

}

Android ViewModel

The AndroidX ViewModel library offers a viewModelScope extension function to provide an auto-cancelled CoroutineScope, but again, this CoroutineScope is hard-coded and uses Dispatchers.Main. This limitation needn't exist.

Dispatch-android-viewmodel doesn't have as many options as its lifecycle counterpart, because the ViewModel.onCleared function is protected and ViewModel does not expose anything about its lifecycle. The only way for a third party library to achieve a lifecycle-aware CoroutineScope is through inheritance.

CoroutineViewModel is a simple abstract class which exposes a lazy viewModelScope property which is automatically cancelled when the ViewModel is destroyed. The exact type of the viewModelScope can be configured statically via ViewModelScopeFactory. In this way, you can use IdlingCoroutineScopes for Espresso testing, TestProvidedCoroutineScopes for unit testing, or any other custom scope you'd like.

If you're using the AAC ViewModel but not dependency injection, this artifact should be very helpful with testing.

import dispatch.android.viewmodel.*
import kotlinx.coroutines.flow.*
import timber.log.*

class MyViewModel : CoroutineViewModel() {

  init {
    MyRepository.someFlow.onEach {
      Timber.d("$it")
    }.launchIn(viewModelScope)
  }
}

The DispatchLifecycleScope may be configured with any dispatcher, since MainImmediateCoroutineScope is just a marker interface. Its lifecycle-aware functions cancel when dropping below a threshold, then automatically restart when entering into the desired lifecycle state again. This is key to preventing the backpressure leak of the AndroidX version, and it's also more analogous to the behavior of LiveData to which many developers are accustomed.

There are two built-in ways to define a custom LifecycleCoroutineScope - by simply constructing one directly inside a Lifecycle class, or by statically setting a custom LifecycleScopeFactory. This second option can be very useful when utilizing an IdlingCoroutineScope.

Testing

Testing is why this library exists. TestCoroutineScope and TestCoroutineDispatcher are very powerful when they can be used, but any reference to a statically defined dispatcher (like a Dispatchers property) removes that control.

To that end, there's a configurable TestDispatcherProvider:

class TestDispatcherProvider(
  override val default: CoroutineDispatcher = TestCoroutineDispatcher(),
  override val io: CoroutineDispatcher = TestCoroutineDispatcher(),
  override val main: CoroutineDispatcher = TestCoroutineDispatcher(),
  override val mainImmediate: CoroutineDispatcher = TestCoroutineDispatcher(),
  override val unconfined: CoroutineDispatcher = TestCoroutineDispatcher()
) : DispatcherProvider

As well as a polymorphic TestProvidedCoroutineScope which may be used in place of any type-specific CoroutineScope:

val testScope = TestProvidedCoroutineScope()

val someUIClass = SomeUIClass(testScope)

class SomeUIClass(val coroutineScope: MainCoroutineScope) {

  fun foo() = coroutineScope.launch {
    // ...
  }

}

There's also testProvided, which delegates to runBlockingTest but which includes a TestDispatcherProvider inside the TestCoroutineScope.

class Subject {
  // this would normally be a hard-coded reference to Dispatchers.Main
  suspend fun sayHello() = withMain {  }
}

@Test
fun `sayHello should say hello`() = runBlockingProvided {

  val subject = SomeClass(this)
  // uses "main" TestCoroutineDispatcher safely with no additional setup
  subject.getSomeData() shouldPrint "hello"
}

Modules

artifact features
dispatch-android-espresso IdlingDispatcher

IdlingDispatcherProvider

dispatch-android-lifecycle-extensions dispatchLifecycleScope
dispatch-android-lifecycle DispatchLifecycleScope

launchOnCreate

launchOnStart

launchOnResume

onNextCreate

onNextStart

onNextResume

dispatch-android-viewmodel CoroutineViewModel

viewModelScope

dispatch-core Dispatcher-specific types and factories

Dispatcher-specific coroutine builders

dispatch-detekt Detekt rules for common auto-imported-the-wrong-thing problems
dispatch-test-junit4 TestCoroutineRule
dispatch-test-junit5 CoroutineTest

CoroutineTestExtension

dispatch-test TestProvidedCoroutineScope

TestDispatcherProvider

runBlockingProvided and testProvided

Full Gradle Config

repositories {
  mavenCentral()
}

dependencies {

  /*
  production code
  */

  // core coroutines
  implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0")
  implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0")

  // a BOM ensures that all artifacts used from the library are of the same version
  implementation(platform("com.rickbusarow.dispatch:dispatch-bom:1.0.0-beta10"))

  // everything provides :core via "api", so you only need this if you have no other "implementation" dispatch artifacts
  implementation("com.rickbusarow.dispatch:dispatch-core")
  // LifecycleCoroutineScope for Android Fragments, Activities, etc.
  implementation("com.rickbusarow.dispatch:dispatch-android-lifecycle")
  // lifecycleScope extension function with a settable factory.  Use this if you don't DI your CoroutineScopes
  // This provides :dispatch-android-lifecycle via "api", so you don't need to declare both
  implementation("com.rickbusarow.dispatch:dispatch-android-lifecycle-extensions")
  // ViewModelScope for Android ViewModels
  implementation("com.rickbusarow.dispatch:dispatch-android-viewmodel")

  /*
  jvm testing
  */

  // core coroutines-test
  testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.0")
  // you only need this if you don't have the -junit4 or -junit5 artifacts
  testImplementation("com.rickbusarow.dispatch:dispatch-test")
  // CoroutineTestRule and :dispatch-test
  // This provides :dispatch-test via "api", so you don't need to declare both
  // This can be used at the same time as :dispatch-test-junit5
  testImplementation("com.rickbusarow.dispatch:dispatch-test-junit4")
  // CoroutineTest, CoroutineTestExtension, and :dispatch-test
  // This provides :dispatch-test via "api", so you don't need to declare both
  // This can be used at the same time as :dispatch-test-junit4
  testImplementation("com.rickbusarow.dispatch:dispatch-test-junit5")
  /*
  Android testing
  */

  // core android
  androidTestImplementation("androidx.test:runner:1.3.0")
  androidTestImplementation("androidx.test.espresso:espresso-core:3.3.0")
  // IdlingDispatcher, IdlingDispatcherProvider, and IdlingCoroutineScope
  androidTestImplementation("com.rickbusarow.dispatch:dispatch-android-espresso")
}

License

Copyright (C) 2021 Rick Busarow
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
     http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Apps
About Me
GitHub: Trinea
Facebook: Dev Tools