Intro
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()
}
}
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.
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
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:
- It delegates to a hard-coded
Dispatchers.Main
CoroutineDispatcher, which complicates unit and Espresso testing by requiring the use of Dispatchers.setMain. - 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.2")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2")
// everything provides :core via "api", so you only need this if you have no other "implementation" dispatch artifacts
implementation("com.rickbusarow.dispatch:dispatch-core:1.0.0-beta10")
// LifecycleCoroutineScope for Android Fragments, Activities, etc.
implementation("com.rickbusarow.dispatch:dispatch-android-lifecycle:1.0.0-beta10")
// 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:1.0.0-beta10")
// ViewModelScope for Android ViewModels
implementation("com.rickbusarow.dispatch:dispatch-android-viewmodel:1.0.0-beta10")
/*
jvm testing
*/
// core coroutines-test
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.2")
// you only need this if you don't have the -junit4 or -junit5 artifacts
testImplementation("com.rickbusarow.dispatch:dispatch-test:1.0.0-beta10")
// 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:1.0.0-beta10")
// 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:1.0.0-beta10")
/*
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:1.0.0-beta10")
}
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.