Skip to content

Commit 367d29a

Browse files
runningcodeclaude
andcommitted
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>
1 parent d8b6ce1 commit 367d29a

4 files changed

Lines changed: 95 additions & 16 deletions

File tree

‎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: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -150,13 +150,12 @@ 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
/**
@@ -167,15 +166,36 @@ static String getResourceIdWithFallback(final @NotNull View view) {
167166
* @throws Resources.NotFoundException in case the view id was not found
168167
*/
169168
public static String getResourceId(final @NotNull View view) throws Resources.NotFoundException {
169+
final @Nullable String resourceId = getResourceIdOrNull(view);
170+
if (resourceId == null) {
171+
throw new Resources.NotFoundException();
172+
}
173+
return resourceId;
174+
}
175+
176+
/**
177+
* Retrieves the human-readable view id based on {@code view.getContext().getResources()}, or
178+
* {@code null} when the view has no resource-backed id. Unlike {@link #getResourceId(View)} this
179+
* does not throw for unresolved ids, avoiding exception-driven control flow on hot, main-thread
180+
* paths such as view-hierarchy snapshots and gesture target resolution.
181+
*
182+
* @param view - the view whose id is being retrieved
183+
* @return human-readable view id, or {@code null} if it cannot be resolved
184+
*/
185+
public static @Nullable String getResourceIdOrNull(final @NotNull View view) {
170186
final int viewId = view.getId();
171187
if (viewId == View.NO_ID || isViewIdGenerated(viewId)) {
172-
throw new Resources.NotFoundException();
188+
return null;
173189
}
174190
final Resources resources = view.getContext().getResources();
175-
if (resources != null) {
191+
if (resources == null) {
192+
return "";
193+
}
194+
try {
176195
return resources.getResourceEntryName(viewId);
196+
} catch (Resources.NotFoundException e) {
197+
return null;
177198
}
178-
return "";
179199
}
180200

181201
private static boolean isViewIdGenerated(int id) {

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

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,65 @@ class ViewUtilsTest {
178178
gestureTargetLocators = listOf(AndroidViewGestureTargetLocator(LazyEvaluator { true }))
179179
}
180180

181+
@Test
182+
fun `getResourceIdOrNull returns resource name when available`() {
183+
val view =
184+
mock<View> {
185+
whenever(it.id).doReturn(0x7f010001)
186+
187+
val context = mock<Context>()
188+
val resources = mock<Resources>()
189+
whenever(resources.getResourceEntryName(it.id)).thenReturn("test_view")
190+
whenever(context.resources).thenReturn(resources)
191+
whenever(it.context).thenReturn(context)
192+
}
193+
194+
assertEquals("test_view", ViewUtils.getResourceIdOrNull(view))
195+
}
196+
197+
@Test
198+
fun `getResourceIdOrNull returns null without throwing for generated id`() {
199+
val context = mock<Context>()
200+
val view =
201+
mock<View> {
202+
// View.generateViewId() starts with 1
203+
whenever(it.id).doReturn(1)
204+
whenever(it.context).thenReturn(context)
205+
}
206+
207+
assertNull(ViewUtils.getResourceIdOrNull(view))
208+
verify(context, never()).resources
209+
}
210+
211+
@Test
212+
fun `getResourceIdOrNull returns null without throwing when view has no id`() {
213+
val context = mock<Context>()
214+
val view =
215+
mock<View> {
216+
whenever(it.id).doReturn(View.NO_ID)
217+
whenever(it.context).thenReturn(context)
218+
}
219+
220+
assertNull(ViewUtils.getResourceIdOrNull(view))
221+
verify(context, never()).resources
222+
}
223+
224+
@Test
225+
fun `getResourceIdOrNull returns null without throwing when resource not found`() {
226+
val view =
227+
mock<View> {
228+
whenever(it.id).doReturn(1234)
229+
230+
val context = mock<Context>()
231+
val resources = mock<Resources>()
232+
whenever(resources.getResourceEntryName(it.id)).thenThrow(Resources.NotFoundException())
233+
whenever(context.resources).thenReturn(resources)
234+
whenever(it.context).thenReturn(context)
235+
}
236+
237+
assertNull(ViewUtils.getResourceIdOrNull(view))
238+
}
239+
181240
@Test
182241
fun `getResourceIdWithFallback falls back to hexadecimal id when resource not found`() {
183242
val view =

0 commit comments

Comments
 (0)