Skip to content

Commit e279b06

Browse files
runningcodeclaude
andauthored
perf(android): Avoid exception-driven control flow in getResourceId (#5631)
* perf(android): Avoid exception-driven control flow in getResourceId ViewUtils.getResourceId threw Resources.NotFoundException for views with no id or a generated id, and callers caught and discarded it. During a view-hierarchy snapshot and on every gesture this ran per view, so in Compose-heavy apps where most views have generated ids the SDK constructed an exception (and a native stack trace fill) per view on the main thread. Add a non-throwing resolveResourceId that returns null for unresolved ids and route the hot callers through it. The public getResourceId remains as a throwing wrapper for backward compatibility. Behavior (emitted identifiers and fallbacks) is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * changelog --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8fe8bad commit e279b06

5 files changed

Lines changed: 85 additions & 87 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
- 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))
1616
- Probe class availability without initializing the class during SDK init ([#5635](https://github.com/getsentry/sentry-java/pull/5635))
17+
- Avoid constructing an exception per view when resolving view ids during view-hierarchy and gesture capture ([#5631](https://github.com/getsentry/sentry-java/pull/5631))
1718

1819
## 8.46.0
1920

sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -256,8 +256,10 @@ private static ViewHierarchyNode viewToNode(@NotNull final View view) {
256256
node.setType(className);
257257

258258
try {
259-
final String identifier = ViewUtils.getResourceId(view);
260-
node.setIdentifier(identifier);
259+
final @Nullable String identifier = ViewUtils.getResourceIdOrNull(view);
260+
if (identifier != null) {
261+
node.setIdentifier(identifier);
262+
}
261263
} catch (Throwable e) {
262264
// ignored
263265
}

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

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package io.sentry.android.core.internal.gestures;
22

3-
import android.content.res.Resources;
43
import android.view.View;
54
import android.widget.AbsListView;
65
import android.widget.ScrollView;
@@ -42,13 +41,12 @@ && isViewScrollable(view, isAndroidXAvailable.getValue())) {
4241
}
4342

4443
private UiElement createUiElement(final @NotNull View targetView) {
45-
try {
46-
final String resourceName = ViewUtils.getResourceId(targetView);
47-
@Nullable String className = ClassUtil.getClassName(targetView);
48-
return new UiElement(targetView, className, resourceName, null, ORIGIN);
49-
} catch (Resources.NotFoundException ignored) {
44+
final @Nullable String resourceName = ViewUtils.getResourceIdOrNull(targetView);
45+
if (resourceName == null) {
5046
return null;
5147
}
48+
@Nullable String className = ClassUtil.getClassName(targetView);
49+
return new UiElement(targetView, className, resourceName, null, ORIGIN);
5250
}
5351

5452
private static boolean isViewTappable(final @NotNull View view) {

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

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -150,32 +150,37 @@ private static final class ViewWithLocation {
150150
* @return human-readable view id
151151
*/
152152
static String getResourceIdWithFallback(final @NotNull View view) {
153-
final int viewId = view.getId();
154-
try {
155-
return getResourceId(view);
156-
} catch (Resources.NotFoundException e) {
153+
final @Nullable String resourceId = getResourceIdOrNull(view);
154+
if (resourceId == null) {
157155
// fall back to hex representation of the id
158-
return "0x" + Integer.toString(viewId, 16);
156+
return "0x" + Integer.toString(view.getId(), 16);
159157
}
158+
return resourceId;
160159
}
161160

162161
/**
163-
* Retrieves the human-readable view id based on {@code view.getContext().getResources()}.
162+
* Retrieves the human-readable view id based on {@code view.getContext().getResources()}, or
163+
* {@code null} when the view has no resource-backed id. Returning {@code null} rather than
164+
* throwing avoids exception-driven control flow on hot, main-thread paths such as view-hierarchy
165+
* snapshots and gesture target resolution.
164166
*
165167
* @param view - the view whose id is being retrieved
166-
* @return human-readable view id
167-
* @throws Resources.NotFoundException in case the view id was not found
168+
* @return human-readable view id, or {@code null} if it cannot be resolved
168169
*/
169-
public static String getResourceId(final @NotNull View view) throws Resources.NotFoundException {
170+
public static @Nullable String getResourceIdOrNull(final @NotNull View view) {
170171
final int viewId = view.getId();
171172
if (viewId == View.NO_ID || isViewIdGenerated(viewId)) {
172-
throw new Resources.NotFoundException();
173+
return null;
173174
}
174175
final Resources resources = view.getContext().getResources();
175-
if (resources != null) {
176+
if (resources == null) {
177+
return "";
178+
}
179+
try {
176180
return resources.getResourceEntryName(viewId);
181+
} catch (Resources.NotFoundException e) {
182+
return null;
177183
}
178-
return "";
179184
}
180185

181186
private static boolean isViewIdGenerated(int id) {

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

Lines changed: 59 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -11,85 +11,18 @@ import io.sentry.internal.gestures.UiElement
1111
import io.sentry.util.LazyEvaluator
1212
import kotlin.test.Test
1313
import kotlin.test.assertEquals
14-
import kotlin.test.assertFailsWith
1514
import kotlin.test.assertNotNull
1615
import kotlin.test.assertNull
1716
import org.junit.runner.RunWith
1817
import org.mockito.kotlin.any
1918
import org.mockito.kotlin.doReturn
20-
import org.mockito.kotlin.doThrow
2119
import org.mockito.kotlin.mock
2220
import org.mockito.kotlin.never
2321
import org.mockito.kotlin.verify
2422
import org.mockito.kotlin.whenever
2523

2624
@RunWith(AndroidJUnit4::class)
2725
class ViewUtilsTest {
28-
@Test
29-
fun `getResourceId returns resourceId when available`() {
30-
val view =
31-
mock<View> {
32-
whenever(it.id).doReturn(0x7f010001)
33-
34-
val context = mock<Context>()
35-
val resources = mock<Resources>()
36-
whenever(resources.getResourceEntryName(it.id)).thenReturn("test_view")
37-
whenever(context.resources).thenReturn(resources)
38-
whenever(it.context).thenReturn(context)
39-
}
40-
41-
assertEquals(ViewUtils.getResourceId(view), "test_view")
42-
}
43-
44-
@Test
45-
fun `getResourceId throws when resource id is not available`() {
46-
val view =
47-
mock<View> {
48-
whenever(it.id).doReturn(View.generateViewId())
49-
50-
val context = mock<Context>()
51-
val resources = mock<Resources>()
52-
whenever(resources.getResourceEntryName(any())).doThrow(Resources.NotFoundException())
53-
whenever(context.resources).thenReturn(resources)
54-
whenever(it.context).thenReturn(context)
55-
}
56-
57-
assertFailsWith<Resources.NotFoundException> { ViewUtils.getResourceId(view) }
58-
}
59-
60-
@Test
61-
fun `when view has no id set, resource name is not looked up `() {
62-
val context = mock<Context>()
63-
val resources = mock<Resources>()
64-
whenever(context.resources).thenReturn(resources)
65-
66-
val view =
67-
mock<View> {
68-
whenever(it.id).doReturn(View.NO_ID)
69-
whenever(it.context).thenReturn(context)
70-
}
71-
72-
assertFailsWith<Resources.NotFoundException> { ViewUtils.getResourceId(view) }
73-
verify(context, never()).resources
74-
}
75-
76-
@Test
77-
fun `when view id is generated, resource name is not looked up `() {
78-
val context = mock<Context>()
79-
val resources = mock<Resources>()
80-
whenever(context.resources).thenReturn(resources)
81-
82-
val view =
83-
mock<View> {
84-
// View.generateViewId() starts with 1
85-
whenever(it.id).doReturn(1)
86-
whenever(it.context).thenReturn(context)
87-
}
88-
89-
assertFailsWith<Resources.NotFoundException> { ViewUtils.getResourceId(view) }
90-
verify(context, never()).resources
91-
}
92-
9326
@Test
9427
fun `findTarget hit-tests children in their own local coordinate space`() {
9528
val child = clickableChild()
@@ -178,6 +111,65 @@ class ViewUtilsTest {
178111
gestureTargetLocators = listOf(AndroidViewGestureTargetLocator(LazyEvaluator { true }))
179112
}
180113

114+
@Test
115+
fun `getResourceIdOrNull returns resource name when available`() {
116+
val view =
117+
mock<View> {
118+
whenever(it.id).doReturn(0x7f010001)
119+
120+
val context = mock<Context>()
121+
val resources = mock<Resources>()
122+
whenever(resources.getResourceEntryName(it.id)).thenReturn("test_view")
123+
whenever(context.resources).thenReturn(resources)
124+
whenever(it.context).thenReturn(context)
125+
}
126+
127+
assertEquals("test_view", ViewUtils.getResourceIdOrNull(view))
128+
}
129+
130+
@Test
131+
fun `getResourceIdOrNull returns null without throwing for generated id`() {
132+
val context = mock<Context>()
133+
val view =
134+
mock<View> {
135+
// View.generateViewId() starts with 1
136+
whenever(it.id).doReturn(1)
137+
whenever(it.context).thenReturn(context)
138+
}
139+
140+
assertNull(ViewUtils.getResourceIdOrNull(view))
141+
verify(context, never()).resources
142+
}
143+
144+
@Test
145+
fun `getResourceIdOrNull returns null without throwing when view has no id`() {
146+
val context = mock<Context>()
147+
val view =
148+
mock<View> {
149+
whenever(it.id).doReturn(View.NO_ID)
150+
whenever(it.context).thenReturn(context)
151+
}
152+
153+
assertNull(ViewUtils.getResourceIdOrNull(view))
154+
verify(context, never()).resources
155+
}
156+
157+
@Test
158+
fun `getResourceIdOrNull returns null without throwing when resource not found`() {
159+
val view =
160+
mock<View> {
161+
whenever(it.id).doReturn(1234)
162+
163+
val context = mock<Context>()
164+
val resources = mock<Resources>()
165+
whenever(resources.getResourceEntryName(it.id)).thenThrow(Resources.NotFoundException())
166+
whenever(context.resources).thenReturn(resources)
167+
whenever(it.context).thenReturn(context)
168+
}
169+
170+
assertNull(ViewUtils.getResourceIdOrNull(view))
171+
}
172+
181173
@Test
182174
fun `getResourceIdWithFallback falls back to hexadecimal id when resource not found`() {
183175
val view =

0 commit comments

Comments
 (0)