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

Commit 6e8e7168 authored by Omar Miatello's avatar Omar Miatello
Browse files

Improved MotionValueBenchmark test readability and added animation tests

- Added more benchmarks for MotionValue, including performance when the
value is unstable and should animate.
- Added ComposeStateTest to verify compose behavior during tests.

Performance results: go/mm-microbenchmarks

Test: MotionValueBenchmark (run on AS)
Test: atest ComposeStateTest
Bug: 384402480
Flag: NONE benchmark module for Motion Mechanics
Change-Id: I706a6b5596326fcd4409ba33823b18fdcdfa8c4a
parent 04bf757e
Loading
Loading
Loading
Loading
+168 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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 com.android.mechanics.benchmark

import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.snapshots.Snapshot
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.junit.Test
import org.junit.runner.RunWith
import platform.test.motion.compose.runMonotonicClockTest

@RunWith(AndroidJUnit4::class)
class ComposeStateTest {
    @Test
    fun mutableState_sendApplyNotifications() = runMonotonicClockTest {
        val mutableState = mutableStateOf(0f)

        var lastRead = -1f
        snapshotFlow { mutableState.value }.onEach { lastRead = it }.launchIn(backgroundScope)
        check(lastRead == -1f) { "[1] lastRead $lastRead, snapshotFlow launchIn" }

        // snapshotFlow will emit the first value (0f).
        testScheduler.advanceTimeBy(1)
        check(lastRead == 0f) { "[2] lastRead $lastRead, first advanceTimeBy()" }

        // update composeState x5.
        repeat(5) {
            mutableState.value++
            check(lastRead == 0f) { "[3 loop] lastRead $lastRead, composeState.floatValue++" }

            testScheduler.advanceTimeBy(1)
            check(lastRead == 0f) { "[4 loop] lastRead $lastRead, advanceTimeBy()" }
        }

        // Try to wait with a delay. It does nothing (lastRead == 0f).
        delay(1)
        check(mutableState.value == 5f) { "[5] mutableState ${mutableState.value}, after loop" }
        check(lastRead == 0f) { "[5] lastRead $lastRead, after loop" }

        // This should trigger the flow.
        Snapshot.sendApplyNotifications()
        check(lastRead == 0f) { "[6] lastRead $lastRead, Snapshot.sendApplyNotifications()" }

        // lastRead will be updated (5f) after advanceTimeBy (or a delay).
        testScheduler.advanceTimeBy(1)
        check(lastRead == 5f) { "[7] lastRead $lastRead, advanceTimeBy" }
    }

    @Test
    fun derivedState_readNotRequireASendApplyNotifications() = runMonotonicClockTest {
        val mutableState = mutableStateOf(0f)

        var derivedRuns = 0
        val derived = derivedStateOf {
            derivedRuns++
            mutableState.value * 2f
        }
        check(derivedRuns == 0) { "[1] derivedRuns: $derivedRuns, should be 0" }

        var lastRead = -1f
        snapshotFlow { derived.value }.onEach { lastRead = it }.launchIn(backgroundScope)
        check(lastRead == -1f) { "[2] lastRead $lastRead, snapshotFlow launchIn" }
        check(derivedRuns == 0) { "[2] derivedRuns: $derivedRuns, should be 0" }

        // snapshotFlow will emit the first value (0f * 2f = 0f).
        testScheduler.advanceTimeBy(16)
        check(lastRead == 0f) { "[3] lastRead $lastRead, first advanceTimeBy()" }
        check(derivedRuns == 1) { "[3] derivedRuns: $derivedRuns, should be 1" }

        // update composeState x5.
        repeat(5) {
            mutableState.value++
            check(lastRead == 0f) { "[4 loop] lastRead $lastRead, composeState.floatValue++" }

            testScheduler.advanceTimeBy(16)
            check(lastRead == 0f) { "[5 loop] lastRead $lastRead, advanceTimeBy()" }
        }

        // Try to wait with a delay. It does nothing (lastRead == 0f).
        delay(1)
        check(mutableState.value == 5f) { "[6] mutableState ${mutableState.value}, after loop" }
        check(lastRead == 0f) { "[6] lastRead $lastRead, after loop" }
        check(derivedRuns == 1) { "[6] derivedRuns $derivedRuns, after loop" }

        // Reading a derived state, this will trigger the flow.
        // NOTE: We are not using Snapshot.sendApplyNotifications()
        derived.value
        check(lastRead == 0f) { "[7] lastRead $lastRead, read derivedDouble" }
        check(derivedRuns == 2) { "[7] derivedRuns $derivedRuns, read derived" } // Triggered

        // lastRead will be updated (5f * 2f = 10f) after advanceTimeBy (or a delay)
        testScheduler.advanceTimeBy(16)
        check(lastRead == 5f * 2f) { "[8] lastRead $lastRead, advanceTimeBy" } // New value
        check(derivedRuns == 2) { "[8] derivedRuns $derivedRuns, read derived" }
    }

    @Test
    fun derivedState_readADerivedStateTriggerOthersDerivedState() = runMonotonicClockTest {
        val mutableState = mutableStateOf(0f)

        var derivedRuns = 0
        val derived = derivedStateOf {
            derivedRuns++
            mutableState.value
        }

        var otherRuns = 0
        repeat(100) {
            val otherState = derivedStateOf {
                otherRuns++
                mutableState.value
            }
            // Observer all otherStates.
            snapshotFlow { otherState.value }.launchIn(backgroundScope)
        }
        check(derivedRuns == 0) { "[1] derivedRuns: $derivedRuns" }
        check(otherRuns == 0) { "[1] otherRuns: $otherRuns" }

        // Wait for snapshotFlow.
        testScheduler.advanceTimeBy(16)
        check(derivedRuns == 0) { "[2] derivedRuns: $derivedRuns" }
        check(otherRuns == 100) { "[2] otherRuns: $otherRuns" }

        // This write might trigger all otherStates observed, but it does not.
        mutableState.value++
        check(derivedRuns == 0) { "[3] derivedRuns: $derivedRuns" }
        check(otherRuns == 100) { "[3] otherRuns: $otherRuns" }

        // Wait for several frames, but still doesn't trigger otherStates.
        repeat(10) { testScheduler.advanceTimeBy(16) }
        check(derivedRuns == 0) { "[4] derivedRuns: $derivedRuns" }
        check(otherRuns == 100) { "[4] otherRuns: $otherRuns" }

        // Reading derived state will trigger all otherStates.
        // This behavior is causing us some problems, because reading a derived state causes all
        // the
        // dirty derived states to be reread, and this can happen multiple times per frame,
        // making
        // derived states much more expensive than one might expect.
        derived.value
        check(derivedRuns == 1) { "[5] derivedRuns: $derivedRuns" }
        check(otherRuns == 100) { "[5] otherRuns: $otherRuns" }

        // Now we pay the cost of those derived states.
        testScheduler.advanceTimeBy(1)
        check(derivedRuns == 1) { "[6] derivedRuns: $derivedRuns" }
        check(otherRuns == 200) { "[6] otherRuns: $otherRuns" }
    }
}
+166 −23
Original line number Diff line number Diff line
@@ -18,64 +18,207 @@ package com.android.mechanics.benchmark

import androidx.benchmark.junit4.BenchmarkRule
import androidx.benchmark.junit4.measureRepeated
import androidx.compose.runtime.MutableFloatState
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.snapshots.Snapshot
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.android.mechanics.DistanceGestureContext
import com.android.mechanics.MotionValue
import com.android.mechanics.spec.Guarantee
import com.android.mechanics.spec.InputDirection
import com.android.mechanics.spec.Mapping
import com.android.mechanics.spec.MotionSpec
import com.android.mechanics.spec.buildDirectionalMotionSpec
import com.android.mechanics.spring.SpringParameters
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import platform.test.motion.compose.MonotonicClockTestScope
import platform.test.motion.compose.runMonotonicClockTest

/** Benchmark, which will execute on an Android device. Previous results: go/mm-microbenchmarks */
@RunWith(AndroidJUnit4::class)
class MotionValueBenchmark {
    @get:Rule val benchmarkRule = BenchmarkRule()

    private data class TestData(
        val motionValue: MotionValue,
        val gestureContext: DistanceGestureContext,
        val input: MutableFloatState,
        val spec: MotionSpec,
    )

    private fun testData(
        gestureContext: DistanceGestureContext = DistanceGestureContext(0f, InputDirection.Max, 2f),
        input: Float = 0f,
        spec: MotionSpec = MotionSpec.Empty,
    ): TestData {
        val inputState = mutableFloatStateOf(input)
        return TestData(
            motionValue = MotionValue(inputState::floatValue, gestureContext, spec),
            gestureContext = gestureContext,
            input = inputState,
            spec = spec,
        )
    }

    // Fundamental operations on MotionValue: create, read, update.

    @Test
    fun createMotionValue() {
        val gestureContext = DistanceGestureContext(0f, InputDirection.Max, 2f)
        val currentInput = { 0f }
        benchmarkRule.measureRepeated { MotionValue(currentInput, gestureContext) }
        val input = { 0f }

        benchmarkRule.measureRepeated { MotionValue(input, gestureContext) }
    }

    @Test
    fun changeInput_readOutput() {
        val gestureContext = DistanceGestureContext(0f, InputDirection.Max, 2f)
        val a = mutableFloatStateOf(0f)
        val motionValue = MotionValue(a::floatValue, gestureContext)
    fun stable_readOutput_noChanges() {
        val data = testData()

        // The first read may cost more than the others, it is not interesting for this test.
        data.motionValue.floatValue

        benchmarkRule.measureRepeated { data.motionValue.floatValue }
    }

    @Test
    fun stable_readOutput_afterWriteInput() {
        val data = testData()

        benchmarkRule.measureRepeated {
            runWithMeasurementDisabled { a.floatValue += 1f }
            motionValue.floatValue
            runWithMeasurementDisabled { data.input.floatValue += 1f }
            data.motionValue.floatValue
        }
    }

    @Test
    fun readOutputMultipleTimes() {
        val gestureContext = DistanceGestureContext(0f, InputDirection.Max, 2f)
        val a = mutableFloatStateOf(0f)
        val motionValue = MotionValue(a::floatValue, gestureContext)
    fun stable_writeInput_AND_readOutput() {
        val data = testData()

        benchmarkRule.measureRepeated {
            runWithMeasurementDisabled {
                a.floatValue += 1f
                motionValue.output
            data.input.floatValue += 1f
            data.motionValue.floatValue
        }
            motionValue.output
    }

    // Compose specific

    @Test
    fun writeState_1snapshotFlow() = runMonotonicClockTest {
        val composeState = mutableFloatStateOf(0f)

        var lastRead = 0f
        snapshotFlow { composeState.floatValue }.onEach { lastRead = it }.launchIn(backgroundScope)

        benchmarkRule.measureRepeated {
            composeState.floatValue++
            Snapshot.sendApplyNotifications()
            testScheduler.advanceTimeBy(16)
        }

        check(lastRead == composeState.floatValue) {
            "snapshotFlow lastRead $lastRead != ${composeState.floatValue} (current composeState)"
        }
    }

    @Test
    fun readOutputMultipleTimesMeasureAll() {
        val gestureContext = DistanceGestureContext(0f, InputDirection.Max, 2f)
        val currentInput = mutableFloatStateOf(0f)
        val motionValue = MotionValue(currentInput::floatValue, gestureContext)
    fun writeState_100snapshotFlow() = runMonotonicClockTest {
        val composeState = mutableFloatStateOf(0f)

        repeat(100) { snapshotFlow { composeState.floatValue }.launchIn(backgroundScope) }

        benchmarkRule.measureRepeated {
            composeState.floatValue++
            Snapshot.sendApplyNotifications()
            testScheduler.advanceTimeBy(16)
        }
    }

    // Animations

    private fun MonotonicClockTestScope.keepRunningDuringTest(motionValue: MotionValue) {
        val keepRunningJob = launch { motionValue.keepRunning() }
        doOnTearDown { keepRunningJob.cancel() }
    }

    private val MotionSpec.Companion.ZeroToOne_AtOne
        get() =
            MotionSpec(
                buildDirectionalMotionSpec(
                    defaultSpring = SpringParameters(stiffness = 300f, dampingRatio = .9f),
                    initialMapping = Mapping.Zero,
                ) {
                    constantValue(breakpoint = 1f, value = 1f)
                }
            )

    private val InputDirection.opposite
        get() = if (this == InputDirection.Min) InputDirection.Max else InputDirection.Min

    @Test
    fun unstable_resetGestureContext_readOutput() = runMonotonicClockTest {
        val data = testData(input = 1f, spec = MotionSpec.ZeroToOne_AtOne)
        keepRunningDuringTest(data.motionValue)

        benchmarkRule.measureRepeated {
            currentInput.floatValue += 1f
            motionValue.output
            motionValue.output
            if (data.motionValue.isStable) {
                data.gestureContext.reset(0f, data.gestureContext.direction.opposite)
            }
            data.motionValue.floatValue
            testScheduler.advanceTimeBy(16)
        }
    }

    @Test
    fun unstable_resetGestureContext_snapshotFlowOutput() = runMonotonicClockTest {
        val data = testData(input = 1f, spec = MotionSpec.ZeroToOne_AtOne)
        keepRunningDuringTest(data.motionValue)

        snapshotFlow { data.motionValue.floatValue }.launchIn(backgroundScope)

        benchmarkRule.measureRepeated {
            if (data.motionValue.isStable) {
                data.gestureContext.reset(0f, data.gestureContext.direction.opposite)
            }
            testScheduler.advanceTimeBy(16)
        }
    }

    private val MotionSpec.Companion.ZeroToOne_AtOne_WithGuarantee
        get() =
            MotionSpec(
                buildDirectionalMotionSpec(
                    defaultSpring = SpringParameters(stiffness = 300f, dampingRatio = .9f),
                    initialMapping = Mapping.Zero,
                ) {
                    constantValue(
                        breakpoint = 1f,
                        value = 1f,
                        guarantee = Guarantee.GestureDragDelta(1f),
                    )
                }
            )

    @Test
    fun unstable_resetGestureContext_guarantee_readOutput() = runMonotonicClockTest {
        val data = testData(input = 1f, spec = MotionSpec.ZeroToOne_AtOne_WithGuarantee)
        keepRunningDuringTest(data.motionValue)

        benchmarkRule.measureRepeated {
            if (data.motionValue.isStable) {
                data.gestureContext.reset(0f, data.gestureContext.direction.opposite)
            } else {
                val isMax = data.gestureContext.direction == InputDirection.Max
                data.gestureContext.dragOffset += if (isMax) 0.01f else -0.01f
            }

            data.motionValue.floatValue
            testScheduler.advanceTimeBy(16)
        }
    }
}