Skip to content

Commit c63143c

Browse files
committed
RUM-15813: Skip method scan in LayoutNodeUtils reflection fallback
The Compose reflection fallback previously built a LayoutNode's method array and scanned it on every ACTION_MOVE hit-test to find the mangled accessors (getLayoutDelegate / getOuterCoordinator / getCoordinates). Resolve the JVM module suffix once per instance ("", "$ui_release" or "$ui") and build every subsequent method name via direct getMethod lookup, so the hot path no longer allocates the methods array or walks it. Also memoize the boundsInWindow reflection Method by lazy.
1 parent f47ab32 commit c63143c

2 files changed

Lines changed: 126 additions & 33 deletions

File tree

integrations/dd-sdk-android-compose/src/main/kotlin/com/datadog/android/compose/internal/utils/LayoutNodeUtils.kt

Lines changed: 73 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,25 @@ import com.datadog.android.api.feature.FeatureSdkCore
2222
import com.datadog.android.compose.DatadogSemanticsPropertyKey
2323
import com.datadog.android.rum.RumAttributes.ACTION_TARGET_ROLE
2424
import com.datadog.android.rum.RumAttributes.ACTION_TARGET_SELECTED
25+
import java.lang.reflect.Method
2526

2627
internal class LayoutNodeUtils {
2728

2829
private var reflectionFallbackModeActivated = false
2930

31+
// RUM-15813: circuit breaker for the reflection fallback. Flipped to true only when
32+
// reflection is proven unusable on this instance (missing boundsInWindow Method or no
33+
// accessor suffix resolvable). After that we stop entering the reflection branch — it
34+
// would keep throwing NoSuchMethodException on every ACTION_MOVE otherwise.
35+
private var reflectionFallbackDisabled = false
36+
37+
// RUM-15813: Kotlin internal accessors in androidx.compose.ui are JVM-mangled with
38+
// a module suffix that is stable per build (plain / "$ui" / "$ui_release"). All three
39+
// prefixes we look up live in the same module, so one resolved suffix is enough to
40+
// build every method name and drop the per-call javaClass.methods linear scan.
41+
private var internalMethodSuffix: String? = null
42+
private val boundsInWindowReflectionMethod: Method? by lazy { resolveBoundsInWindowMethod() }
43+
3044
@Suppress("NestedBlockDepth", "CyclomaticComplexMethod")
3145
fun resolveLayoutNode(node: LayoutNode): TargetNode? {
3246
return runSafe("resolveLayoutNode") {
@@ -99,10 +113,10 @@ internal class LayoutNodeUtils {
99113
}
100114
}
101115

102-
fun getLayoutNodeBoundsInWindow(node: LayoutNode): Rect? = if (reflectionFallbackModeActivated) {
103-
getLayoutNodeBoundsInWindowReflection(node)
104-
} else {
105-
getLayoutNodeBoundsInWindowInternal(node) ?: getLayoutNodeBoundsInWindowReflection(node)
116+
fun getLayoutNodeBoundsInWindow(node: LayoutNode): Rect? = when {
117+
reflectionFallbackDisabled -> getLayoutNodeBoundsInWindowInternal(node)
118+
reflectionFallbackModeActivated -> getLayoutNodeBoundsInWindowReflection(node)
119+
else -> getLayoutNodeBoundsInWindowInternal(node) ?: getLayoutNodeBoundsInWindowReflection(node)
106120
}
107121

108122
private fun getLayoutNodeBoundsInWindowInternal(node: LayoutNode): Rect? = runSafe(
@@ -114,19 +128,61 @@ internal class LayoutNodeUtils {
114128
) {
115129
// TODO RUM-13454 Update compose bom and remove this method
116130
reflectionFallbackModeActivated = true
117-
val coordinates = node.getMethod("getLayoutDelegate")
118-
?.getMethod("getOuterCoordinator")
119-
?.getMethod("getCoordinates")
131+
val method = boundsInWindowReflectionMethod
132+
if (method == null) {
133+
reflectionFallbackDisabled = true
134+
return@runSafe null
135+
}
136+
val coordinates = node.invokeInternalAccessor("getLayoutDelegate")
137+
?.invokeInternalAccessor("getOuterCoordinator")
138+
?.invokeInternalAccessor("getCoordinates")
139+
if (coordinates == null) {
140+
// Only disable when suffix resolution itself never succeeded — i.e. no accessor
141+
// exists for any known mangling. A transient null mid-chain on a working build
142+
// shouldn't permanently disable the branch.
143+
if (internalMethodSuffix == null) {
144+
reflectionFallbackDisabled = true
145+
}
146+
return@runSafe null
147+
}
120148

121149
@Suppress("UnsafeThirdPartyFunctionCall") // it's okay if exception will be thrown here
150+
method.invoke(null, coordinates) as? Rect
151+
}
152+
153+
private fun resolveBoundsInWindowMethod(): Method? = runSafe(
154+
"resolveBoundsInWindowMethod"
155+
) {
156+
@Suppress("UnsafeThirdPartyFunctionCall") // runSafe swallows any Throwable
157+
val coordinatesClass = Class.forName("androidx.compose.ui.layout.LayoutCoordinates")
158+
@Suppress("UnsafeThirdPartyFunctionCall") // runSafe swallows any Throwable
122159
Class.forName("androidx.compose.ui.layout.LayoutCoordinatesKt")
123-
.getMethod("boundsInWindow", Class.forName("androidx.compose.ui.layout.LayoutCoordinates"))
124-
.invoke(null, coordinates) as? Rect
160+
.getMethod("boundsInWindow", coordinatesClass)
125161
}
126162

127-
private fun Any.getMethod(prefix: String): Any? {
128-
return this.javaClass.methods.firstOrNull { it.name == prefix || it.name.startsWith("$prefix$") }
129-
?.invoke(this)
163+
@Suppress("UnsafeThirdPartyFunctionCall") // runSafe in the caller swallows any Throwable
164+
private fun Any.invokeInternalAccessor(prefix: String): Any? {
165+
val klass = this.javaClass
166+
val suffix = internalMethodSuffix ?: klass.resolveInternalSuffix(prefix)?.also {
167+
internalMethodSuffix = it
168+
} ?: return null
169+
return klass.tryGetNoArgMethod("$prefix$suffix")?.invoke(this)
170+
}
171+
172+
private fun Class<*>.resolveInternalSuffix(prefix: String): String? {
173+
for (suffix in KNOWN_INTERNAL_SUFFIXES) {
174+
if (tryGetNoArgMethod("$prefix$suffix") != null) return suffix
175+
}
176+
return null
177+
}
178+
179+
private fun Class<*>.tryGetNoArgMethod(name: String): Method? {
180+
return try {
181+
@Suppress("UnsafeThirdPartyFunctionCall") // NoSuchMethodException is expected and handled
182+
getMethod(name)
183+
} catch (@Suppress("SwallowedException") e: NoSuchMethodException) {
184+
null
185+
}
130186
}
131187

132188
private fun <T> runSafe(callSite: String, action: () -> T): T? {
@@ -168,5 +224,10 @@ internal class LayoutNodeUtils {
168224
"androidx.compose.foundation.gestures.ScrollableElement"
169225
private const val CLASS_NAME_SELECTABLE_ELEMENT =
170226
"androidx.compose.foundation.selection.SelectableElement"
227+
228+
// Empty suffix covers public accessors; "$ui" and "$ui_release" cover Kotlin-internal
229+
// accessors in the androidx.compose.ui module (the exact suffix depends on build
230+
// configuration). Ordered by likelihood — public first, release build second.
231+
private val KNOWN_INTERNAL_SUFFIXES = listOf("", "\$ui_release", "\$ui")
171232
}
172233
}

integrations/dd-sdk-android-compose/src/test/kotlin/com/datadog/android/compose/internal/utils/LayoutNodeGetMethodTest.kt

Lines changed: 53 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import org.junit.jupiter.api.Test
1111
import java.lang.reflect.Method
1212

1313
/**
14-
* Tests for the [LayoutNodeUtils.getMethod] reflection helper that resolves
14+
* Tests for the [LayoutNodeUtils.invokeInternalAccessor] reflection helper that resolves
1515
* Kotlin-internal method names regardless of module-suffix mangling.
1616
*
1717
* Compose internal properties are compiled to JVM methods whose names are mangled
@@ -21,50 +21,51 @@ import java.lang.reflect.Method
2121
*/
2222
internal class LayoutNodeGetMethodTest {
2323

24-
private val testedLayoutNodeUtils = LayoutNodeUtils()
25-
26-
// getMethod is private fun Any.getMethod(prefix: String): Any? compiled as an
27-
// instance method with signature getMethod(Object, String) in the JVM.
28-
private val getMethodFn: Method = LayoutNodeUtils::class.java
29-
.getDeclaredMethod("getMethod", Any::class.java, String::class.java)
24+
// invokeInternalAccessor is private fun Any.invokeInternalAccessor(prefix: String): Any? compiled
25+
// as an instance method with signature invokeInternalAccessor(Object, String) in the JVM.
26+
private val invokeInternalAccessorFn: Method = LayoutNodeUtils::class.java
27+
.getDeclaredMethod("invokeInternalAccessor", Any::class.java, String::class.java)
3028
.also { it.isAccessible = true }
3129

3230
// region $ui suffix
3331

3432
@Test
35-
fun `M find method W getMethod {method has dollar ui suffix}`() {
33+
fun `M find method W invokeInternalAccessor {method has dollar ui suffix}`() {
3634
// Given
35+
val tested = LayoutNodeUtils()
3736
val expected = Any()
3837
val fakeNode = FakeLayoutNodeUi(expected)
3938

4039
// When
41-
val result = getMethodFn.invoke(testedLayoutNodeUtils, fakeNode, "getLayoutDelegate")
40+
val result = invokeInternalAccessorFn.invoke(tested, fakeNode, "getLayoutDelegate")
4241

4342
// Then
4443
assertThat(result).isSameAs(expected)
4544
}
4645

4746
@Test
48-
fun `M find method W getMethod {outer coordinator method has dollar ui suffix}`() {
47+
fun `M find method W invokeInternalAccessor {outer coordinator method has dollar ui suffix}`() {
4948
// Given
49+
val tested = LayoutNodeUtils()
5050
val expected = Any()
5151
val fakeNode = FakeLayoutNodeUi(expected)
5252

5353
// When
54-
val result = getMethodFn.invoke(testedLayoutNodeUtils, fakeNode, "getOuterCoordinator")
54+
val result = invokeInternalAccessorFn.invoke(tested, fakeNode, "getOuterCoordinator")
5555

5656
// Then
5757
assertThat(result).isSameAs(expected)
5858
}
5959

6060
@Test
61-
fun `M find method W getMethod {coordinates method has dollar ui suffix}`() {
61+
fun `M find method W invokeInternalAccessor {coordinates method has dollar ui suffix}`() {
6262
// Given
63+
val tested = LayoutNodeUtils()
6364
val expected = Any()
6465
val fakeNode = FakeLayoutNodeUi(expected)
6566

6667
// When
67-
val result = getMethodFn.invoke(testedLayoutNodeUtils, fakeNode, "getCoordinates")
68+
val result = invokeInternalAccessorFn.invoke(tested, fakeNode, "getCoordinates")
6869

6970
// Then
7071
assertThat(result).isSameAs(expected)
@@ -75,55 +76,86 @@ internal class LayoutNodeGetMethodTest {
7576
// region $ui_release suffix
7677

7778
@Test
78-
fun `M find method W getMethod {method has dollar ui_release suffix}`() {
79+
fun `M find method W invokeInternalAccessor {method has dollar ui_release suffix}`() {
7980
// Given
81+
val tested = LayoutNodeUtils()
8082
val expected = Any()
8183
val fakeNode = FakeLayoutNodeUiRelease(expected)
8284

8385
// When
84-
val result = getMethodFn.invoke(testedLayoutNodeUtils, fakeNode, "getLayoutDelegate")
86+
val result = invokeInternalAccessorFn.invoke(tested, fakeNode, "getLayoutDelegate")
8587

8688
// Then
8789
assertThat(result).isSameAs(expected)
8890
}
8991

9092
@Test
91-
fun `M find method W getMethod {outer coordinator method has dollar ui_release suffix}`() {
93+
fun `M find method W invokeInternalAccessor {outer coordinator method has dollar ui_release suffix}`() {
9294
// Given
95+
val tested = LayoutNodeUtils()
9396
val expected = Any()
9497
val fakeNode = FakeLayoutNodeUiRelease(expected)
9598

9699
// When
97-
val result = getMethodFn.invoke(testedLayoutNodeUtils, fakeNode, "getOuterCoordinator")
100+
val result = invokeInternalAccessorFn.invoke(tested, fakeNode, "getOuterCoordinator")
98101

99102
// Then
100103
assertThat(result).isSameAs(expected)
101104
}
102105

103106
@Test
104-
fun `M find method W getMethod {coordinates method has dollar ui_release suffix}`() {
107+
fun `M find method W invokeInternalAccessor {coordinates method has dollar ui_release suffix}`() {
105108
// Given
109+
val tested = LayoutNodeUtils()
106110
val expected = Any()
107111
val fakeNode = FakeLayoutNodeUiRelease(expected)
108112

109113
// When
110-
val result = getMethodFn.invoke(testedLayoutNodeUtils, fakeNode, "getCoordinates")
114+
val result = invokeInternalAccessorFn.invoke(tested, fakeNode, "getCoordinates")
111115

112116
// Then
113117
assertThat(result).isSameAs(expected)
114118
}
115119

116120
// endregion
117121

122+
// region suffix reuse
123+
124+
@Test
125+
fun `M reuse resolved suffix W invokeInternalAccessor {subsequent prefixes on same module}`() {
126+
// Given — once the first lookup resolves a suffix, later prefixes must reuse it
127+
// without re-probing, because all accessors in the same compose-ui module share it.
128+
val tested = LayoutNodeUtils()
129+
val expected = Any()
130+
val fakeNode = FakeLayoutNodeUi(expected)
131+
val suffixField = LayoutNodeUtils::class.java
132+
.getDeclaredField("internalMethodSuffix")
133+
.also { it.isAccessible = true }
134+
135+
// When
136+
invokeInternalAccessorFn.invoke(tested, fakeNode, "getLayoutDelegate")
137+
val suffixAfterFirst = suffixField.get(tested)
138+
invokeInternalAccessorFn.invoke(tested, fakeNode, "getOuterCoordinator")
139+
invokeInternalAccessorFn.invoke(tested, fakeNode, "getCoordinates")
140+
val suffixAfterThird = suffixField.get(tested)
141+
142+
// Then
143+
assertThat(suffixAfterFirst).isEqualTo("\$ui")
144+
assertThat(suffixAfterThird).isEqualTo("\$ui")
145+
}
146+
147+
// endregion
148+
118149
// region no match
119150

120151
@Test
121-
fun `M return null W getMethod {no matching method}`() {
152+
fun `M return null W invokeInternalAccessor {no matching method}`() {
122153
// Given
154+
val tested = LayoutNodeUtils()
123155
val fakeNode = Any()
124156

125157
// When
126-
val result = getMethodFn.invoke(testedLayoutNodeUtils, fakeNode, "getLayoutDelegate")
158+
val result = invokeInternalAccessorFn.invoke(tested, fakeNode, "getLayoutDelegate")
127159

128160
// Then
129161
assertThat(result).isNull()

0 commit comments

Comments
 (0)