diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java index 97de9935b8cf..30ab976a4941 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java @@ -58,6 +58,7 @@ import com.facebook.react.fabric.internal.interop.InteropUIBlockListener; import com.facebook.react.fabric.interop.UIBlock; import com.facebook.react.fabric.interop.UIBlockViewResolver; +import com.facebook.react.fabric.mounting.DisplayFrameInterval; import com.facebook.react.fabric.mounting.MountItemDispatcher; import com.facebook.react.fabric.mounting.MountingManager; import com.facebook.react.fabric.mounting.SurfaceMountingManager; @@ -182,6 +183,7 @@ public class FabricUIManager private final ReactApplicationContext mReactApplicationContext; private final MountingManager mMountingManager; private final FabricEventDispatcher mEventDispatcher; + private final DisplayFrameInterval mDisplayFrameInterval; private final MountItemDispatcher mMountItemDispatcher; private final ViewManagerRegistry mViewManagerRegistry; @@ -252,8 +254,10 @@ public FabricUIManager( mDispatchUIFrameCallback = new DispatchUIFrameCallback(reactContext); mReactApplicationContext = reactContext; mMountingManager = new MountingManager(viewManagerRegistry, mMountItemExecutor); + mDisplayFrameInterval = new DisplayFrameInterval(reactContext); mMountItemDispatcher = - new MountItemDispatcher(mMountingManager, new MountItemDispatchListener()); + new MountItemDispatcher( + mMountingManager, new MountItemDispatchListener(), mDisplayFrameInterval); mEventDispatcher = new FabricEventDispatcher(reactContext, new FabricEventEmitter(this)); mBatchEventDispatchedListener = batchEventDispatchedListener; mReactApplicationContext.addLifecycleEventListener(this); @@ -459,6 +463,7 @@ public void stopSurface(final int surfaceID) { @Override public void initialize() { mEventDispatcher.addBatchEventDispatchedListener(mBatchEventDispatchedListener); + mDisplayFrameInterval.start(); if (ReactNativeFeatureFlags.enableFabricLogs()) { mDevToolsReactPerfLogger = new DevToolsReactPerfLogger(); mDevToolsReactPerfLogger.addDevToolsReactPerfLoggerListener(FABRIC_PERF_LOGGER); @@ -494,6 +499,7 @@ public void invalidate() { mEventDispatcher.removeBatchEventDispatchedListener(mBatchEventDispatchedListener); mEventDispatcher.invalidate(); + mDisplayFrameInterval.stop(); mReactApplicationContext.unregisterComponentCallbacks(mViewManagerRegistry); mViewManagerRegistry.invalidate(); diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/DisplayFrameInterval.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/DisplayFrameInterval.kt new file mode 100644 index 000000000000..8b5b63f140f3 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/DisplayFrameInterval.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.fabric.mounting + +import android.content.Context +import android.hardware.display.DisplayManager +import android.view.Display +import com.facebook.react.bridge.UiThreadUtil + +/** + * The default display's frame interval at its current refresh rate. It is read again when the + * display reports a change, so a frame never reads the display itself. + */ +internal class DisplayFrameInterval(context: Context) : + MountItemDispatcher.FrameIntervalSource, DisplayManager.DisplayListener { + private val displayManager: DisplayManager? = context.getSystemService(DisplayManager::class.java) + + // From Android 16 a listener gets refresh-rate changes only once the process has read the rate. + @Volatile private var frameIntervalNanos: Long? = readFrameIntervalNanos() + + fun start() { + displayManager?.registerDisplayListener(this, UiThreadUtil.getUiThreadHandler()) + frameIntervalNanos = readFrameIntervalNanos() + } + + fun stop() { + displayManager?.unregisterDisplayListener(this) + } + + override fun frameIntervalNanos(): Long? = frameIntervalNanos + + override fun onDisplayChanged(displayId: Int) { + if (displayId == Display.DEFAULT_DISPLAY) { + frameIntervalNanos = readFrameIntervalNanos() + } + } + + override fun onDisplayAdded(displayId: Int): Unit = Unit + + override fun onDisplayRemoved(displayId: Int): Unit = Unit + + private fun readFrameIntervalNanos(): Long? { + val refreshRate = displayManager?.getDisplay(Display.DEFAULT_DISPLAY)?.refreshRate ?: 0f + return if (refreshRate > 0f) (NANOSECONDS_PER_SECOND / refreshRate).toLong() else null + } + + private companion object { + private const val NANOSECONDS_PER_SECOND: Double = 1_000_000_000.0 + } +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/MountItemDispatcher.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/MountItemDispatcher.kt index 72efd7c42058..5334d4afcc07 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/MountItemDispatcher.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/MountItemDispatcher.kt @@ -7,7 +7,6 @@ package com.facebook.react.fabric.mounting -import android.os.SystemClock import android.view.View import androidx.annotation.UiThread import com.facebook.common.logging.FLog @@ -17,6 +16,7 @@ import com.facebook.react.bridge.ReactIgnorableMountingException import com.facebook.react.bridge.ReactNoCrashSoftException import com.facebook.react.bridge.ReactSoftExceptionLogger import com.facebook.react.bridge.RetryableMountingLayerException +import com.facebook.react.common.SystemClock import com.facebook.react.fabric.FabricUIManager import com.facebook.react.fabric.mounting.mountitems.DispatchCommandMountItem import com.facebook.react.fabric.mounting.mountitems.MountItem @@ -29,6 +29,7 @@ import java.util.concurrent.ConcurrentLinkedQueue internal class MountItemDispatcher( private val mountingManager: MountingManager, private val itemDispatchListener: ItemDispatchListener, + private val frameIntervalSource: FrameIntervalSource, ) { private val viewCommandMountItems: Queue = ConcurrentLinkedQueue() private val mountItems: Queue = ConcurrentLinkedQueue() @@ -336,10 +337,19 @@ internal class MountItemDispatcher( return } - val deadline = lastFrameTimeNanos + FRAME_TIME_NS / 2 + val deadline = lastFrameTimeNanos + computePreMountBudgetNanos() dispatchPreMountItemsImpl(deadline) } + /** + * Half of the display's current frame, and never longer than [LONGEST_PRE_MOUNT_NANOS]. A display + * that reports no refresh rate is budgeted as a 60 Hz one. + */ + private fun computePreMountBudgetNanos(): Long { + val frameIntervalNanos = frameIntervalSource.frameIntervalNanos() ?: FRAME_TIME_NS + return minOf(frameIntervalNanos / 2, LONGEST_PRE_MOUNT_NANOS) + } + private fun dispatchPreMountItemsImpl(deadline: Long) { Systrace.beginSection(Systrace.TRACE_TAG_REACT, "MountItemDispatcher::premountViews") @@ -355,7 +365,7 @@ internal class MountItemDispatcher( try { while (true) { - if (System.nanoTime() > deadline) { + if (SystemClock.nanoTime() > deadline) { break } @@ -419,11 +429,22 @@ internal class MountItemDispatcher( fun onItemsQueued() } + fun interface FrameIntervalSource { + /** The display's current frame interval, or null while the display reports no refresh rate. */ + fun frameIntervalNanos(): Long? + } + private companion object { private const val TAG: String = "MountItemDispatcher" private const val FRAME_TIME_NS: Long = (1000000000 / 60).toLong() + /** + * The longest a frame pre-mounts for: half of a 30 Hz frame, so that a display below 30 Hz, + * such as an adaptive panel idling at 1 Hz, holds the UI thread no longer than at 30 Hz. + */ + private const val LONGEST_PRE_MOUNT_NANOS: Long = 1_000_000_000L / 30 / 2 + private fun drainConcurrentItemQueue(queue: Queue): List? { if (queue.isEmpty()) { return null diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/mounting/DisplayFrameIntervalTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/mounting/DisplayFrameIntervalTest.kt new file mode 100644 index 000000000000..cd7bfe807e7d --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/mounting/DisplayFrameIntervalTest.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.fabric.mounting + +import android.os.Looper +import android.view.Display +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.shadows.ShadowDisplay +import org.robolectric.shadows.ShadowDisplayManager + +@RunWith(RobolectricTestRunner::class) +class DisplayFrameIntervalTest { + private val display: Display = ShadowDisplay.getDefaultDisplay() + + private fun setRefreshRate(refreshRate: Float) { + shadowOf(display).setRefreshRate(refreshRate) + ShadowDisplayManager.changeDisplay(Display.DEFAULT_DISPLAY, "") + shadowOf(Looper.getMainLooper()).idle() + } + + @Test + fun readsTheDefaultDisplaysFrameAtEveryRefreshRate() { + val frameIntervalNanosByRefreshRate = + listOf(1f, 60f, 90f, 120f, 144f, 165f, 185f, 240f).associateWith { refreshRate -> + setRefreshRate(refreshRate) + DisplayFrameInterval(RuntimeEnvironment.getApplication()).frameIntervalNanos() + } + + assertThat(frameIntervalNanosByRefreshRate) + .isEqualTo( + mapOf( + 1f to 1_000_000_000L, + 60f to 16_666_666L, + 90f to 11_111_111L, + 120f to 8_333_333L, + 144f to 6_944_444L, + 165f to 6_060_606L, + 185f to 5_405_405L, + 240f to 4_166_666L, + ) + ) + } + + @Test + fun followsARefreshRateChangeTheDisplayReports() { + setRefreshRate(120f) + val frameInterval = DisplayFrameInterval(RuntimeEnvironment.getApplication()) + frameInterval.start() + + setRefreshRate(60f) + + assertThat(frameInterval.frameIntervalNanos()).isEqualTo(SIXTY_HERTZ_FRAME_NANOS) + } + + @Test + fun keepsItsFrameOnceStopped() { + setRefreshRate(120f) + val frameInterval = DisplayFrameInterval(RuntimeEnvironment.getApplication()) + frameInterval.start() + frameInterval.stop() + + setRefreshRate(60f) + + assertThat(frameInterval.frameIntervalNanos()).isEqualTo(ONE_HUNDRED_TWENTY_HERTZ_FRAME_NANOS) + } + + @Test + fun reportsNoFrameWhileTheDisplayReportsNoRefreshRate() { + setRefreshRate(0f) + + assertThat(DisplayFrameInterval(RuntimeEnvironment.getApplication()).frameIntervalNanos()) + .isNull() + } + + private companion object { + const val SIXTY_HERTZ_FRAME_NANOS: Long = 1_000_000_000L / 60 + const val ONE_HUNDRED_TWENTY_HERTZ_FRAME_NANOS: Long = 1_000_000_000L / 120 + } +} diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/mounting/MountItemDispatcherTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/mounting/MountItemDispatcherTest.kt new file mode 100644 index 000000000000..98dc4655d5a5 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/mounting/MountItemDispatcherTest.kt @@ -0,0 +1,156 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.fabric.mounting + +import com.facebook.react.common.SystemClock +import com.facebook.react.fabric.mounting.mountitems.MountItem +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import com.facebook.testutils.shadows.ShadowNativeLoader +import com.facebook.testutils.shadows.ShadowPerformanceTracer +import com.facebook.testutils.shadows.ShadowSoLoader +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.MockedStatic +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config( + shadows = [ShadowSoLoader::class, ShadowNativeLoader::class, ShadowPerformanceTracer::class] +) +class MountItemDispatcherTest { + private lateinit var systemClock: MockedStatic + private lateinit var dispatcher: MountItemDispatcher + private var nowNanos: Long = 0L + private var executedItems: Int = 0 + private var displayFrameIntervalNanos: Long? = SIXTY_HERTZ_FRAME_NANOS + + @Before + fun setUp() { + ReactNativeFeatureFlagsForTests.setUp() + systemClock = mockStatic(SystemClock::class.java) + systemClock.`when` { SystemClock.nanoTime() }.thenAnswer { nowNanos } + val mountingManager = mock() + whenever(mountingManager.surfaceIsStopped(any())).thenReturn(false) + whenever(mountingManager.isWaitingForViewAttach(any())).thenReturn(false) + dispatcher = MountItemDispatcher(mountingManager, mock()) { displayFrameIntervalNanos } + } + + @After + fun tearDown() { + systemClock.close() + } + + /** Queues items that each take [costNanos] of the frame to execute. */ + private fun queuePreMountItems(count: Int, costNanos: Long) { + repeat(count) { + dispatcher.addPreAllocateMountItem( + object : MountItem { + override fun execute(mountingManager: MountingManager) { + executedItems++ + nowNanos += costNanos + } + + override fun getSurfaceId(): Int = SURFACE_ID + } + ) + } + } + + /** + * Runs one frame of pre-mounting starting at [frameTimeNanos] and returns how many items it + * executed. + */ + private fun preMountFrame(frameTimeNanos: Long): Int { + nowNanos = frameTimeNanos + val before = executedItems + dispatcher.dispatchPreMountItems(frameTimeNanos) + return executedItems - before + } + + /** Runs one frame at each refresh rate and returns how many items each frame pre-mounted. */ + private fun preMountFrameAtEachRefreshRate(refreshRates: List): Map = + refreshRates.withIndex().associate { (index, refreshRate) -> + displayFrameIntervalNanos = NANOSECONDS_PER_SECOND / refreshRate + queuePreMountItems(count = 20, costNanos = MILLISECOND_NANOS) + refreshRate to preMountFrame(FIRST_FRAME_NANOS + index * NANOSECONDS_PER_SECOND) + } + + @Test + fun preMountsForHalfOfTheDisplaysFrameFromThirtyHertzUp() { + // Items start at 0, 1, 2, ... ms, and the first one starting past half of the frame waits for + // the next frame: half of a 30 Hz frame is 16.67 ms, of a 185 Hz frame 2.70 ms. + assertThat(preMountFrameAtEachRefreshRate(listOf(30, 48, 60, 90, 120, 144, 165, 185, 240))) + .isEqualTo( + mapOf( + 30 to 17, + 48 to 11, + 60 to 9, + 90 to 6, + 120 to 5, + 144 to 4, + 165 to 4, + 185 to 3, + 240 to 3, + ) + ) + } + + @Test + fun neverPreMountsLongerThanHalfOfAThirtyHertzFrameBelowThirtyHertz() { + // Half of a 1 Hz frame would be 500 ms; every rate below 30 Hz keeps 16.67 ms. + assertThat(preMountFrameAtEachRefreshRate(listOf(1, 10, 24))) + .isEqualTo(mapOf(1 to 17, 10 to 17, 24 to 17)) + } + + @Test + fun preMountsForHalfOfASixtyHertzFrameWhileTheDisplayReportsNoRate() { + displayFrameIntervalNanos = null + queuePreMountItems(count = 20, costNanos = MILLISECOND_NANOS) + + assertThat(preMountFrame(FIRST_FRAME_NANOS)).isEqualTo(9) + } + + @Test + fun theBudgetFollowsTheDisplayBackToSixtyHertz() { + displayFrameIntervalNanos = ONE_HUNDRED_TWENTY_HERTZ_FRAME_NANOS + queuePreMountItems(count = 20, costNanos = MILLISECOND_NANOS) + assertThat(preMountFrame(FIRST_FRAME_NANOS)).isEqualTo(5) + + displayFrameIntervalNanos = SIXTY_HERTZ_FRAME_NANOS + + assertThat(preMountFrame(FIRST_FRAME_NANOS + SIXTY_HERTZ_FRAME_NANOS)).isEqualTo(9) + } + + @Test + fun aSkippedFrameKeepsTheDisplaysBudget() { + displayFrameIntervalNanos = ONE_HUNDRED_TWENTY_HERTZ_FRAME_NANOS + queuePreMountItems(count = 20, costNanos = MILLISECOND_NANOS) + assertThat(preMountFrame(FIRST_FRAME_NANOS)).isEqualTo(5) + + // The next frame runs two vsyncs later; the budget still comes from the display. + assertThat(preMountFrame(FIRST_FRAME_NANOS + 2 * ONE_HUNDRED_TWENTY_HERTZ_FRAME_NANOS)) + .isEqualTo(5) + } + + private companion object { + const val SURFACE_ID: Int = 1 + const val MILLISECOND_NANOS: Long = 1_000_000L + const val NANOSECONDS_PER_SECOND: Long = 1_000_000_000L + const val FIRST_FRAME_NANOS: Long = NANOSECONDS_PER_SECOND + const val SIXTY_HERTZ_FRAME_NANOS: Long = NANOSECONDS_PER_SECOND / 60 + const val ONE_HUNDRED_TWENTY_HERTZ_FRAME_NANOS: Long = NANOSECONDS_PER_SECOND / 120 + } +}