Skip to content

New integration: AndroidX Lifecycle #760

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 10 commits into from
1 change: 1 addition & 0 deletions binary-compatibility-validator/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ dependencies {
testArtifacts project(':kotlinx-coroutines-jdk8')
testArtifacts project(':kotlinx-coroutines-slf4j')
testArtifacts project(path: ':kotlinx-coroutines-play-services', configuration: 'default')
testArtifacts project(path: ':kotlinx-coroutines-androidx-lifecycle', configuration: 'default')

testArtifacts project(':kotlinx-coroutines-android')
testArtifacts project(':kotlinx-coroutines-javafx')
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
public final class kotlinx/coroutines/androidx/lifecycle/LifecycleDefaultScopesKt {
public static final fun getCoroutineScope (Landroidx/lifecycle/Lifecycle;)Lkotlinx/coroutines/CoroutineScope;
public static final fun getCoroutineScope (Landroidx/lifecycle/LifecycleOwner;)Lkotlinx/coroutines/CoroutineScope;
public static final fun getJob (Landroidx/lifecycle/Lifecycle;)Lkotlinx/coroutines/Job;
}

public final class kotlinx/coroutines/androidx/lifecycle/LifecycleKt {
public static final fun createJob (Landroidx/lifecycle/Lifecycle;Landroidx/lifecycle/Lifecycle$State;)Lkotlinx/coroutines/Job;
public static synthetic fun createJob$default (Landroidx/lifecycle/Lifecycle;Landroidx/lifecycle/Lifecycle$State;ILjava/lang/Object;)Lkotlinx/coroutines/Job;
public static final fun createScope (Landroidx/lifecycle/Lifecycle;Landroidx/lifecycle/Lifecycle$State;)Lkotlinx/coroutines/CoroutineScope;
}

3 changes: 2 additions & 1 deletion integration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ Module name below corresponds to the artifact name in Maven/Gradle.
* [kotlinx-coroutines-jdk8](kotlinx-coroutines-jdk8/README.md) -- integration with JDK8 `CompletableFuture` (Android API level 24).
* [kotlinx-coroutines-guava](kotlinx-coroutines-guava/README.md) -- integration with Guava [ListenableFuture](https://github.com/google/guava/wiki/ListenableFutureExplained).
* [kotlinx-coroutines-slf4j](kotlinx-coroutines-slf4j/README.md) -- integration with SLF4J [MDC](https://logback.qos.ch/manual/mdc.html).
* [kotlinx-coroutines-play-services](kotlinx-coroutines-play-services) -- integration with Google Play Services [Tasks API](https://developers.google.com/android/guides/tasks). |
* [kotlinx-coroutines-play-services](kotlinx-coroutines-play-services) -- integration with Google Play Services [Tasks API](https://developers.google.com/android/guides/tasks).
* [kotlinx-coroutines-androidx-lifecycle](kotlinx-coroutines-androidx-lifecycle) -- integration with AndroidX [Lifecycle](https://developer.android.com/reference/kotlin/androidx/lifecycle/Lifecycle) and [LifecycleOwner](https://developer.android.com/reference/kotlin/androidx/lifecycle/LifecycleOwner).

## Contributing

Expand Down
59 changes: 59 additions & 0 deletions integration/kotlinx-coroutines-androidx-lifecycle/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Module kotlinx-coroutines-androidx-lifecycle

Integration with AndroidX [Lifecycle](
https://developer.android.com/reference/kotlin/androidx/lifecycle/Lifecycle
) and [LifecycleOwner](
https://developer.android.com/reference/kotlin/androidx/lifecycle/LifecycleOwner
).

Extension properties:

| **Name** | **Description**
| -------- | ---------------
| [Lifecycle.coroutineScope][lifecycleScope] | A scope that dispatches on Android Main thread and is active until the Lifecycle is destroyed.
| [LifecycleOwner.coroutineScope][lifecycleOwnerScope] | A scope that dispatches on Android Main thread and is active until the LifecycleOwner is destroyed.
| [Lifecycle.job][lifecycleJob] | A job that is cancelled when the Lifecycle is destroyed.

Extension functions:

| **Name** | **Description**
| -------- | ---------------
| [Lifecycle.createJob][lifecycleCreateJob] | A job that is active while the state is at least the passed one.
| [Lifecycle.createScope][lifecycleCreateScope] | A scope that dispatches on Android Main thread and is active while the state is at least the passed one.

## Example

```kotlin
class MainActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycle.coroutineScope.launch {
someSuspendFunction()
someOtherSuspendFunction()
someCancellableSuspendFunction()
}
}

override fun onStart() {
super.onStart()
val startedScope = lifecycle.createScope(activeWhile = Lifecycle.State.STARTED)
startedScope.launch {
aCancellableSuspendFunction()
yetAnotherCancellableSuspendFunction()
}
startedScope.aMethodThatWillLaunchSomeCoroutines()
}
}
```

# Package kotlinx.coroutines.androidx.lifecycle

Integration with AndroidX [Lifecycle](https://developer.android.com/reference/kotlin/androidx/lifecycle/Lifecycle)
and [LifecycleOwner](https://developer.android.com/reference/kotlin/androidx/lifecycle/LifecycleOwner).

<!--- MODULE kotlinx-coroutines-core -->
<!--- INDEX kotlinx.coroutines -->
<!--- MODULE kotlinx-coroutines-androidx-lifecycle -->
<!--- INDEX kotlinx.coroutines.androidx.lifecycle -->
<!--- END -->
92 changes: 92 additions & 0 deletions integration/kotlinx-coroutines-androidx-lifecycle/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import java.nio.file.Files
import java.nio.file.NoSuchFileException
import java.util.zip.ZipEntry
import java.util.zip.ZipFile

/*
* Copyright 2016-2018 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
*/

ext.androidx_lifecycle_version = '2.0.0'

repositories {
google()
}

def attr = Attribute.of("artifactType", String.class)
configurations {
aar {
attributes { attribute(attr, ArtifactTypeDefinition.JAR_TYPE) }
sourceSets.main.compileClasspath += it
sourceSets.test.compileClasspath += it
sourceSets.test.runtimeClasspath += it
}
aarTest {
attributes { attribute(attr, ArtifactTypeDefinition.JAR_TYPE) }
sourceSets.test.compileClasspath += it
sourceSets.test.runtimeClasspath += it
}
}

sourceSets {
test {
resources.srcDirs = ["test/resources"]
}
}

dependencies {
registerTransform {
from.attribute(attr, "aar")
to.attribute(attr, "jar")
artifactTransform(ExtractJars.class)
}
aar("androidx.lifecycle:lifecycle-common:$androidx_lifecycle_version")
aarTest("androidx.lifecycle:lifecycle-runtime:$androidx_lifecycle_version")
api project(":kotlinx-coroutines-android")
}

tasks.withType(dokka.getClass()) {
externalDocumentationLink {
url = new URL("https://developer.android.com/reference/androidx/")
}
}

class ExtractJars extends ArtifactTransform {
@Override
List<File> transform(File input) {
unzip(input)

List<File> jars = new ArrayList<>()
outputDirectory.traverse(nameFilter: ~/.*\.jar/) { jars += it }

return jars
}

private void unzip(File zipFile) {
ZipFile zip
try {
zip = new ZipFile(zipFile)
for (entry in zip.entries()) {
unzipEntryTo(zip, entry)
}
} finally {
if (zip != null) zip.close()
}
}

private void unzipEntryTo(ZipFile zip, ZipEntry entry) {
File output = new File(outputDirectory, entry.name)
if (entry.isDirectory()) {
output.mkdirs()
} else {
InputStream stream
try {
stream = zip.getInputStream(entry)
Files.copy(stream, output.toPath())
} catch (NoSuchFileException ignored) {
} finally {
if (stream != null) stream.close()
}
}
}
}
49 changes: 49 additions & 0 deletions integration/kotlinx-coroutines-androidx-lifecycle/src/Lifecycle.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package kotlinx.coroutines.androidx.lifecycle

import androidx.lifecycle.GenericLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.Lifecycle.State.INITIALIZED
import androidx.lifecycle.LifecycleOwner
import kotlinx.coroutines.*

/**
* Returns a [CoroutineScope] that uses [Dispatchers.Main] by default, and that will be cancelled as
* soon as this [Lifecycle] [currentState][Lifecycle.getCurrentState] is no longer
* [at least][Lifecycle.State.isAtLeast] the passed [activeWhile] state.
*
* **Beware**: if the current state is lower than the passed [activeWhile] state, you'll get an
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This API surprises me in two ways:

  1. If I instantiate this very early (e.g. in field initialization) it will always end up creating a cancelled job which I won't discover until my coroutine doesn't run.
  2. If a state is re-enterable (e.g. resumed on Fragment) this will not resume again.

What do you think a lifecycle aware dispatcher to solve both of those cases as well as handle the onCreate situation discussed previously?

I'm not sure this exactly the right API, but it seems likely to work out for these cases. Basically, it'd operate as an event loop that pauses when it's below the expected state. What do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you want to launch a coroutine at instantiation time with a lifecycle aware scope, you can still use createScope(activeWhile = Lifecycle.State.INITIALIZED).

Do you think I should change the default for the coroutineScope and job properties to be active while initialized instead of active while created?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

About your second point: the typical use case there is to launch a coroutine inside the onStart or onResume method that is automatically cancelled when the lifecycle is paused or resumed, so a new coroutine is launched each time the onStart or onResume method is called.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a Job is cancelled, there's no going back, but you can always start a coroutine later when the conditions are met again, as mentioned in my comment just above.

About pausable dispatcher, I thought about this a while ago, but this doesn't fit well with coroutines design.

You could think that you could pause dispatching, but this could cause more problems, as you may be relying on a dispatching loop to release resources, creating temporary leaks in suspended coroutines that may resume only much later, if ever.

Suspending work based on the lifecycle has to be opt-in at call site.
This is something that is possible with this awaitState(…) extension function for Lifecycle I wrote, and that can work well for the use cases where you want to pause execution until the lifecycle is at least in some state. You can call this before starting expensive work or in a loop tied to the UI component (Activity, Fragment…).

I personally already have use cases for this method, like showing DialogFragments safely.
Do you think I should include this method in this PR?

* already cancelled scope.
*/
fun Lifecycle.createScope(activeWhile: Lifecycle.State): CoroutineScope {
return CoroutineScope(createJob(activeWhile) + Dispatchers.Main)
}

/**
* Creates a [SupervisorJob] that will be cancelled as soon as this [Lifecycle]
* [currentState][Lifecycle.getCurrentState] is no longer [at least][Lifecycle.State.isAtLeast] the
* passed [activeWhile] state.
*
* **Beware**: if the current state is lower than the passed [activeWhile] state, you'll get an
* already cancelled job.
*/
fun Lifecycle.createJob(activeWhile: Lifecycle.State = INITIALIZED): Job {
require(activeWhile != Lifecycle.State.DESTROYED) {
"DESTROYED is a terminal state that is forbidden for createJob(…), to avoid leaks."
}
return SupervisorJob().also { job ->
when (currentState) {
Lifecycle.State.DESTROYED -> job.cancel() // Fast path if already destroyed
else -> GlobalScope.launch(Dispatchers.Main) { // State is usually synced on next loop,
// this allows to use STARTED from onStart in Activities for example.
addObserver(object : GenericLifecycleObserver {
override fun onStateChanged(source: LifecycleOwner?, event: Lifecycle.Event) {
if (!currentState.isAtLeast(activeWhile)) {
removeObserver(this)
job.cancel()
}
}
})
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package kotlinx.coroutines.androidx.lifecycle

import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import java.util.concurrent.ConcurrentHashMap

/**
* Returns a [CoroutineScope] that uses [Dispatchers.Main] by default, and that is cancelled when
* the [Lifecycle] reaches [Lifecycle.State.DESTROYED] state.
*
* Note that this value is cached until the Lifecycle reaches the destroyed state.
*/
val Lifecycle.coroutineScope: CoroutineScope
get() = cachedLifecycleCoroutineScopes[this] ?: job.let { job ->
val newScope = CoroutineScope(job + Dispatchers.Main)
if (job.isActive) {
cachedLifecycleCoroutineScopes[this] = newScope
job.invokeOnCompletion { _ -> cachedLifecycleCoroutineScopes -= this }
}
newScope
}

/**
* Calls [Lifecycle.coroutineScope] for the [Lifecycle] of this [LifecycleOwner].
*
* This is an inline property, just there for convenient usage from any [LifecycleOwner],
* like FragmentActivity, AppCompatActivity, Fragment and LifecycleService.
*/
inline val LifecycleOwner.coroutineScope get() = lifecycle.coroutineScope

/**
* Returns a [SupervisorJob] that will be cancelled as soon as the [Lifecycle] reaches
* [Lifecycle.State.DESTROYED] state.
*
* Note that this value is cached until the Lifecycle reaches the destroyed state.
*
* You can use this job for custom [CoroutineScope]s, or as a parent [Job].
*/
val Lifecycle.job: Job
get() = cachedLifecycleJobs[this] ?: createJob().also {
if (it.isActive) {
cachedLifecycleJobs[this] = it
it.invokeOnCompletion { _ -> cachedLifecycleJobs -= this }
}
}

private val cachedLifecycleJobs = ConcurrentHashMap<Lifecycle, Job>()
private val cachedLifecycleCoroutineScopes = ConcurrentHashMap<Lifecycle, CoroutineScope>()
Loading