1 /*
2  * Copyright 2023 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package androidx.compose.material.ripple.benchmark
18 
19 import androidx.compose.foundation.IndicationNodeFactory
20 import androidx.compose.foundation.indication
21 import androidx.compose.foundation.interaction.HoverInteraction
22 import androidx.compose.foundation.interaction.Interaction
23 import androidx.compose.foundation.interaction.InteractionSource
24 import androidx.compose.foundation.interaction.MutableInteractionSource
25 import androidx.compose.foundation.interaction.PressInteraction
26 import androidx.compose.foundation.layout.Box
27 import androidx.compose.foundation.layout.size
28 import androidx.compose.material.ripple.RippleAlpha
29 import androidx.compose.material.ripple.createRippleModifierNode
30 import androidx.compose.runtime.Composable
31 import androidx.compose.testutils.ComposeBenchmarkScope
32 import androidx.compose.testutils.LayeredComposeTestCase
33 import androidx.compose.testutils.benchmark.ComposeBenchmarkRule
34 import androidx.compose.testutils.benchmark.benchmarkFirstCompose
35 import androidx.compose.testutils.doFramesUntilNoChangesPending
36 import androidx.compose.ui.Modifier
37 import androidx.compose.ui.geometry.Offset
38 import androidx.compose.ui.graphics.Color
39 import androidx.compose.ui.graphics.ColorProducer
40 import androidx.compose.ui.node.DelegatableNode
41 import androidx.compose.ui.platform.ViewRootForTest
42 import androidx.compose.ui.unit.Dp
43 import androidx.compose.ui.unit.dp
44 import androidx.test.ext.junit.runners.AndroidJUnit4
45 import androidx.test.filters.LargeTest
46 import kotlinx.coroutines.delay
47 import kotlinx.coroutines.runBlocking
48 import org.junit.Rule
49 import org.junit.Test
50 import org.junit.runner.RunWith
51 
52 /** Benchmark for Android ripple performance */
53 @LargeTest
54 @RunWith(AndroidJUnit4::class)
55 class RippleBenchmark {
56 
57     @get:Rule val benchmarkRule = ComposeBenchmarkRule()
58 
59     @Test
firstCompositionnull60     fun firstComposition() {
61         val interactionSource = MutableInteractionSource()
62         benchmarkRule.benchmarkFirstCompose {
63             object : LayeredComposeTestCase() {
64                 @Composable
65                 override fun MeasuredContent() {
66                     Box(
67                         Modifier.indication(
68                             interactionSource = interactionSource,
69                             indication = TestRipple
70                         )
71                     )
72                 }
73             }
74         }
75     }
76 
77     /**
78      * Cost of emitting a [PressInteraction] for the first time (so including any work done as part
79      * of collecting the interaction and creating a ripple) and then rendering a frame after that.
80      *
81      * [PressInteraction] tests the RippleDrawable codepath - other [Interaction]s use a simplified
82      * common-code StateLayer.
83      */
84     @Test
initialEmitPressInteractionnull85     fun initialEmitPressInteraction() {
86         val press = PressInteraction.Press(Offset.Zero)
87 
88         with(benchmarkRule) {
89             runBenchmarkFor({ RippleInteractionTestCase() }) {
90                 measureRepeatedOnUiThread {
91                     runWithMeasurementDisabled {
92                         doFramesUntilNoChangesMeasureLayoutOrDrawPending()
93                     }
94 
95                     runBlocking { getTestCase().emitInteraction(press) }
96                     doFrame()
97 
98                     // We explicitly tear down after each iteration so we incur costs for anything
99                     // cached at the view hierarchy level, in this case the RippleContainer.
100                     runWithMeasurementDisabled { disposeContent() }
101                 }
102             }
103         }
104     }
105 
106     /**
107      * Cost of emitting another [PressInteraction] and rendering a frame after we have already
108      * emitted one.
109      */
110     @Test
additionalEmitPressInteractionnull111     fun additionalEmitPressInteraction() {
112         val press1 = PressInteraction.Press(Offset.Zero)
113         val release1 = PressInteraction.Release(press1)
114         val press2 = PressInteraction.Press(Offset.Zero)
115 
116         with(benchmarkRule) {
117             runBenchmarkFor({ RippleInteractionTestCase() }) {
118                 measureRepeatedOnUiThread {
119                     runWithMeasurementDisabled {
120                         doFramesUntilNoChangesMeasureLayoutOrDrawPending()
121                         runBlocking {
122                             getTestCase().emitInteraction(press1)
123                             // Account for RippleHostView#setRippleState logic that will delay
124                             // an exit that happens on the same frame as an enter which will cause
125                             // a callback to be posted, breaking synchronization in this case /
126                             // causing a more costly and rare codepath.
127                             delay(100)
128                             getTestCase().emitInteraction(release1)
129                         }
130                         doFramesUntilNoChangesMeasureLayoutOrDrawPending()
131                     }
132 
133                     runBlocking { getTestCase().emitInteraction(press2) }
134                     doFrame()
135 
136                     runWithMeasurementDisabled { disposeContent() }
137                 }
138             }
139         }
140     }
141 
142     /**
143      * Cost of emitting a [HoverInteraction] for the first time and then rendering a frame after
144      * that.
145      *
146      * [HoverInteraction] ends up being drawn by a common-code StateLayer, as with focus and drag -
147      * so there is no need to test those cases separately.
148      */
149     @Test
initialEmitHoverInteractionnull150     fun initialEmitHoverInteraction() {
151         val hover = HoverInteraction.Enter()
152 
153         with(benchmarkRule) {
154             runBenchmarkFor({ RippleInteractionTestCase() }) {
155                 measureRepeatedOnUiThread {
156                     runWithMeasurementDisabled {
157                         doFramesUntilNoChangesMeasureLayoutOrDrawPending()
158                     }
159 
160                     runBlocking { getTestCase().emitInteraction(hover) }
161                     doFrame()
162 
163                     // We explicitly tear down after each iteration so we incur costs for anything
164                     // cached at the view hierarchy level. There shouldn't be anything cached in
165                     // this way for the hover case, but we do it to be consistent with the press
166                     // case.
167                     runWithMeasurementDisabled { disposeContent() }
168                 }
169             }
170         }
171     }
172 
173     /**
174      * Cost of emitting another [HoverInteraction] and rendering a frame after we have already
175      * emitted one.
176      */
177     @Test
additionalEmitHoverInteractionnull178     fun additionalEmitHoverInteraction() {
179         val hover1 = HoverInteraction.Enter()
180         val unhover1 = HoverInteraction.Exit(hover1)
181         val hover2 = HoverInteraction.Enter()
182 
183         with(benchmarkRule) {
184             runBenchmarkFor({ RippleInteractionTestCase() }) {
185                 measureRepeatedOnUiThread {
186                     runWithMeasurementDisabled {
187                         doFramesUntilNoChangesMeasureLayoutOrDrawPending()
188                         runBlocking {
189                             getTestCase().emitInteraction(hover1)
190                             getTestCase().emitInteraction(unhover1)
191                         }
192                         doFramesUntilNoChangesMeasureLayoutOrDrawPending()
193                     }
194 
195                     runBlocking { getTestCase().emitInteraction(hover2) }
196                     doFrame()
197 
198                     runWithMeasurementDisabled { disposeContent() }
199                 }
200             }
201         }
202     }
203 }
204 
205 /** Test case a ripple that allows emitting [Interaction]s with [emitInteraction]. */
206 @Suppress("DEPRECATION_ERROR")
207 private class RippleInteractionTestCase : LayeredComposeTestCase() {
208     private val interactionSource = MutableInteractionSource()
209 
210     @Composable
MeasuredContentnull211     override fun MeasuredContent() {
212         Box(Modifier.size(100.dp).indication(interactionSource, TestRipple))
213     }
214 
emitInteractionnull215     suspend fun emitInteraction(interaction: Interaction) {
216         interactionSource.emit(interaction)
217     }
218 }
219 
220 /**
221  * [doFramesUntilNoChangesPending] but also accounts for pending measure or layout passes, and
222  * whether the view itself is dirty
223  */
doFramesUntilNoChangesMeasureLayoutOrDrawPendingnull224 private fun ComposeBenchmarkScope<*>.doFramesUntilNoChangesMeasureLayoutOrDrawPending() {
225     val maxAmountOfFrames = 10
226     var framesDone = 0
227     while (framesDone < maxAmountOfFrames) {
228         doFrame()
229         framesDone++
230 
231         fun hasPending(): Boolean {
232             var hasPending = hasPendingChanges()
233             val hostView = getHostView()
234             hasPending = hasPending || (hostView as ViewRootForTest).hasPendingMeasureOrLayout
235             hasPending = hasPending || hostView.isDirty
236             return hasPending
237         }
238 
239         if (!(hasPending())) {
240             // We are stable!
241             return
242         }
243     }
244 
245     // Still not stable
246     throw AssertionError("Changes are still pending after '$maxAmountOfFrames' frames.")
247 }
248 
<lambda>null249 private val TestRipple = TestIndicationNodeFactory({ TestRippleColor }, { TestRippleAlpha })
250 
251 private val TestRippleColor = Color.Red
252 
253 private val TestRippleAlpha =
254     RippleAlpha(draggedAlpha = 0.1f, focusedAlpha = 0.2f, hoveredAlpha = 0.3f, pressedAlpha = 0.4f)
255 
256 private class TestIndicationNodeFactory(
257     private val color: ColorProducer,
258     private val rippleAlpha: () -> RippleAlpha
259 ) : IndicationNodeFactory {
createnull260     override fun create(interactionSource: InteractionSource): DelegatableNode {
261         return createRippleModifierNode(
262             interactionSource = interactionSource,
263             bounded = true,
264             radius = Dp.Unspecified,
265             color = color,
266             rippleAlpha = rippleAlpha
267         )
268     }
269 
equalsnull270     override fun equals(other: Any?): Boolean {
271         if (this === other) return true
272         if (javaClass != other?.javaClass) return false
273 
274         other as TestIndicationNodeFactory
275 
276         if (color != other.color) return false
277         if (rippleAlpha != other.rippleAlpha) return false
278 
279         return true
280     }
281 
hashCodenull282     override fun hashCode(): Int {
283         var result = color.hashCode()
284         result = 31 * result + rippleAlpha.hashCode()
285         return result
286     }
287 }
288