Merge "Initial Compose pointer input benchmarks." into androidx-master-dev
diff --git a/ui/integration-tests/benchmark/build.gradle b/ui/integration-tests/benchmark/build.gradle
index 5e992e1..40add9b 100644
--- a/ui/integration-tests/benchmark/build.gradle
+++ b/ui/integration-tests/benchmark/build.gradle
@@ -41,6 +41,7 @@
implementation(KOTLIN_REFLECT)
implementation(ANDROIDX_TEST_RULES)
implementation(JUNIT)
+ implementation(TRUTH)
androidTestImplementation project(":compose:ui:ui")
androidTestImplementation project(":compose:foundation:foundation-layout")
diff --git a/ui/integration-tests/benchmark/src/androidTest/AndroidManifest.xml b/ui/integration-tests/benchmark/src/androidTest/AndroidManifest.xml
index 1983f55..d23850c 100644
--- a/ui/integration-tests/benchmark/src/androidTest/AndroidManifest.xml
+++ b/ui/integration-tests/benchmark/src/androidTest/AndroidManifest.xml
@@ -29,5 +29,6 @@
<!-- enable profileableByShell for non-intrusive profiling tools -->
<!--suppress AndroidElementNotAllowed -->
<profileable android:shell="true"/>
+ <activity android:name="androidx.ui.pointerinput.TestActivity" />
</application>
</manifest>
diff --git a/ui/integration-tests/benchmark/src/androidTest/java/androidx/ui/pointerinput/AndroidTapIntegrationBenchmark.kt b/ui/integration-tests/benchmark/src/androidTest/java/androidx/ui/pointerinput/AndroidTapIntegrationBenchmark.kt
new file mode 100644
index 0000000..4d84c53
--- /dev/null
+++ b/ui/integration-tests/benchmark/src/androidTest/java/androidx/ui/pointerinput/AndroidTapIntegrationBenchmark.kt
@@ -0,0 +1,205 @@
+/*
+ * Copyright 2019 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.ui.pointerinput
+
+import android.content.Context
+import android.view.MotionEvent
+import android.view.MotionEvent.ACTION_DOWN
+import android.view.MotionEvent.ACTION_UP
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewGroup.LayoutParams.MATCH_PARENT
+import android.widget.FrameLayout
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.benchmark.junit4.BenchmarkRule
+import androidx.benchmark.junit4.measureRepeated
+import androidx.test.annotation.UiThreadTest
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import com.google.common.truth.Truth.assertThat
+import org.junit.Assert
+import org.junit.Before
+import org.junit.Ignore
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.util.concurrent.TimeUnit
+
+/**
+ * Benchmark for simply tapping on an item in Android.
+ *
+ * The intent is to measure the speed of all parts necessary for a normal tap starting from
+ * MotionEvents getting dispatched to a particular view. The test therefore includes hit
+ * testing and dispatch.
+ *
+ * This is intended to be an equivalent counterpart to [ComposeTapIntegrationBenchmark].
+ *
+ * The hierarchy is set up to look like:
+ * rootView
+ * -> LinearLayout
+ * -> CustomView (with click listener)
+ * -> TextView
+ * -> TextView
+ * -> TextView
+ * -> ...
+ *
+ * MotionEvents are dispatched to rootView as ACTION_DOWN followed by ACTION_UP. The validity of
+ * the test is verified in a custom click listener in CustomView with
+ * com.google.common.truth.Truth.assertThat and by counting the clicks in the click listener and
+ * later verifying that they count is sufficiently high.
+ *
+ * The reason a CustomView is used with a custom click listener is that View's normal click
+ * listener is called via a posted Runnable, which is problematic for the benchmark library and
+ * less equivalent to what Compose does anyway.
+ */
+@LargeTest
+@RunWith(AndroidJUnit4::class)
+class AndroidTapIntegrationBenchmark {
+
+ private lateinit var rootView: View
+ private lateinit var expectedLabel: String
+
+ private var actualClickCount = 0
+ private var expectedClickCount = 0
+
+ @get:Rule
+ val benchmarkRule = BenchmarkRule()
+
+ @Suppress("DEPRECATION")
+ @get:Rule
+ val activityTestRule = androidx.test.rule.ActivityTestRule(TestActivity::class.java)
+
+ @Before
+ fun setup() {
+ val activity = activityTestRule.activity
+ Assert.assertTrue(
+ "timed out waiting for activity focus",
+ activity.hasFocusLatch.await(5, TimeUnit.SECONDS)
+ )
+
+ rootView = activity.findViewById<ViewGroup>(android.R.id.content)
+
+ activityTestRule.runOnUiThread {
+
+ val children = (0 until NumItems).map { i ->
+ CustomView(activity).apply {
+ layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, ItemHeightPx.toInt())
+ label = "$i"
+ clickListener = {
+ assertThat(this.label).isEqualTo(expectedLabel)
+ actualClickCount++
+ }
+ }
+ }
+
+ val linearLayout = LinearLayout(activity).apply {
+ orientation = LinearLayout.VERTICAL
+ layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)
+ children.forEach {
+ addView(it)
+ }
+ }
+
+ activity.setContentView(linearLayout)
+ }
+ }
+
+ // This test requires more hit test processing so changes to hit testing will be tracked more
+ // by this test.
+ @UiThreadTest
+ @Test
+ @Ignore("We don't want this to show up in our benchmark CI tests.")
+ fun clickOnLateItem() {
+ // As items that are laid out last are hit tested first (so z order is respected), item
+ // at 0 will be hit tested late.
+ clickOnItem(0, "0")
+ }
+
+ // This test requires less hit testing so changes to dispatch will be tracked more by this test.
+ @UiThreadTest
+ @Test
+ @Ignore("We don't want this to show up in our benchmark CI tests.")
+ fun clickOnEarlyItem() {
+ // As items that are laid out last are hit tested first (so z order is respected), item
+ // at NumItems - 1 will be hit tested early.
+ val lastItem = NumItems - 1
+ clickOnItem(lastItem, "$lastItem")
+ }
+
+ private fun clickOnItem(item: Int, expectedLabel: String) {
+
+ this.expectedLabel = expectedLabel
+
+ // half height of an item + top of the chosen item = middle of the chosen item
+ val y = (ItemHeightPx / 2) + (item * ItemHeightPx)
+
+ val down = MotionEvent(
+ 0,
+ ACTION_DOWN,
+ 1,
+ 0,
+ arrayOf(PointerProperties(0)),
+ arrayOf(PointerCoords(0f, y)),
+ rootView
+ )
+
+ val up = MotionEvent(
+ 10,
+ ACTION_UP,
+ 1,
+ 0,
+ arrayOf(PointerProperties(0)),
+ arrayOf(PointerCoords(0f, y)),
+ rootView
+ )
+
+ benchmarkRule.measureRepeated {
+ rootView.dispatchTouchEvent(down)
+ rootView.dispatchTouchEvent(up)
+ expectedClickCount++
+ }
+
+ assertThat(actualClickCount).isEqualTo(expectedClickCount)
+ }
+}
+
+private class CustomView(context: Context) : FrameLayout(context) {
+ var label: String
+ get() = textView.text.toString()
+ set(value) {
+ textView.text = value
+ }
+
+ lateinit var clickListener: () -> Unit
+
+ val textView: TextView = TextView(context).apply {
+ layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)
+ }
+
+ init {
+ addView(textView)
+ }
+
+ override fun onTouchEvent(event: MotionEvent?): Boolean {
+ if (event!!.actionMasked == ACTION_UP) {
+ clickListener.invoke()
+ }
+
+ return true
+ }
+}
\ No newline at end of file
diff --git a/ui/integration-tests/benchmark/src/androidTest/java/androidx/ui/pointerinput/ComposeTapIntegrationBenchmark.kt b/ui/integration-tests/benchmark/src/androidTest/java/androidx/ui/pointerinput/ComposeTapIntegrationBenchmark.kt
new file mode 100644
index 0000000..8f592d5
--- /dev/null
+++ b/ui/integration-tests/benchmark/src/androidTest/java/androidx/ui/pointerinput/ComposeTapIntegrationBenchmark.kt
@@ -0,0 +1,187 @@
+/*
+ * Copyright 2019 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.ui.pointerinput
+
+import android.view.View
+import android.view.ViewGroup
+import androidx.benchmark.junit4.BenchmarkRule
+import androidx.benchmark.junit4.measureRepeated
+import androidx.compose.foundation.Text
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.DensityAmbient
+import androidx.compose.ui.platform.setContent
+import androidx.compose.ui.unit.dp
+import androidx.test.annotation.UiThreadTest
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import com.google.common.truth.Truth.assertThat
+import org.junit.Assert
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.util.concurrent.TimeUnit
+
+/**
+ * Benchmark for simply tapping on an item in Compose.
+ *
+ * The intent is to measure the speed of all parts necessary for a normal tap starting from
+ * [MotionEvent]s getting dispatched to a particular view. The test therefore includes hit
+ * testing and dispatch.
+ *
+ * This is intended to be an equivalent counterpart to [AndroidTapIntegrationBenchmark].
+ *
+ * The hierarchy is set up to look like:
+ * rootView
+ * -> Column
+ * -> Text (with click listener)
+ * -> Text (with click listener)
+ * -> Text (with click listener)
+ * -> ...
+ *
+ * MotionEvents are dispatched to rootView as ACTION_DOWN followed by ACTION_UP. The validity of
+ * the test is verified inside the click listener with com.google.common.truth.Truth.assertThat
+ * and by counting the clicks in the click listener and later verifying that they count is
+ * sufficiently high.
+ */
+@LargeTest
+@RunWith(AndroidJUnit4::class)
+class ComposeTapIntegrationBenchmark {
+
+ private lateinit var rootView: View
+ private lateinit var expectedLabel: String
+
+ private var itemHeightDp = 0.dp // Is set to correct value during composition.
+ private var actualClickCount = 0
+ private var expectedClickCount = 0
+
+ @get:Rule
+ val benchmarkRule = BenchmarkRule()
+
+ @Suppress("DEPRECATION")
+ @get:Rule
+ val activityTestRule = androidx.test.rule.ActivityTestRule(TestActivity::class.java)
+
+ @Before
+ fun setup() {
+ val activity = activityTestRule.activity
+ Assert.assertTrue(
+ "timed out waiting for activity focus",
+ activity.hasFocusLatch.await(5, TimeUnit.SECONDS)
+ )
+
+ rootView = activity.findViewById<ViewGroup>(android.R.id.content)
+
+ activityTestRule.runOnUiThreadIR {
+ activity.setContent {
+ with(DensityAmbient.current) {
+ itemHeightDp = ItemHeightPx.toDp()
+ }
+ App()
+ }
+ }
+ }
+
+ // This test requires more hit test processing so changes to hit testing will be tracked more
+ // by this test.
+ @UiThreadTest
+ @Test
+ fun clickOnLateItem() {
+ // As items that are laid out last are hit tested first (so z order is respected), item
+ // at 0 will be hit tested late.
+ clickOnItem(0, "0")
+ }
+
+ // This test requires less hit testing so changes to dispatch will be tracked more by this test.
+ @UiThreadTest
+ @Test
+ fun clickOnEarlyItemFyi() {
+ // As items that are laid out last are hit tested first (so z order is respected), item
+ // at NumItems - 1 will be hit tested early.
+ val lastItem = NumItems - 1
+ clickOnItem(lastItem, "$lastItem")
+ }
+
+ private fun clickOnItem(item: Int, expectedLabel: String) {
+
+ this.expectedLabel = expectedLabel
+
+ // half height of an item + top of the chosen item = middle of the chosen item
+ val y = (ItemHeightPx / 2) + (item * ItemHeightPx)
+
+ val down = MotionEvent(
+ 0,
+ android.view.MotionEvent.ACTION_DOWN,
+ 1,
+ 0,
+ arrayOf(PointerProperties(0)),
+ arrayOf(PointerCoords(0f, y)),
+ rootView
+ )
+
+ val up = MotionEvent(
+ 10,
+ android.view.MotionEvent.ACTION_UP,
+ 1,
+ 0,
+ arrayOf(PointerProperties(0)),
+ arrayOf(PointerCoords(0f, y)),
+ rootView
+ )
+
+ benchmarkRule.measureRepeated {
+ rootView.dispatchTouchEvent(down)
+ rootView.dispatchTouchEvent(up)
+ expectedClickCount++
+ }
+
+ assertThat(actualClickCount).isEqualTo(expectedClickCount)
+ }
+
+ @Composable
+ fun App() {
+ EmailList(NumItems)
+ }
+
+ @Composable
+ fun EmailList(count: Int) {
+ Column {
+ repeat(count) { i ->
+ Email("$i")
+ }
+ }
+ }
+
+ @Composable
+ fun Email(label: String) {
+ Text(
+ text = label,
+ modifier = Modifier
+ .clickable {
+ assertThat(label).isEqualTo(expectedLabel)
+ actualClickCount++
+ }
+ .fillMaxWidth()
+ .height(itemHeightDp)
+ )
+ }
+}
diff --git a/ui/integration-tests/benchmark/src/androidTest/java/androidx/ui/pointerinput/TapIntegrationBenchmarkValues.kt b/ui/integration-tests/benchmark/src/androidTest/java/androidx/ui/pointerinput/TapIntegrationBenchmarkValues.kt
new file mode 100644
index 0000000..903c918
--- /dev/null
+++ b/ui/integration-tests/benchmark/src/androidTest/java/androidx/ui/pointerinput/TapIntegrationBenchmarkValues.kt
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2020 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.ui.pointerinput
+
+val ItemHeightPx = 1f
+val NumItems = 100
\ No newline at end of file
diff --git a/ui/integration-tests/benchmark/src/androidTest/java/androidx/ui/pointerinput/TestActivity.kt b/ui/integration-tests/benchmark/src/androidTest/java/androidx/ui/pointerinput/TestActivity.kt
new file mode 100644
index 0000000..811b1b8
--- /dev/null
+++ b/ui/integration-tests/benchmark/src/androidTest/java/androidx/ui/pointerinput/TestActivity.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2019 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.ui.pointerinput
+
+import androidx.activity.ComponentActivity
+import java.util.concurrent.CountDownLatch
+
+class TestActivity : ComponentActivity() {
+ var hasFocusLatch = CountDownLatch(1)
+
+ override fun onWindowFocusChanged(hasFocus: Boolean) {
+ super.onWindowFocusChanged(hasFocus)
+ if (hasFocus) {
+ hasFocusLatch.countDown()
+ }
+ }
+}
diff --git a/ui/integration-tests/benchmark/src/androidTest/java/androidx/ui/pointerinput/utils.kt b/ui/integration-tests/benchmark/src/androidTest/java/androidx/ui/pointerinput/utils.kt
new file mode 100644
index 0000000..aeb62bf
--- /dev/null
+++ b/ui/integration-tests/benchmark/src/androidTest/java/androidx/ui/pointerinput/utils.kt
@@ -0,0 +1,94 @@
+/*
+ * Copyright 2019 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.ui.pointerinput
+
+import android.view.MotionEvent
+import android.view.View
+
+// We only need this because IR compiler doesn't like converting lambdas to Runnables
+@Suppress("DEPRECATION")
+internal fun androidx.test.rule.ActivityTestRule<*>.runOnUiThreadIR(block: () -> Unit) {
+ val runnable: Runnable = object : Runnable {
+ override fun run() {
+ block()
+ }
+ }
+ runOnUiThread(runnable)
+}
+
+/**
+ * Creates a simple [MotionEvent].
+ *
+ * @param dispatchTarget The [View] that the [MotionEvent] is going to be dispatched to. This
+ * guarantees that the MotionEvent is created correctly for both Compose (which relies on raw
+ * coordinates being correct) and Android (which requires that local coordinates are correct).
+ */
+internal fun MotionEvent(
+ eventTime: Int,
+ action: Int,
+ numPointers: Int,
+ actionIndex: Int,
+ pointerProperties: Array<MotionEvent.PointerProperties>,
+ pointerCoords: Array<MotionEvent.PointerCoords>,
+ dispatchTarget: View
+): MotionEvent {
+
+ val locationOnScreen = IntArray(2) { 0 }
+ dispatchTarget.getLocationOnScreen(locationOnScreen)
+
+ pointerCoords.forEach {
+ it.x += locationOnScreen[0]
+ it.y += locationOnScreen[1]
+ }
+
+ val motionEvent = MotionEvent.obtain(
+ 0,
+ eventTime.toLong(),
+ action + (actionIndex shl MotionEvent.ACTION_POINTER_INDEX_SHIFT),
+ numPointers,
+ pointerProperties,
+ pointerCoords,
+ 0,
+ 0,
+ 0f,
+ 0f,
+ 0,
+ 0,
+ 0,
+ 0
+ ).apply {
+ offsetLocation(-locationOnScreen[0].toFloat(), -locationOnScreen[1].toFloat())
+ }
+
+ pointerCoords.forEach {
+ it.x -= locationOnScreen[0]
+ it.y -= locationOnScreen[1]
+ }
+
+ return motionEvent
+}
+
+@Suppress("RemoveRedundantQualifierName")
+internal fun PointerProperties(id: Int) =
+ MotionEvent.PointerProperties().apply { this.id = id }
+
+@Suppress("RemoveRedundantQualifierName")
+internal fun PointerCoords(x: Float, y: Float) =
+ MotionEvent.PointerCoords().apply {
+ this.x = x
+ this.y = y
+ }
\ No newline at end of file