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