Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 2aed5342 authored by Mike Schneider's avatar Mike Schneider
Browse files

Suspend [MotionValue.keepRunning] when idle #MotionMechanics

This is currently using a [SnapshotFlow] to observe the input and spring
stability. Will investigate further improvements on this, but the
current implementation works.

Also cleaned up tests a bit more, now that `waitForIdle` does
complete while autoAdvancing (it did not before, due to the endless
calls to withFrameNanos

Flag: NONE Initial commits for new library, currently unused.
Test: atest mechanics_tests
Bug: 379248269
Change-Id: I51f27332b299ec114d06ace5997960eb4f325425
parent a7a2c33c
Loading
Loading
Loading
Loading
+54 −10
Original line number Diff line number Diff line
@@ -24,6 +24,7 @@ import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.referentialEqualityPolicy
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.util.lerp
import androidx.compose.ui.util.packFloats
@@ -43,7 +44,9 @@ import com.android.mechanics.spring.calculateUpdatedState
import java.util.concurrent.atomic.AtomicInteger
import kotlin.math.max
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch

/**
 * Computes an animated [output] value, by mapping the [currentInput] according to the [spec].
@@ -154,21 +157,51 @@ class MotionValue(
     */
    suspend fun keepRunning(): Nothing = coroutineScope {
        check(!isActive) { "keepRunning() invoked while already running" }

        isActive = true
        try {
            while (true) {
                // TODO suspend unless there are input changes or an animation is finishing.
            // The purpose of this implementation is to run an animation frame (via withFrameNanos)
            // whenever the input changes, or the spring is still settling, but otherwise just
            // suspend.

            // Used to suspend when no animations are running, and to wait for a wakeup signal.
            val wakeupChannel = Channel<Unit>(capacity = Channel.CONFLATED)

            // `true` while the spring is settling.
            var runAnimationFrames = !isStable
            launch {
                // TODO(b/383979536) use a SnapshotStateObserver instead
                snapshotFlow {
                        // observe all input values
                        var result = spec.hashCode()
                        result = result * 31 + currentInput().hashCode()
                        result = result * 31 + currentDirection.hashCode()
                        result = result * 31 + currentGestureDistance.hashCode()

                        // Track whether the spring needs animation frames to finish
                        // In fact, whether the spring is settling is the only relevant bit to
                        // export from here. For everything else, just cause the flow to emit a
                        // different value (hence the hashing)
                        (result shl 1) + if (isStable) 0 else 1
                    }
                    .collect { hashedState ->
                        // while the 'runAnimationFrames' bit was set on the result
                        runAnimationFrames = (hashedState and 1) != 0
                        // nudge the animation runner in case its sleeping.
                        wakeupChannel.send(Unit)
                    }
            }

                withFrameNanos { frameTimeNanos ->
                    // A new animation frame started. This does not animate anything just yet - if
                    // an
                    // animation is ongoing, it will be updated because of the `animationTimeNanos`
                    // that
                    // is updated here.
                    currentAnimationTimeNanos = frameTimeNanos
            while (true) {
                if (!runAnimationFrames) {
                    // While the spring does not need animation frames (its stable), wait until
                    // woken up - this can be for a single frame after an input change.
                    debugIsAnimating = false
                    wakeupChannel.receive()
                }

                debugIsAnimating = true
                withFrameNanos { frameTimeNanos -> currentAnimationTimeNanos = frameTimeNanos }

                // At this point, the complete frame is done (including layout, drawing and
                // everything else). What follows next is similar what one would do in a
                // `SideEffect`, were this composable code:
@@ -763,6 +796,16 @@ class MotionValue(
            debugInspector?.isActive = value
        }

    /**
     * `false` whenever the [keepRunning] coroutine is suspended while no animation is running and
     * the input is not changing.
     */
    private var debugIsAnimating = false
        set(value) {
            field = value
            debugInspector?.isAnimating = value
        }

    private var debugInspector: DebugInspector? = null
    private var debugInspectorRefCount = AtomicInteger(0)

@@ -791,6 +834,7 @@ class MotionValue(
                        lastAnimation,
                    ),
                    isActive,
                    debugIsAnimating,
                    ::onDisposeDebugInspector,
                )
        }
+10 −2
Original line number Diff line number Diff line
@@ -32,7 +32,8 @@ import kotlinx.coroutines.DisposableHandle
class DebugInspector
internal constructor(
    initialFrameData: FrameData,
    isActive: Boolean,
    initialIsActive: Boolean,
    initialIsAnimating: Boolean,
    disposableHandle: DisposableHandle,
) : DisposableHandle by disposableHandle {

@@ -41,7 +42,14 @@ internal constructor(
        internal set

    /** Whether a [MotionValue.keepRunning] coroutine is active currently. */
    var isActive: Boolean by mutableStateOf(isActive)
    var isActive: Boolean by mutableStateOf(initialIsActive)
        internal set

    /**
     * `false` whenever the [MotionValue.keepRunning] coroutine internally is suspended while no
     * animation is running and the input is not changing.
     */
    var isAnimating: Boolean by mutableStateOf(initialIsAnimating)
        internal set
}

+127 −12
Original line number Diff line number Diff line
@@ -19,6 +19,10 @@
package com.android.mechanics

import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.TestMonotonicFrameClock
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.android.mechanics.spec.BreakpointKey
@@ -37,9 +41,13 @@ import com.android.mechanics.testing.MotionValueToolkit.Companion.isStable
import com.android.mechanics.testing.MotionValueToolkit.Companion.output
import com.android.mechanics.testing.goldenTest
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestCoroutineScheduler
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withContext
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -328,22 +336,115 @@ class MotionValueTest {
    }

    @Test
    fun keepRunning_concurrentInvocationThrows() = runTest {
    fun keepRunning_concurrentInvocationThrows() = runTestWithFrameClock { testScheduler, _ ->
        val underTest = MotionValue({ 1f }, FakeGestureContext)
        val realJob = launch { underTest.keepRunning() }
        testScheduler.runCurrent()

        rule.setContent {
            LaunchedEffect(underTest) {
                val firstJob = launch { underTest.keepRunning() }
        assertThat(realJob.isActive).isTrue()
        try {
            underTest.keepRunning()
            // keepRunning returns Nothing, will never get here
        } catch (e: Throwable) {
            assertThat(e).isInstanceOf(IllegalStateException::class.java)
            assertThat(e).hasMessageThat().contains("keepRunning() invoked while already running")
        }
        assertThat(realJob.isActive).isTrue()
        realJob.cancel()
    }

                val result = kotlin.runCatching { underTest.keepRunning() }
    @Test
    fun keepRunning_suspendsWithoutAnAnimation() = runTest {
        val input = mutableFloatStateOf(0f)
        val spec = specBuilder(Mapping.Zero).toBreakpoint(1f).completeWith(Mapping.One)
        val underTest = MotionValue(input::value, FakeGestureContext, spec)
        rule.setContent { LaunchedEffect(Unit) { underTest.keepRunning() } }

                assertThat(result.isFailure).isTrue()
                assertThat(result.exceptionOrNull()).isInstanceOf(IllegalStateException::class.java)
        val inspector = underTest.debugInspector()
        var framesCount = 0
        backgroundScope.launch { snapshotFlow { inspector.frame }.collect { framesCount++ } }

                assertThat(firstJob.isActive).isTrue()
                firstJob.cancel()
            }
        rule.awaitIdle()
        framesCount = 0
        rule.mainClock.autoAdvance = false

        assertThat(inspector.isActive).isTrue()
        assertThat(inspector.isAnimating).isFalse()

        // Update the value, but WITHOUT causing an animation
        input.floatValue = 0.5f
        rule.awaitIdle()

        // Still on the old frame..
        assertThat(framesCount).isEqualTo(0)
        // ... [underTest] is now waiting for an animation frame
        assertThat(inspector.isAnimating).isTrue()

        rule.mainClock.advanceTimeByFrame()
        rule.awaitIdle()

        // Produces the frame..
        assertThat(framesCount).isEqualTo(1)
        // ... and is suspended again.
        assertThat(inspector.isAnimating).isFalse()

        rule.mainClock.autoAdvance = true
        rule.awaitIdle()
        // Ensure that no more frames are produced
        assertThat(framesCount).isEqualTo(1)
    }

    @Test
    fun keepRunning_remainsActiveWhileAnimating() = runTest {
        val input = mutableFloatStateOf(0f)
        val spec = specBuilder(Mapping.Zero).toBreakpoint(1f).completeWith(Mapping.One)
        val underTest = MotionValue(input::value, FakeGestureContext, spec)
        rule.setContent { LaunchedEffect(Unit) { underTest.keepRunning() } }

        val inspector = underTest.debugInspector()
        var framesCount = 0
        backgroundScope.launch { snapshotFlow { inspector.frame }.collect { framesCount++ } }

        rule.awaitIdle()
        framesCount = 0
        rule.mainClock.autoAdvance = false

        assertThat(inspector.isActive).isTrue()
        assertThat(inspector.isAnimating).isFalse()

        // Update the value, WITH triggering an animation
        input.floatValue = 1.5f
        rule.awaitIdle()

        // Still on the old frame..
        assertThat(framesCount).isEqualTo(0)
        // ... [underTest] is now waiting for an animation frame
        assertThat(inspector.isAnimating).isTrue()

        // A couple frames should be generated without pausing
        repeat(5) {
            rule.mainClock.advanceTimeByFrame()
            rule.awaitIdle()

            // The spring is still settling...
            assertThat(inspector.frame.isStable).isFalse()
            // ... animation keeps going ...
            assertThat(inspector.isAnimating).isTrue()
            // ... and frames are produces...
            assertThat(framesCount).isEqualTo(it + 1)
        }

        // But this will stop as soon as the animation is finished. Skip forward.
        rule.mainClock.autoAdvance = true
        rule.awaitIdle()

        // At which point the spring is stable again...
        assertThat(inspector.frame.isStable).isTrue()
        // ... and animations are suspended again.
        assertThat(inspector.isAnimating).isFalse()
        // Without too many assumptions about how long it took to settle the spring, should be
        // more than  160ms
        assertThat(framesCount).isGreaterThan(10)
    }

    @Test
@@ -363,6 +464,19 @@ class MotionValueTest {
        assertThat(underTest.debugInspector()).isNotSameInstanceAs(originalInspector)
    }

    @OptIn(ExperimentalTestApi::class)
    private fun runTestWithFrameClock(
        testBody:
            suspend CoroutineScope.(
                testScheduler: TestCoroutineScheduler, backgroundScope: CoroutineScope,
            ) -> Unit
    ) = runTest {
        val testScope: TestScope = this
        withContext(TestMonotonicFrameClock(testScope, FrameDelayNanos)) {
            testBody(testScope.testScheduler, testScope.backgroundScope)
        }
    }

    companion object {
        val B1 = BreakpointKey("breakpoint1")
        val B2 = BreakpointKey("breakpoint2")
@@ -374,6 +488,7 @@ class MotionValueTest {
                override val distance: Float
                    get() = 0f
            }
        private val FrameDelayNanos: Long = 16_000_000L

        fun specBuilder(firstSegment: Mapping = Mapping.Identity) =
            MotionSpec.builder(
+4 −21
Original line number Diff line number Diff line
@@ -111,7 +111,6 @@ fun MotionTestRule<MotionValueToolkit>.goldenTest(
) = runTest {
    with(toolkit.composeTestRule) {
        val frameEmitter = MutableStateFlow<Long>(0)
        mainClock.autoAdvance = false

        val testHarness =
            MotionValueTestHarness(
@@ -123,28 +122,12 @@ fun MotionTestRule<MotionValueToolkit>.goldenTest(
                frameEmitter.asStateFlow(),
            )
        val underTest = testHarness.underTest
        val inspector = underTest.debugInspector()

        val debugInspector = underTest.debugInspector()

        var recompositionCount = 0
        var lastOutput = 0f
        var lastOutputTarget = 0f
        var lastIsStable = false

        setContent {
            LaunchedEffect(Unit) { underTest.keepRunning() }
            recompositionCount++
            lastOutput = underTest.output
            lastOutputTarget = underTest.outputTarget
            lastIsStable = underTest.isStable
        }
        setContent { LaunchedEffect(Unit) { underTest.keepRunning() } }

        val recordingJob = launch { testInput.invoke(testHarness) }

        // TODO = remove this block once we have automatic
        waitForIdle()
        mainClock.advanceTimeByFrame()

        waitForIdle()
        mainClock.autoAdvance = false

@@ -153,7 +136,7 @@ fun MotionTestRule<MotionValueToolkit>.goldenTest(

        fun recordFrame(frameId: TimestampFrameId) {
            frameIds.add(frameId)
            frameData.add(debugInspector.frame)
            frameData.add(inspector.frame)
        }

        val startFrameTime = mainClock.currentTime
@@ -181,7 +164,7 @@ fun MotionTestRule<MotionValueToolkit>.goldenTest(
                ),
            )

        debugInspector.dispose()
        inspector.dispose()

        val recordedMotion = create(timeSeries, screenshots = null)
        verifyTimeSeries.invoke(recordedMotion.timeSeries)