Skip to content

Commit f3de345

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 f3de345

2 files changed

Lines changed: 114 additions & 36 deletions

File tree

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

Lines changed: 61 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
package com.datadog.android.compose.internal.utils
1010

1111
import androidx.compose.ui.geometry.Rect
12+
import androidx.compose.ui.layout.LayoutCoordinates
1213
import androidx.compose.ui.layout.boundsInWindow
1314
import androidx.compose.ui.node.LayoutNode
1415
import androidx.compose.ui.semantics.Role
@@ -22,11 +23,24 @@ import com.datadog.android.api.feature.FeatureSdkCore
2223
import com.datadog.android.compose.DatadogSemanticsPropertyKey
2324
import com.datadog.android.rum.RumAttributes.ACTION_TARGET_ROLE
2425
import com.datadog.android.rum.RumAttributes.ACTION_TARGET_SELECTED
26+
import java.lang.reflect.Method
2527

2628
internal class LayoutNodeUtils {
2729

2830
private var reflectionFallbackModeActivated = false
2931

32+
// RUM-15813: Kotlin internal accessors in androidx.compose.ui are JVM-mangled with
33+
// a module suffix that is stable per build (plain / "$ui" / "$ui_release"). All three
34+
// prefixes we look up live in the same module, so one resolved suffix is enough to
35+
// build every method name and drop the per-call javaClass.methods linear scan.
36+
//
37+
// Combined with [reflectionFallbackModeActivated] this forms a 3-state machine:
38+
// (activated=false, suffix=null) → initial; try internal then reflection.
39+
// (activated=true, suffix=non-null)→ reflection works; skip internal.
40+
// (activated=true, suffix=null) → reflection proved broken; fall back to internal
41+
// only, avoiding repeated NoSuchMethodException.
42+
private var internalMethodSuffix: String? = null
43+
3044
@Suppress("NestedBlockDepth", "CyclomaticComplexMethod")
3145
fun resolveLayoutNode(node: LayoutNode): TargetNode? {
3246
return runSafe("resolveLayoutNode") {
@@ -99,10 +113,18 @@ 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+
!reflectionFallbackModeActivated -> {
118+
getLayoutNodeBoundsInWindowInternal(node) ?: getLayoutNodeBoundsInWindowReflection(node)
119+
}
120+
121+
internalMethodSuffix != null -> {
122+
getLayoutNodeBoundsInWindowReflection(node)
123+
}
124+
125+
else -> {
126+
getLayoutNodeBoundsInWindowInternal(node)
127+
}
106128
}
107129

108130
private fun getLayoutNodeBoundsInWindowInternal(node: LayoutNode): Rect? = runSafe(
@@ -114,19 +136,38 @@ internal class LayoutNodeUtils {
114136
) {
115137
// TODO RUM-13454 Update compose bom and remove this method
116138
reflectionFallbackModeActivated = true
117-
val coordinates = node.getMethod("getLayoutDelegate")
118-
?.getMethod("getOuterCoordinator")
119-
?.getMethod("getCoordinates")
120-
121-
@Suppress("UnsafeThirdPartyFunctionCall") // it's okay if exception will be thrown here
122-
Class.forName("androidx.compose.ui.layout.LayoutCoordinatesKt")
123-
.getMethod("boundsInWindow", Class.forName("androidx.compose.ui.layout.LayoutCoordinates"))
124-
.invoke(null, coordinates) as? Rect
139+
val coordinates = node.invokeInternalAccessor("getLayoutDelegate")
140+
?.invokeInternalAccessor("getOuterCoordinator")
141+
?.invokeInternalAccessor("getCoordinates") as? LayoutCoordinates
142+
// A null here with an unresolved suffix means reflection can't work at all on this
143+
// build — the next getLayoutNodeBoundsInWindow call will fall through to the
144+
// internal-only branch via the (activated=true, suffix=null) state.
145+
coordinates?.boundsInWindow()
146+
}
147+
148+
@Suppress("UnsafeThirdPartyFunctionCall") // runSafe in the caller swallows any Throwable
149+
private fun Any.invokeInternalAccessor(prefix: String): Any? {
150+
val klass = this.javaClass
151+
val suffix = internalMethodSuffix ?: klass.resolveInternalSuffix(prefix)?.also {
152+
internalMethodSuffix = it
153+
} ?: return null
154+
return klass.tryGetNoArgMethod("$prefix$suffix")?.invoke(this)
125155
}
126156

127-
private fun Any.getMethod(prefix: String): Any? {
128-
return this.javaClass.methods.firstOrNull { it.name == prefix || it.name.startsWith("$prefix$") }
129-
?.invoke(this)
157+
private fun Class<*>.resolveInternalSuffix(prefix: String): String? {
158+
for (suffix in KNOWN_INTERNAL_SUFFIXES) {
159+
if (tryGetNoArgMethod("$prefix$suffix") != null) return suffix
160+
}
161+
return null
162+
}
163+
164+
private fun Class<*>.tryGetNoArgMethod(name: String): Method? {
165+
return try {
166+
@Suppress("UnsafeThirdPartyFunctionCall") // NoSuchMethodException is expected and handled
167+
getMethod(name)
168+
} catch (@Suppress("SwallowedException") e: NoSuchMethodException) {
169+
null
170+
}
130171
}
131172

132173
private fun <T> runSafe(callSite: String, action: () -> T): T? {
@@ -168,5 +209,10 @@ internal class LayoutNodeUtils {
168209
"androidx.compose.foundation.gestures.ScrollableElement"
169210
private const val CLASS_NAME_SELECTABLE_ELEMENT =
170211
"androidx.compose.foundation.selection.SelectableElement"
212+
213+
// Empty suffix covers public accessors; "$ui" and "$ui_release" cover Kotlin-internal
214+
// accessors in the androidx.compose.ui module (the exact suffix depends on build
215+
// configuration). Ordered by likelihood — public first, release build second.
216+
private val KNOWN_INTERNAL_SUFFIXES = listOf("", "\$ui_release", "\$ui")
171217
}
172218
}

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)