Skip to content

Build Android touch event payloads in one JNI call - #59554

Open
hryhoriiK97 wants to merge 4 commits into
react:mainfrom
hryhoriiK97:android-touch-payload-single-call
Open

hryhoriiK97 wants to merge 4 commits into
react:mainfrom
hryhoriiK97:android-touch-payload-single-call

Conversation

@hryhoriiK97

@hryhoriiK97 hryhoriiK97 commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Summary:

On Android, every touch event goes through TouchesHelper.sendTouchEvent, which builds the payload JS receives as nested WritableNativeMaps.

Current behavior. For each pointer of the MotionEvent, createPointersArray creates a native map and puts eight values into it, one JNI call each. Then, for each changed pointer, the payload is assembled from copies of those maps: the event itself is a copy of the changed pointer, and changedTouches and touches are arrays of further copies (copy(), pushMap(), putArray(), each one or more JNI calls). With one finger moving, that is about 20–25 JNI calls and 8 native-backed objects for every ACTION_MOVE.

With this change. TouchesHelper collects the pointer values once into a DoubleArray. For each event it creates an empty WritableNativeMap and passes it with the pointer data to a new native method, TouchesHelper.nativePutTouchPayload, which fills it in C++ through the map's existing putDouble, putInt and putNativeArray. That is two JNI calls per event (creating the map and filling it) instead of 20–25. TouchesHelper still decides which pointers go into changedTouches and touches, now as bit masks of pointer indices (start: the new pointer / all; end: the lifted pointer / all but it; move: all / all; cancel: all / none). The keys, values and value types of the payload are the same as before, and the events are dispatched exactly as before.

WritableNativeMap is unchanged. The native method is registered like TransformHelper's, from TouchesHelper.cpp in reactnativejni_common, and TouchesHelper is @DoNotStrip because the class is looked up by name when the library loads. TouchesHelper is internal, so this adds no public Kotlin API; the C++ API snapshots get the new TouchesHelper class. The layout of the pointer data (eight values per pointer) is documented on nativePutTouchPayload, and the C++ side and the test shadow point to it.

Changelog:

[ANDROID] [CHANGED] - Build touch event payloads in native code, making each touch event cheaper on the main thread

Test Plan:

  • TouchEventDispatchTest passes. It checks the payloads of the start/move/end, start/move/cancel and multi-pointer start/pointer-up/cancel sequences, now through ShadowTouchesHelper, a Kotlin version of nativePutTouchPayload, and compares the map contents. With the end case deliberately broken (lifted pointer kept in touches), 2 of its 3 tests fail.
  • ktfmt and the repository's clang-format report no changes for the touched files. The C++ API snapshots were regenerated with scripts/cxx-api.
  • ./gradlew :packages:rn-tester:android:app:assembleRelease -PreactNativeArchitectures=arm64-v8a succeeds, from main at 01d5527.
  • Same payloads. A local self-test (not part of this PR) built synthetic MotionEvents for 1 to 5 pointers, a pointer going down and up at every index, a move and a cancel, ran them through the previous implementation and through the first version of this PR, and compared the payloads with toHashMap(): 40 events, 60 payloads, no differences, on a moto g57 power. The current version builds the payload with the same C++ code; only the way the result is put into the map changed.
  • Release builds of this PR and of main. Two separate RNTester release APKs (this PR, and main at 01d5527), RNTester Playground screen with a PanResponder drag area, 36 drags of 1.8 s per launch driven by adb shell input swipe (about 4,200 touch events per launch), installs alternated, moto g57 power (Android 16). On-CPU time of the main and JS threads during the drags from /proc, trace sections from Perfetto:
main this PR change
Main thread, 36 drags 9.07 / 8.75 / 8.99 s 8.22 / 8.12 / 8.15 s −8.7%
JS thread, 36 drags 5.01 / 4.88 / 5.05 s 4.97 / 4.92 / 5.00 s no change
TouchesHelper.sentTouchEventModern(topTouchMove) trace section, ~700 events 449 µs 309 µs −31%
Native heap after the drags (dumpsys meminfo) 44.3 / 42.5 / 42.4 MB 33.2 / 33.3 / 33.1 MB −10 MB

The trace-section times include the tracing overhead. FabricEventEmitter.receiveEvent('topTouchMove') is about 11–17 µs slower with this PR (95 → 111 µs), which is small next to the saving in sentTouchEventModern. In every run JS received all touch moves, 10 out of 10 taps on Pressable rows were handled, and there were no errors in logcat.

  • Not run: a C++ unit test of nativePutTouchPayload (JVM tests can't load the native library; it is covered on the device by the runs above and, through the shadow, by TouchEventDispatchTest).

🤖 Generated with Claude Code

TouchesHelper built every touch event payload as nested WritableNativeMaps:
for each pointer, eight JNI put calls, then several copies of the same map
for the event, changedTouches and touches. Collect the pointer values into
a DoubleArray instead and let WritableNativeMap.putTouchPayload build the
whole payload in native code in a single JNI call. TouchesHelper still
decides which pointers are in changedTouches and touches, as bit masks of
pointer indices; the keys, values and value types of the payload are the
same as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 27, 2026
@facebook-github-tools facebook-github-tools Bot added p: Software Mansion Partner: Software Mansion Partner Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. labels Oct 27, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@javache javache left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WritableNativeMap is a generic data structure, it should not be aware of touches.

Instead can we add a native method to TouchesHelper which creates the NativeMap specifically for that use-case?

WritableNativeMap is a generic data structure, so it no longer has a
touch-specific putTouchPayload. TouchesHelper gets a static native method
instead, registered like TransformHelper, which fills an empty
WritableNativeMap through its existing C++ API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hryhoriiK97

Copy link
Copy Markdown
Contributor Author

WritableNativeMap is a generic data structure, it should not be aware of touches.

Instead can we add a native method to TouchesHelper which creates the NativeMap specifically for that use-case?

@javache I've moved it. WritableNativeMap is unchanged now. TouchesHelper has its own native method, registered like TransformHelper, which fills an empty WritableNativeMap through its existing putDouble/putInt/putNativeArray.

I've re-measured on the same device: main thread −8.7% over 36 drags, topTouchMove 449 → 309 µs, same as before.

@hryhoriiK97
hryhoriiK97 requested a review from javache October 28, 2026 10:46
internal object TouchesHelper {
@JvmField @Deprecated("Not used in New Architecture") val TARGET_KEY: String = "target"
init {
ReactNativeJniCommonSoLoader.staticInit()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be ReactNativeJNISoLoader

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, it now loads ReactNativeJNISoLoader and is registered in OnLoad.cpp next to TransformHelper - 279fc96

private const val PAGE_Y_KEY = "pageY"
private const val TIMESTAMP_KEY = "timestamp"
private const val POINTER_IDENTIFIER_KEY = "identifier"
@JvmField @Deprecated("Not used in New Architecture") val TARGET_KEY: String = "target"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has been deprecated for long enough - let's remove it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed 👍

Comment on lines +130 to +131
val eventData = WritableNativeMap()
nativePutTouchPayload(eventData, pointerData, pointer, changedPointers, touchPointers)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Create the WritableNativeMap directly from C++

Suggested change
val eventData = WritableNativeMap()
nativePutTouchPayload(eventData, pointerData, pointer, changedPointers, touchPointers)
val eventData = nativeCreateTouchPayload(pointerData, pointer, changedPointers, touchPointers)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done - 279fc96 via newObjectJavaArgs() + cthis(). newObjectCxxArgs aborts here ("Attempt to set C++ native pointer twice"), since the Kotlin constructor already calls initHybrid. Same perf as before.

…ctnativejni

Address review: nativeCreateTouchPayload now creates the WritableNativeMap in
C++ and returns it, TouchesHelper loads reactnativejni and is registered
there like TransformHelper, and the deprecated TARGET_KEY is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hryhoriiK97
hryhoriiK97 requested a review from javache October 28, 2026 11:49
@meta-codesync

meta-codesync Bot commented Oct 28, 2026

Copy link
Copy Markdown

@javache has imported this pull request. If you are a Meta employee, you can view this in D128055442.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. p: Software Mansion Partner: Software Mansion Partner Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants