Merge "Closeable Recomposer" into androidx-main
diff --git a/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/gestures/SuspendingGestureTestUtil.kt b/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/gestures/SuspendingGestureTestUtil.kt
index 3d4fe80..c0ba66b 100644
--- a/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/gestures/SuspendingGestureTestUtil.kt
+++ b/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/gestures/SuspendingGestureTestUtil.kt
@@ -106,6 +106,8 @@
}
yield()
block()
+ // Pointer input effects will loop indefinitely; fully cancel them.
+ recomposer.cancel()
}
}
diff --git a/compose/runtime/runtime/api/current.txt b/compose/runtime/runtime/api/current.txt
index 7fcff8b..86110fe 100644
--- a/compose/runtime/runtime/api/current.txt
+++ b/compose/runtime/runtime/api/current.txt
@@ -371,13 +371,15 @@
ctor public Recomposer(kotlin.coroutines.CoroutineContext effectCoroutineContext);
method public androidx.compose.runtime.RecomposerInfo asRecomposerInfo();
method public suspend Object? awaitIdle(kotlin.coroutines.Continuation<? super kotlin.Unit> p);
+ method public void cancel();
+ method public void close();
method public long getChangeCount();
method public boolean getHasPendingWork();
method public kotlinx.coroutines.flow.Flow<androidx.compose.runtime.Recomposer.State> getState();
method @Deprecated public boolean hasInvalidations();
method public suspend Object? join(kotlin.coroutines.Continuation<? super kotlin.Unit> p);
- method public suspend Object? runRecomposeAndApplyChanges(kotlin.coroutines.Continuation<?> p);
- method public void shutDown();
+ method public suspend Object? runRecomposeAndApplyChanges(kotlin.coroutines.Continuation<? super kotlin.Unit> p);
+ method @Deprecated public void shutDown();
property public final long changeCount;
property public final boolean hasPendingWork;
property public final kotlinx.coroutines.flow.Flow<androidx.compose.runtime.Recomposer.State> state;
diff --git a/compose/runtime/runtime/api/public_plus_experimental_current.txt b/compose/runtime/runtime/api/public_plus_experimental_current.txt
index 7fcff8b..86110fe 100644
--- a/compose/runtime/runtime/api/public_plus_experimental_current.txt
+++ b/compose/runtime/runtime/api/public_plus_experimental_current.txt
@@ -371,13 +371,15 @@
ctor public Recomposer(kotlin.coroutines.CoroutineContext effectCoroutineContext);
method public androidx.compose.runtime.RecomposerInfo asRecomposerInfo();
method public suspend Object? awaitIdle(kotlin.coroutines.Continuation<? super kotlin.Unit> p);
+ method public void cancel();
+ method public void close();
method public long getChangeCount();
method public boolean getHasPendingWork();
method public kotlinx.coroutines.flow.Flow<androidx.compose.runtime.Recomposer.State> getState();
method @Deprecated public boolean hasInvalidations();
method public suspend Object? join(kotlin.coroutines.Continuation<? super kotlin.Unit> p);
- method public suspend Object? runRecomposeAndApplyChanges(kotlin.coroutines.Continuation<?> p);
- method public void shutDown();
+ method public suspend Object? runRecomposeAndApplyChanges(kotlin.coroutines.Continuation<? super kotlin.Unit> p);
+ method @Deprecated public void shutDown();
property public final long changeCount;
property public final boolean hasPendingWork;
property public final kotlinx.coroutines.flow.Flow<androidx.compose.runtime.Recomposer.State> state;
diff --git a/compose/runtime/runtime/api/restricted_current.txt b/compose/runtime/runtime/api/restricted_current.txt
index 1049b98..11370f5 100644
--- a/compose/runtime/runtime/api/restricted_current.txt
+++ b/compose/runtime/runtime/api/restricted_current.txt
@@ -395,13 +395,15 @@
ctor public Recomposer(kotlin.coroutines.CoroutineContext effectCoroutineContext);
method public androidx.compose.runtime.RecomposerInfo asRecomposerInfo();
method public suspend Object? awaitIdle(kotlin.coroutines.Continuation<? super kotlin.Unit> p);
+ method public void cancel();
+ method public void close();
method public long getChangeCount();
method public boolean getHasPendingWork();
method public kotlinx.coroutines.flow.Flow<androidx.compose.runtime.Recomposer.State> getState();
method @Deprecated public boolean hasInvalidations();
method public suspend Object? join(kotlin.coroutines.Continuation<? super kotlin.Unit> p);
- method public suspend Object? runRecomposeAndApplyChanges(kotlin.coroutines.Continuation<?> p);
- method public void shutDown();
+ method public suspend Object? runRecomposeAndApplyChanges(kotlin.coroutines.Continuation<? super kotlin.Unit> p);
+ method @Deprecated public void shutDown();
property public final long changeCount;
property public final boolean hasPendingWork;
property public final kotlinx.coroutines.flow.Flow<androidx.compose.runtime.Recomposer.State> state;
diff --git a/compose/runtime/runtime/compose-runtime-benchmark/src/androidTest/java/androidx/compose/runtime/benchmark/ComposeBenchmarkBase.kt b/compose/runtime/runtime/compose-runtime-benchmark/src/androidTest/java/androidx/compose/runtime/benchmark/ComposeBenchmarkBase.kt
index 95f78ad..fcf16d4 100644
--- a/compose/runtime/runtime/compose-runtime-benchmark/src/androidTest/java/androidx/compose/runtime/benchmark/ComposeBenchmarkBase.kt
+++ b/compose/runtime/runtime/compose-runtime-benchmark/src/androidTest/java/androidx/compose/runtime/benchmark/ComposeBenchmarkBase.kt
@@ -74,7 +74,7 @@
} finally {
activity.setContentView(emptyView)
advanceUntilIdle()
- recomposer.shutDown()
+ recomposer.cancel()
}
}
@@ -163,7 +163,7 @@
}
activity.setContentView(emptyView)
- recomposer.shutDown()
+ recomposer.cancel()
}
}
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Recomposer.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Recomposer.kt
index 8cc6928..1b3f20b 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Recomposer.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Recomposer.kt
@@ -33,6 +33,7 @@
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.job
import kotlinx.coroutines.launch
@@ -47,6 +48,9 @@
/**
* Runs [block] with a new, active [Recomposer] applying changes in the calling [CoroutineContext].
+ * The [Recomposer] will be [closed][Recomposer.close] after [block] returns.
+ * [withRunningRecomposer] will return once the [Recomposer] is [Recomposer.State.ShutDown]
+ * and all child jobs launched by [block] have [joined][Job.join].
*/
suspend fun <R> withRunningRecomposer(
block: suspend CoroutineScope.(recomposer: Recomposer) -> R
@@ -55,7 +59,8 @@
// Will be cancelled when recomposerJob cancels
launch { recomposer.runRecomposeAndApplyChanges() }
block(recomposer).also {
- recomposer.shutDown()
+ recomposer.close()
+ recomposer.join()
}
}
@@ -88,7 +93,8 @@
*/
// RedundantVisibilityModifier suppressed because metalava picks up internal function overrides
// if 'internal' is not explicitly specified - b/171342041
-@Suppress("RedundantVisibilityModifier")
+// NotCloseable suppressed because this is Kotlin-only common code; [Auto]Closeable not available.
+@Suppress("RedundantVisibilityModifier", "NotCloseable")
@OptIn(
ExperimentalComposeApi::class,
InternalComposeApi::class
@@ -125,12 +131,20 @@
// kick it out and make sure no new ones start if we have one.
val cancellation = CancellationException("Recomposer effect job completed", throwable)
+ var continuationToResume: CancellableContinuation<Unit>? = null
synchronized(stateLock) {
val runnerJob = runnerJob
if (runnerJob != null) {
_state.value = State.ShuttingDown
- // This will cancel frameContinuation if needed
- runnerJob.cancel(cancellation)
+ // If the recomposer is closed we will let the runnerJob return from
+ // runRecomposeAndApplyChanges normally and consider ourselves shut down
+ // immediately.
+ if (!isClosed) {
+ // This is the job hosting frameContinuation; no need to resume it otherwise
+ runnerJob.cancel(cancellation)
+ } else if (frameContinuation != null) {
+ continuationToResume = frameContinuation
+ }
frameContinuation = null
runnerJob.invokeOnCompletion { runnerJobCause ->
synchronized(stateLock) {
@@ -147,6 +161,7 @@
_state.value = State.ShutDown
}
}
+ continuationToResume?.resume(Unit)
}
}
@@ -161,13 +176,13 @@
*/
enum class State {
/**
- * [shutDown] was called on the [Recomposer] and all cleanup work has completed.
+ * [cancel] was called on the [Recomposer] and all cleanup work has completed.
* The [Recomposer] is no longer available for use.
*/
ShutDown,
/**
- * [shutDown] was called on the [Recomposer] and it is no longer available for use.
+ * [cancel] was called on the [Recomposer] and it is no longer available for use.
* Cleanup work has not yet been fully completed and composition effect coroutines may
* still be running.
*/
@@ -205,12 +220,17 @@
}
private val stateLock = Any()
+
+ // Begin properties guarded by stateLock
private var runnerJob: Job? = null
private var closeCause: Throwable? = null
private val knownCompositions = mutableListOf<ControlledComposition>()
private val snapshotInvalidations = mutableListOf<Set<Any>>()
private val compositionInvalidations = mutableListOf<ControlledComposition>()
private var frameContinuation: CancellableContinuation<Unit>? = null
+ private var isClosed: Boolean = false
+ // End properties guarded by stateLock
+
private val _state = MutableStateFlow(State.Inactive)
/**
@@ -247,6 +267,13 @@
}
/**
+ * `true` if there is still work to do for an active caller of [runRecomposeAndApplyChanges]
+ */
+ private val shouldKeepRecomposing: Boolean
+ get() = synchronized(stateLock) { !isClosed } ||
+ effectJob.children.any { it.isActive }
+
+ /**
* The current [State] of this [Recomposer]. See each [State] value for its meaning.
*/
public val state: Flow<State>
@@ -301,10 +328,11 @@
* While [runRecomposeAndApplyChanges] is running, [awaitIdle] will suspend until there are no
* more invalid composers awaiting recomposition.
*
- * This method never returns. Cancel the calling [CoroutineScope] to stop.
+ * This method will not return unless the [Recomposer] is [close]d and all effects in managed
+ * compositions complete.
* Unhandled failure exceptions from child coroutines will be thrown by this method.
*/
- suspend fun runRecomposeAndApplyChanges(): Nothing {
+ suspend fun runRecomposeAndApplyChanges() {
val parentFrameClock = coroutineContext[MonotonicFrameClock] ?: DefaultMonotonicFrameClock
withContext(broadcastFrameClock) {
// Enforce mutual exclusion of callers; register self as current runner
@@ -335,12 +363,15 @@
val toRecompose = mutableListOf<ControlledComposition>()
val toApply = mutableListOf<ControlledComposition>()
- while (true) {
+ while (shouldKeepRecomposing) {
// Await something to do
if (_state.value < State.PendingWork) {
suspendCancellableCoroutine<Unit> { co ->
synchronized(stateLock) {
- if (_state.value == State.PendingWork) {
+ val currentState = _state.value
+ if (currentState == State.PendingWork ||
+ currentState <= State.ShuttingDown
+ ) {
co.resume(Unit)
} else {
frameContinuation = co
@@ -433,15 +464,32 @@
* [Recomposer] will also be cancelled. See [join] to await the completion of all of these
* outstanding tasks.
*/
- fun shutDown() {
+ fun cancel() {
effectJob.cancel()
}
+ @Deprecated("renamed to cancel(); consider close() for your use case", ReplaceWith("cancel()"))
+ fun shutDown() = cancel()
+
/**
- * Await the completion of a [shutDown] operation.
+ * Close this [Recomposer]. Once all effects launched by managed compositions complete,
+ * any active call to [runRecomposeAndApplyChanges] will return normally and this [Recomposer]
+ * will be [State.ShutDown]. See [join] to await the completion of all of these outstanding
+ * tasks.
+ */
+ fun close() {
+ if (effectJob.complete()) {
+ synchronized(stateLock) {
+ isClosed = true
+ }
+ }
+ }
+
+ /**
+ * Await the completion of a [cancel] operation.
*/
suspend fun join() {
- effectJob.join()
+ state.first { it == State.ShutDown }
}
internal override fun composeInitial(
diff --git a/compose/runtime/runtime/src/test/kotlin/androidx/compose/runtime/CompositionTests.kt b/compose/runtime/runtime/src/test/kotlin/androidx/compose/runtime/CompositionTests.kt
index fc8855b..69e8be0 100644
--- a/compose/runtime/runtime/src/test/kotlin/androidx/compose/runtime/CompositionTests.kt
+++ b/compose/runtime/runtime/src/test/kotlin/androidx/compose/runtime/CompositionTests.kt
@@ -2806,7 +2806,7 @@
block(recomposer)
// This call doesn't need to be in a finally; everything it does will be torn down
// in exceptional cases by the coroutineScope failure
- recomposer.shutDown()
+ recomposer.cancel()
}
@Composable fun Wrap(content: @Composable () -> Unit) {
diff --git a/compose/runtime/runtime/src/test/kotlin/androidx/compose/runtime/RecomposerTests.kt b/compose/runtime/runtime/src/test/kotlin/androidx/compose/runtime/RecomposerTests.kt
new file mode 100644
index 0000000..db19167
--- /dev/null
+++ b/compose/runtime/runtime/src/test/kotlin/androidx/compose/runtime/RecomposerTests.kt
@@ -0,0 +1,134 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * 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.
+ */
+
+package androidx.compose.runtime
+
+import androidx.compose.runtime.mock.TestMonotonicFrameClock
+import androidx.compose.runtime.snapshots.withMutableSnapshot
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.runBlockingTest
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeoutOrNull
+import org.junit.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+
+@OptIn(ExperimentalCoroutinesApi::class, ExperimentalComposeApi::class)
+class RecomposerTests {
+
+ @Test
+ fun recomposerRecomposesWhileOpen() = runBlockingTest {
+ val testClock = TestMonotonicFrameClock(this)
+ withContext(testClock) {
+ val recomposer = Recomposer(coroutineContext)
+ val runner = launch {
+ recomposer.runRecomposeAndApplyChanges()
+ }
+ val composition = Composition(UnitApplier(), recomposer)
+ var state by mutableStateOf(0)
+ var lastRecomposedState = -1
+ composition.setContent {
+ lastRecomposedState = state
+ }
+ assertEquals(0, lastRecomposedState, "initial composition")
+ withMutableSnapshot { state = 1 }
+ assertNotNull(
+ withTimeoutOrNull(3_000) { recomposer.awaitIdle() },
+ "timed out waiting for recomposer idle for recomposition"
+ )
+ assertEquals(1, lastRecomposedState, "recomposition")
+ recomposer.close()
+ assertNotNull(
+ withTimeoutOrNull(3_000) { recomposer.join() },
+ "timed out waiting for recomposer.join"
+ )
+ assertNotNull(
+ withTimeoutOrNull(3_000) { runner.join() },
+ "timed out waiting for recomposer runner job"
+ )
+ withMutableSnapshot { state = 2 }
+ assertNotNull(
+ withTimeoutOrNull(3_000) {
+ recomposer.state.first { it <= Recomposer.State.PendingWork }
+ },
+ "timed out waiting for recomposer to not have active pending work"
+ )
+ assertEquals(1, lastRecomposedState, "expected no recomposition by closed recomposer")
+ }
+ }
+
+ @Test
+ fun recomposerRemainsOpenUntilEffectsJoin() = runBlockingTest {
+ val testClock = TestMonotonicFrameClock(this)
+ withContext(testClock) {
+ val recomposer = Recomposer(coroutineContext)
+ val runner = launch {
+ recomposer.runRecomposeAndApplyChanges()
+ }
+ val composition = Composition(UnitApplier(), recomposer)
+ val completer = Job()
+ composition.setContent {
+ LaunchedEffect(completer) {
+ completer.join()
+ }
+ }
+ recomposer.awaitIdle()
+ recomposer.close()
+ recomposer.awaitIdle()
+ assertTrue(runner.isActive, "runner is still active")
+ completer.complete()
+ assertNotNull(
+ withTimeoutOrNull(5_000) { recomposer.join() },
+ "Expected recomposer join"
+ )
+ assertEquals(Recomposer.State.ShutDown, recomposer.state.first(), "recomposer state")
+ assertNotNull(
+ withTimeoutOrNull(5_000) { runner.join() },
+ "Expected runner join"
+ )
+ }
+ }
+}
+
+private class UnitApplier : Applier<Unit> {
+ override val current: Unit
+ get() = Unit
+
+ override fun down(node: Unit) {
+ }
+
+ override fun up() {
+ }
+
+ override fun insertTopDown(index: Int, instance: Unit) {
+ }
+
+ override fun insertBottomUp(index: Int, instance: Unit) {
+ }
+
+ override fun remove(index: Int, count: Int) {
+ }
+
+ override fun move(from: Int, to: Int, count: Int) {
+ }
+
+ override fun clear() {
+ }
+}
diff --git a/compose/runtime/runtime/src/test/kotlin/androidx/compose/runtime/mock/CompositionTest.kt b/compose/runtime/runtime/src/test/kotlin/androidx/compose/runtime/mock/CompositionTest.kt
index 611e945..67a7da2 100644
--- a/compose/runtime/runtime/src/test/kotlin/androidx/compose/runtime/mock/CompositionTest.kt
+++ b/compose/runtime/runtime/src/test/kotlin/androidx/compose/runtime/mock/CompositionTest.kt
@@ -67,7 +67,7 @@
}
scope.block()
scope.composition?.dispose()
- recomposer.shutDown()
+ recomposer.cancel()
recomposer.join()
}
}
diff --git a/compose/ui/ui-test-junit4/src/androidMain/kotlin/androidx/compose/ui/test/junit4/AndroidComposeTestRule.kt b/compose/ui/ui-test-junit4/src/androidMain/kotlin/androidx/compose/ui/test/junit4/AndroidComposeTestRule.kt
index a58576e..d43bd21 100644
--- a/compose/ui/ui-test-junit4/src/androidMain/kotlin/androidx/compose/ui/test/junit4/AndroidComposeTestRule.kt
+++ b/compose/ui/ui-test-junit4/src/androidMain/kotlin/androidx/compose/ui/test/junit4/AndroidComposeTestRule.kt
@@ -341,7 +341,7 @@
}
base.evaluate()
} finally {
- recomposer.shutDown()
+ recomposer.cancel()
// FYI: Not canceling these scope below would end up cleanupTestCoroutines
// throwing errors on active coroutines
recomposerApplyCoroutineScope.cancel()
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/platform/WindowRecomposerTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/platform/WindowRecomposerTest.kt
index 26d6a75..646f377 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/platform/WindowRecomposerTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/platform/WindowRecomposerTest.kt
@@ -76,7 +76,7 @@
assertNull("expected Activity to have been collected", weakActivityRef.get())
}
} finally {
- localRecomposer.shutDown()
+ localRecomposer.cancel()
runBlocking {
recomposerJob.join()
}
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/WindowRecomposer.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/WindowRecomposer.kt
index c90661e..94aba7e 100644
--- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/WindowRecomposer.kt
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/WindowRecomposer.kt
@@ -76,7 +76,7 @@
/**
* Get a [Recomposer] for the window where [windowRootView] is at the root of the window's
* [View] hierarchy. The factory is responsible for establishing a policy for
- * [shutting down][Recomposer.shutDown] the returned [Recomposer]. [windowRootView] will
+ * [shutting down][Recomposer.cancel] the returned [Recomposer]. [windowRootView] will
* hold a hard reference to the returned [Recomposer] until it [joins][Recomposer.join]
* after shutting down.
*/
@@ -228,7 +228,7 @@
Lifecycle.Event.ON_START -> pausableClock?.resume()
Lifecycle.Event.ON_STOP -> pausableClock?.pause()
Lifecycle.Event.ON_DESTROY -> {
- recomposer.shutDown()
+ recomposer.cancel()
}
}
}