Skip to content

Commit d8b6ce1

Browse files
runningcodeclaude
andauthored
perf(android): Hit-test gestures without getLocationOnScreen (#5595)
* perf(android): Hit-test gestures without getLocationOnScreen (JAVA-534) ViewUtils.findTarget called View.getLocationOnScreen for every visited view, and that walks from the view up to the root each time, making the traversal O(N*depth) per tap and scroll start. Instead, map the touch point down into each child's local coordinate space as we descend the tree — the same way ViewGroup dispatches touch events — so each view costs O(1) and the whole traversal is O(N). The locators still receive the original decor-view-relative coordinates, since the Compose locator hit-tests against window coordinates. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * changelog * Update changelog * test(android): Cover scroll and child matrix in findTarget hit-testing (JAVA-534) The existing test only exercised the left/top offset path of mapToChild. Add cases for a scrolled parent and a non-identity child matrix so the other two coordinate-mapping branches are covered, and switch the class to Robolectric so the real Matrix math runs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Move changelog entry to Unreleased as a performance improvement Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c453856 commit d8b6ce1

5 files changed

Lines changed: 184 additions & 48 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
### Performance
6+
7+
- Speed up touch gesture target detection on deeply nested view hierarchies by hit-testing in local coordinates instead of calling `getLocationOnScreen` per view ([#5595](https://github.com/getsentry/sentry-java/pull/5595))
8+
39
## 8.46.0
410

511
### Fixes

sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java

Lines changed: 67 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
package io.sentry.android.core.internal.gestures;
22

33
import android.content.res.Resources;
4+
import android.graphics.Matrix;
45
import android.view.MotionEvent;
56
import android.view.View;
67
import android.view.ViewGroup;
78
import io.sentry.android.core.SentryAndroidOptions;
89
import io.sentry.internal.gestures.GestureTargetLocator;
910
import io.sentry.internal.gestures.UiElement;
10-
import java.util.LinkedList;
11+
import java.util.ArrayDeque;
1112
import java.util.List;
1213
import java.util.Queue;
1314
import org.jetbrains.annotations.ApiStatus;
@@ -17,39 +18,62 @@
1718
@ApiStatus.Internal
1819
public final class ViewUtils {
1920

20-
private static final int[] coordinates = new int[2];
21-
2221
/**
23-
* Verifies if the given touch coordinates are within the bounds of the given view.
22+
* Verifies if the given touch coordinates, expressed in the view's own local coordinate space,
23+
* are within the bounds of the given view.
2424
*
2525
* @param view the view to check if the touch coordinates are within its bounds
26-
* @param x - the x coordinate of a {@link MotionEvent}
27-
* @param y - the y coordinate of {@link MotionEvent}
26+
* @param localX - the x coordinate of the touch, relative to the view's top-left corner
27+
* @param localY - the y coordinate of the touch, relative to the view's top-left corner
2828
* @return true if the touch coordinates are within the bounds of the view, false otherwise
2929
*/
3030
private static boolean touchWithinBounds(
31-
final @Nullable View view, final float x, final float y) {
31+
final @Nullable View view, final float localX, final float localY) {
3232
if (view == null) {
3333
return false;
3434
}
3535

36-
view.getLocationOnScreen(coordinates);
37-
int vx = coordinates[0];
38-
int vy = coordinates[1];
36+
final int w = view.getWidth();
37+
final int h = view.getHeight();
3938

40-
int w = view.getWidth();
41-
int h = view.getHeight();
39+
return !(localX < 0 || localX > w || localY < 0 || localY > h);
40+
}
4241

43-
return !(x < vx || x > vx + w || y < vy || y > vy + h);
42+
/**
43+
* Maps a touch point expressed in the parent's local coordinate space into the child's local
44+
* coordinate space. This mirrors how {@link ViewGroup} dispatches touch events to its children
45+
* and lets us hit-test the whole tree with a single downward traversal, instead of calling {@link
46+
* View#getLocationOnScreen(int[])} (which walks up to the root) for every view.
47+
*/
48+
private static @NotNull ViewWithLocation mapToChild(
49+
final @NotNull View child,
50+
final float parentX,
51+
final float parentY,
52+
final int parentScrollX,
53+
final int parentScrollY) {
54+
float childX = parentX + parentScrollX - child.getLeft();
55+
float childY = parentY + parentScrollY - child.getTop();
56+
57+
final @Nullable Matrix matrix = child.getMatrix();
58+
if (matrix != null && !matrix.isIdentity()) {
59+
final Matrix inverse = new Matrix();
60+
if (matrix.invert(inverse)) {
61+
final float[] point = {childX, childY};
62+
inverse.mapPoints(point);
63+
childX = point[0];
64+
childY = point[1];
65+
}
66+
}
67+
return new ViewWithLocation(child, childX, childY);
4468
}
4569

4670
/**
4771
* Finds a target view, that has been selected/clicked by the given coordinates x and y and the
4872
* given {@code viewTargetSelector}.
4973
*
5074
* @param decorView - the root view of this window
51-
* @param x - the x coordinate of a {@link MotionEvent}
52-
* @param y - the y coordinate of {@link MotionEvent}
75+
* @param x - the x coordinate of a {@link MotionEvent}, relative to the decor view
76+
* @param y - the y coordinate of {@link MotionEvent}, relative to the decor view
5377
* @param targetType - the type of target to find
5478
* @return the {@link View} that contains the touch coordinates and complements the {@code
5579
* viewTargetSelector}
@@ -62,25 +86,35 @@ private static boolean touchWithinBounds(
6286
final UiElement.Type targetType) {
6387

6488
final List<GestureTargetLocator> locators = options.getGestureTargetLocators();
65-
final Queue<View> queue = new LinkedList<>();
66-
queue.add(decorView);
89+
final Queue<ViewWithLocation> queue = new ArrayDeque<>();
90+
// The touch coordinates from the MotionEvent are already relative to the decor view, i.e. in
91+
// its local coordinate space.
92+
queue.add(new ViewWithLocation(decorView, x, y));
6793

6894
@Nullable UiElement target = null;
69-
while (queue.size() > 0) {
70-
final View view = queue.poll();
95+
while (!queue.isEmpty()) {
96+
final ViewWithLocation current = queue.poll();
97+
final View view = current.view;
7198

72-
if (!touchWithinBounds(view, x, y)) {
99+
if (!touchWithinBounds(view, current.x, current.y)) {
73100
// if the touch is not hitting the view, skip traversal of its children
74101
continue;
75102
}
76103

77104
if (view instanceof ViewGroup) {
78105
final ViewGroup viewGroup = (ViewGroup) view;
106+
final int scrollX = viewGroup.getScrollX();
107+
final int scrollY = viewGroup.getScrollY();
79108
for (int i = 0; i < viewGroup.getChildCount(); i++) {
80-
queue.add(viewGroup.getChildAt(i));
109+
final @Nullable View child = viewGroup.getChildAt(i);
110+
if (child != null) {
111+
queue.add(mapToChild(child, current.x, current.y, scrollX, scrollY));
112+
}
81113
}
82114
}
83115

116+
// Locators receive the original decor-view-relative coordinates, as the Compose locator
117+
// hit-tests against window coordinates.
84118
for (int i = 0; i < locators.size(); i++) {
85119
final GestureTargetLocator locator = locators.get(i);
86120
final @Nullable UiElement newTarget = locator.locate(view, x, y, targetType);
@@ -96,6 +130,18 @@ private static boolean touchWithinBounds(
96130
return target;
97131
}
98132

133+
private static final class ViewWithLocation {
134+
final @NotNull View view;
135+
final float x;
136+
final float y;
137+
138+
ViewWithLocation(final @NotNull View view, final float x, final float y) {
139+
this.view = view;
140+
this.x = x;
141+
this.y = y;
142+
}
143+
}
144+
99145
/**
100146
* Retrieves the human-readable view id based on {@code view.getContext().getResources()}, falls
101147
* back to a hexadecimal id representation in case the view id is not available in the resources.

sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewHelpers.kt

Lines changed: 6 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,6 @@ import android.content.res.Resources
55
import android.view.MotionEvent
66
import android.view.View
77
import android.view.Window
8-
import kotlin.math.abs
9-
import org.mockito.kotlin.any
10-
import org.mockito.kotlin.doAnswer
118
import org.mockito.kotlin.doReturn
129
import org.mockito.kotlin.mock
1310
import org.mockito.kotlin.whenever
@@ -35,31 +32,17 @@ internal inline fun <reified T : View> mockView(
3532
context: Context? = null,
3633
finalize: (T) -> Unit = {},
3734
): T {
38-
val coordinates = IntArray(2)
39-
if (!touchWithinBounds) {
40-
coordinates[0] = (event.x).toInt() + 10
41-
coordinates[1] = (event.y).toInt() + 10
42-
} else {
43-
coordinates[0] = (event.x).toInt() - 10
44-
coordinates[1] = (event.y).toInt() - 10
45-
}
35+
// The decor-view-relative touch point used in these tests is (0, 0), and child views are mocked
36+
// at offset (0, 0), so the point reaches every view unchanged. A view therefore contains the
37+
// touch iff its width/height are non-negative; a negative size marks the touch as outside.
38+
val size = if (touchWithinBounds) 10 else -1
4639
val mockView: T = mock {
4740
whenever(it.id).thenReturn(id)
4841
whenever(it.context).thenReturn(context)
4942
whenever(it.isClickable).thenReturn(clickable)
5043
whenever(it.visibility).thenReturn(if (visible) View.VISIBLE else View.GONE)
51-
52-
whenever(it.getLocationOnScreen(any())).doAnswer {
53-
val array = it.arguments[0] as IntArray
54-
array[0] = coordinates[0]
55-
array[1] = coordinates[1]
56-
null
57-
}
58-
59-
val diffPosX = abs(event.x - coordinates[0]).toInt()
60-
val diffPosY = abs(event.y - coordinates[1]).toInt()
61-
whenever(it.width).thenReturn(diffPosX + 10)
62-
whenever(it.height).thenReturn(diffPosY + 10)
44+
whenever(it.width).thenReturn(size)
45+
whenever(it.height).thenReturn(size)
6346

6447
finalize(this.mock)
6548
}

sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewUtilsTest.kt

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,19 @@ package io.sentry.android.core.internal.gestures
22

33
import android.content.Context
44
import android.content.res.Resources
5+
import android.graphics.Matrix
56
import android.view.View
7+
import android.view.ViewGroup
8+
import androidx.test.ext.junit.runners.AndroidJUnit4
9+
import io.sentry.android.core.SentryAndroidOptions
10+
import io.sentry.internal.gestures.UiElement
11+
import io.sentry.util.LazyEvaluator
612
import kotlin.test.Test
713
import kotlin.test.assertEquals
814
import kotlin.test.assertFailsWith
15+
import kotlin.test.assertNotNull
16+
import kotlin.test.assertNull
17+
import org.junit.runner.RunWith
918
import org.mockito.kotlin.any
1019
import org.mockito.kotlin.doReturn
1120
import org.mockito.kotlin.doThrow
@@ -14,12 +23,13 @@ import org.mockito.kotlin.never
1423
import org.mockito.kotlin.verify
1524
import org.mockito.kotlin.whenever
1625

26+
@RunWith(AndroidJUnit4::class)
1727
class ViewUtilsTest {
1828
@Test
1929
fun `getResourceId returns resourceId when available`() {
2030
val view =
2131
mock<View> {
22-
whenever(it.id).doReturn(View.generateViewId())
32+
whenever(it.id).doReturn(0x7f010001)
2333

2434
val context = mock<Context>()
2535
val resources = mock<Resources>()
@@ -80,6 +90,94 @@ class ViewUtilsTest {
8090
verify(context, never()).resources
8191
}
8292

93+
@Test
94+
fun `findTarget hit-tests children in their own local coordinate space`() {
95+
val child = clickableChild()
96+
val decorView =
97+
mock<ViewGroup> {
98+
whenever(it.width).thenReturn(1000)
99+
whenever(it.height).thenReturn(1000)
100+
whenever(it.childCount).thenReturn(1)
101+
whenever(it.getChildAt(0)).thenReturn(child)
102+
}
103+
val options = optionsWithViewLocator()
104+
105+
// (120, 220) maps to (20, 20) in the child's space -> inside its 50x50 bounds.
106+
assertNotNull(ViewUtils.findTarget(options, decorView, 120f, 220f, UiElement.Type.CLICKABLE))
107+
108+
// (90, 220) maps to (-10, 20) in the child's space -> outside, despite being inside the decor.
109+
assertNull(ViewUtils.findTarget(options, decorView, 90f, 220f, UiElement.Type.CLICKABLE))
110+
}
111+
112+
@Test
113+
fun `findTarget accounts for parent scroll when mapping into a child`() {
114+
val child = clickableChild()
115+
val decorView =
116+
mock<ViewGroup> {
117+
whenever(it.width).thenReturn(1000)
118+
whenever(it.height).thenReturn(1000)
119+
whenever(it.scrollX).thenReturn(30)
120+
whenever(it.scrollY).thenReturn(40)
121+
whenever(it.childCount).thenReturn(1)
122+
whenever(it.getChildAt(0)).thenReturn(child)
123+
}
124+
val options = optionsWithViewLocator()
125+
126+
// With scroll (30, 40), (90, 180) maps to (90 + 30 - 100, 180 + 40 - 200) = (20, 20) -> inside.
127+
assertNotNull(ViewUtils.findTarget(options, decorView, 90f, 180f, UiElement.Type.CLICKABLE))
128+
129+
// The same point without accounting for scroll would map to (-10, -20) -> outside the child.
130+
assertNull(ViewUtils.findTarget(options, decorView, 50f, 140f, UiElement.Type.CLICKABLE))
131+
}
132+
133+
@Test
134+
fun `findTarget applies the inverse of a non-identity child matrix`() {
135+
// The child is visually translated by (40, 40) within its parent, so a parent-space point is
136+
// mapped back by (-40, -40) to reach the child's own coordinate space.
137+
val matrix = Matrix().apply { setTranslate(40f, 40f) }
138+
val child = clickableChild { whenever(it.matrix).thenReturn(matrix) }
139+
val decorView =
140+
mock<ViewGroup> {
141+
whenever(it.width).thenReturn(1000)
142+
whenever(it.height).thenReturn(1000)
143+
whenever(it.childCount).thenReturn(1)
144+
whenever(it.getChildAt(0)).thenReturn(child)
145+
}
146+
val options = optionsWithViewLocator()
147+
148+
// (180, 280) lands at (80, 80) before the matrix (outside 50x50), but the inverse pulls it to
149+
// (40, 40) -> inside.
150+
assertNotNull(ViewUtils.findTarget(options, decorView, 180f, 280f, UiElement.Type.CLICKABLE))
151+
152+
// (130, 230) lands at (30, 30) before the matrix (inside), but the inverse pushes it to
153+
// (-10, -10) -> outside.
154+
assertNull(ViewUtils.findTarget(options, decorView, 130f, 230f, UiElement.Type.CLICKABLE))
155+
}
156+
157+
// A clickable child positioned at (100, 200) within its parent, 50x50 in size.
158+
private fun clickableChild(finalize: (View) -> Unit = {}): View {
159+
val context = mock<Context>()
160+
val resources = mock<Resources>()
161+
whenever(context.resources).thenReturn(resources)
162+
whenever(resources.getResourceEntryName(any())).thenReturn("child")
163+
return mock {
164+
whenever(it.id).thenReturn(0x7f010001)
165+
whenever(it.context).thenReturn(context)
166+
whenever(it.isClickable).thenReturn(true)
167+
whenever(it.visibility).thenReturn(View.VISIBLE)
168+
whenever(it.left).thenReturn(100)
169+
whenever(it.top).thenReturn(200)
170+
whenever(it.width).thenReturn(50)
171+
whenever(it.height).thenReturn(50)
172+
finalize(this.mock)
173+
}
174+
}
175+
176+
private fun optionsWithViewLocator(): SentryAndroidOptions =
177+
SentryAndroidOptions().apply {
178+
gestureTargetLocators = listOf(AndroidViewGestureTargetLocator(LazyEvaluator { true }))
179+
}
180+
83181
@Test
84182
fun `getResourceIdWithFallback falls back to hexadecimal id when resource not found`() {
85183
val view =

sentry-compose/src/androidMain/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocator.kt

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import io.sentry.compose.boundsInWindow
1515
import io.sentry.internal.gestures.GestureTargetLocator
1616
import io.sentry.internal.gestures.UiElement
1717
import io.sentry.util.AutoClosableReentrantLock
18-
import java.util.LinkedList
18+
import java.util.ArrayDeque
1919
import java.util.Queue
2020

2121
@OptIn(InternalComposeUiApi::class)
@@ -45,7 +45,7 @@ public class ComposeGestureTargetLocator(private val logger: ILogger) : GestureT
4545
val rootLayoutNode = root.root
4646

4747
// Pair<Node, ParentTag>
48-
val queue: Queue<Pair<LayoutNode, String?>> = LinkedList()
48+
val queue: Queue<Pair<LayoutNode, String?>> = ArrayDeque()
4949
queue.add(Pair(rootLayoutNode, null))
5050

5151
// the final tag to return, only relevant for clicks
@@ -92,7 +92,10 @@ public class ComposeGestureTargetLocator(private val logger: ILogger) : GestureT
9292
}
9393
}
9494
}
95-
queue.addAll(node.zSortedChildren.asMutableList().map { Pair(it, tag) })
95+
val children = node.zSortedChildren.asMutableList()
96+
for (index in children.indices) {
97+
queue.add(Pair(children[index], tag))
98+
}
9699
}
97100
}
98101

0 commit comments

Comments
 (0)