From 2ebc39ca397128ac183adb0e6a050770ebb86b9f Mon Sep 17 00:00:00 2001 From: Ruslan Shestopalyuk Date: Thu, 9 May 2024 03:21:30 -0700 Subject: [PATCH] Convert FpsDebugFrameCallback/AnimationsDebugModule to Kotlin (#44475) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/44475 # Changelog: [Internal] - As the title says, this is preparing to complete the conversion of the whole corresponding module to Kotlin. Reviewed By: christophpurrer Differential Revision: D57095896 fbshipit-source-id: 87fe08aec974f5f1327189d0e74c3782c4391e85 --- .../ReactAndroid/api/ReactAndroid.api | 45 ++-- .../facebook/react/devsupport/FpsView.java | 104 -------- .../com/facebook/react/devsupport/FpsView.kt | 101 ++++++++ .../modules/debug/AnimationsDebugModule.java | 106 --------- .../modules/debug/AnimationsDebugModule.kt | 90 +++++++ .../modules/debug/FpsDebugFrameCallback.java | 225 ------------------ .../modules/debug/FpsDebugFrameCallback.kt | 172 +++++++++++++ 7 files changed, 386 insertions(+), 457 deletions(-) delete mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/FpsView.java create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/FpsView.kt delete mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/debug/AnimationsDebugModule.java create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/debug/AnimationsDebugModule.kt delete mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/debug/FpsDebugFrameCallback.java create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/debug/FpsDebugFrameCallback.kt diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index 9da9afa84c8d01..555c27cf8d8ca1 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -3193,33 +3193,34 @@ public final class com/facebook/react/modules/debug/DevSettingsModule : com/face public fun toggleElementInspector ()V } -public class com/facebook/react/modules/debug/FpsDebugFrameCallback : android/view/Choreographer$FrameCallback { +public final class com/facebook/react/modules/debug/FpsDebugFrameCallback : android/view/Choreographer$FrameCallback { public fun (Lcom/facebook/react/bridge/ReactContext;)V public fun doFrame (J)V - public fun get4PlusFrameStutters ()I - public fun getExpectedNumFrames ()I - public fun getFPS ()D - public fun getFpsInfo (J)Lcom/facebook/react/modules/debug/FpsDebugFrameCallback$FpsInfo; - public fun getJSFPS ()D - public fun getNumFrames ()I - public fun getNumJSFrames ()I - public fun getTotalTimeMS ()I - public fun reset ()V - public fun start ()V - public fun start (D)V - public fun startAndRecordFpsAtEachFrame ()V - public fun stop ()V + public final fun get4PlusFrameStutters ()I + public final fun getExpectedNumFrames ()I + public final fun getFps ()D + public final fun getFpsInfo (J)Lcom/facebook/react/modules/debug/FpsDebugFrameCallback$FpsInfo; + public final fun getJsFPS ()D + public final fun getNumFrames ()I + public final fun getNumJSFrames ()I + public final fun getTotalTimeMS ()I + public final fun reset ()V + public final fun start ()V + public final fun start (D)V + public static synthetic fun start$default (Lcom/facebook/react/modules/debug/FpsDebugFrameCallback;DILjava/lang/Object;)V + public final fun startAndRecordFpsAtEachFrame ()V + public final fun stop ()V } -public class com/facebook/react/modules/debug/FpsDebugFrameCallback$FpsInfo { - public final field fps D - public final field jsFps D - public final field total4PlusFrameStutters I - public final field totalExpectedFrames I - public final field totalFrames I - public final field totalJsFrames I - public final field totalTimeMs I +public final class com/facebook/react/modules/debug/FpsDebugFrameCallback$FpsInfo { public fun (IIIIDDI)V + public final fun getFps ()D + public final fun getJsFps ()D + public final fun getTotal4PlusFrameStutters ()I + public final fun getTotalExpectedFrames ()I + public final fun getTotalFrames ()I + public final fun getTotalJsFrames ()I + public final fun getTotalTimeMs ()I } public class com/facebook/react/modules/debug/SourceCodeModule : com/facebook/fbreact/specs/NativeSourceCodeSpec { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/FpsView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/FpsView.java deleted file mode 100644 index 57827da8c3fcdb..00000000000000 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/FpsView.java +++ /dev/null @@ -1,104 +0,0 @@ -/* - * 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.devsupport; - -import android.widget.FrameLayout; -import android.widget.TextView; -import com.facebook.common.logging.FLog; -import com.facebook.react.R; -import com.facebook.react.bridge.ReactContext; -import com.facebook.react.common.ReactConstants; -import com.facebook.react.modules.debug.FpsDebugFrameCallback; -import java.util.Locale; - -/** - * View that automatically monitors and displays the current app frame rate. Also logs the current - * FPS to logcat while active. - * - *

NB: Requires API 16 for use of FpsDebugFrameCallback. - */ -class FpsView extends FrameLayout { - - private static final int UPDATE_INTERVAL_MS = 500; - - private final TextView mTextView; - private final FpsDebugFrameCallback mFrameCallback; - private final FPSMonitorRunnable mFPSMonitorRunnable; - - public FpsView(ReactContext reactContext) { - super(reactContext); - inflate(reactContext, R.layout.fps_view, this); - mTextView = (TextView) findViewById(R.id.fps_text); - mFrameCallback = new FpsDebugFrameCallback(reactContext); - mFPSMonitorRunnable = new FPSMonitorRunnable(); - setCurrentFPS(0, 0, 0, 0); - } - - @Override - protected void onAttachedToWindow() { - super.onAttachedToWindow(); - mFrameCallback.reset(); - mFrameCallback.start(); - mFPSMonitorRunnable.start(); - } - - @Override - protected void onDetachedFromWindow() { - super.onDetachedFromWindow(); - mFrameCallback.stop(); - mFPSMonitorRunnable.stop(); - } - - private void setCurrentFPS( - double currentFPS, double currentJSFPS, int droppedUIFrames, int total4PlusFrameStutters) { - String fpsString = - String.format( - Locale.US, - "UI: %.1f fps\n%d dropped so far\n%d stutters (4+) so far\nJS: %.1f fps", - currentFPS, - droppedUIFrames, - total4PlusFrameStutters, - currentJSFPS); - mTextView.setText(fpsString); - FLog.d(ReactConstants.TAG, fpsString); - } - - /** Timer that runs every UPDATE_INTERVAL_MS ms and updates the currently displayed FPS. */ - private class FPSMonitorRunnable implements Runnable { - - private boolean mShouldStop = false; - private int mTotalFramesDropped = 0; - private int mTotal4PlusFrameStutters = 0; - - @Override - public void run() { - if (mShouldStop) { - return; - } - mTotalFramesDropped += mFrameCallback.getExpectedNumFrames() - mFrameCallback.getNumFrames(); - mTotal4PlusFrameStutters += mFrameCallback.get4PlusFrameStutters(); - setCurrentFPS( - mFrameCallback.getFPS(), - mFrameCallback.getJSFPS(), - mTotalFramesDropped, - mTotal4PlusFrameStutters); - mFrameCallback.reset(); - - postDelayed(this, UPDATE_INTERVAL_MS); - } - - public void start() { - mShouldStop = false; - post(this); - } - - public void stop() { - mShouldStop = true; - } - } -} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/FpsView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/FpsView.kt new file mode 100644 index 00000000000000..4c46a1f6b243ed --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/FpsView.kt @@ -0,0 +1,101 @@ +/* + * 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.devsupport + +import android.view.View +import android.widget.FrameLayout +import android.widget.TextView +import com.facebook.common.logging.FLog +import com.facebook.react.R +import com.facebook.react.bridge.ReactContext +import com.facebook.react.common.ReactConstants +import com.facebook.react.modules.debug.FpsDebugFrameCallback +import java.util.Locale + +/** + * View that automatically monitors and displays the current app frame rate. Also logs the current + * FPS to logcat while active. + * + * NB: Requires API 16 for use of FpsDebugFrameCallback. + */ +internal class FpsView(reactContext: ReactContext?) : FrameLayout(reactContext!!) { + private val textView: TextView + private val frameCallback: FpsDebugFrameCallback + private val fpsMonitorRunnable: FPSMonitorRunnable + + init { + inflate(reactContext, R.layout.fps_view, this) + textView = findViewById(R.id.fps_text) as TextView + frameCallback = FpsDebugFrameCallback(reactContext!!) + fpsMonitorRunnable = FPSMonitorRunnable() + setCurrentFPS(0.0, 0.0, 0, 0) + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + frameCallback.reset() + frameCallback.start() + fpsMonitorRunnable.start() + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + frameCallback.stop() + fpsMonitorRunnable.stop() + } + + private fun setCurrentFPS( + currentFPS: Double, + currentJSFPS: Double, + droppedUIFrames: Int, + total4PlusFrameStutters: Int + ) { + val fpsString = + String.format( + Locale.US, + "UI: %.1f fps\n%d dropped so far\n%d stutters (4+) so far\nJS: %.1f fps", + currentFPS, + droppedUIFrames, + total4PlusFrameStutters, + currentJSFPS) + textView.text = fpsString + FLog.d(ReactConstants.TAG, fpsString) + } + + /** Timer that runs every UPDATE_INTERVAL_MS ms and updates the currently displayed FPS. */ + private inner class FPSMonitorRunnable : Runnable { + private var shouldStop = false + private var totalFramesDropped = 0 + private var total4PlusFrameStutters = 0 + + override fun run() { + if (shouldStop) { + return + } + totalFramesDropped += frameCallback.expectedNumFrames - frameCallback.numFrames + total4PlusFrameStutters += frameCallback.get4PlusFrameStutters() + setCurrentFPS( + frameCallback.fps, frameCallback.jsFPS, totalFramesDropped, total4PlusFrameStutters) + frameCallback.reset() + postDelayed(this, UPDATE_INTERVAL_MS.toLong()) + } + + fun start() { + shouldStop = false + post(this) + } + + fun stop() { + shouldStop = true + } + } + + companion object { + private const val UPDATE_INTERVAL_MS = 500 + } +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/debug/AnimationsDebugModule.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/debug/AnimationsDebugModule.java deleted file mode 100644 index 95b24e3de2deb3..00000000000000 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/debug/AnimationsDebugModule.java +++ /dev/null @@ -1,106 +0,0 @@ -/* - * 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.modules.debug; - -import android.widget.Toast; -import androidx.annotation.Nullable; -import com.facebook.common.logging.FLog; -import com.facebook.fbreact.specs.NativeAnimationsDebugModuleSpec; -import com.facebook.react.bridge.JSApplicationCausedNativeException; -import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.react.common.ReactConstants; -import com.facebook.react.module.annotations.ReactModule; -import com.facebook.react.modules.debug.interfaces.DeveloperSettings; -import java.util.Locale; - -/** - * Module that records debug information during transitions (animated navigation events such as - * going from one screen to another). - */ -@ReactModule(name = NativeAnimationsDebugModuleSpec.NAME) -class AnimationsDebugModule extends NativeAnimationsDebugModuleSpec { - - private @Nullable FpsDebugFrameCallback mFrameCallback; - private @Nullable final DeveloperSettings mCatalystSettings; - - public AnimationsDebugModule( - ReactApplicationContext reactContext, DeveloperSettings catalystSettings) { - super(reactContext); - mCatalystSettings = catalystSettings; - } - - @Override - public void startRecordingFps() { - if (mCatalystSettings == null || !mCatalystSettings.isAnimationFpsDebugEnabled()) { - return; - } - - if (mFrameCallback != null) { - throw new JSApplicationCausedNativeException("Already recording FPS!"); - } - - mFrameCallback = new FpsDebugFrameCallback(getReactApplicationContext()); - mFrameCallback.startAndRecordFpsAtEachFrame(); - } - - /** - * Called when an animation finishes. The caller should include the animation stop time in ms - * (unix time) so that we know when the animation stopped from the JS perspective and we don't - * count time after as being part of the animation. - */ - @Override - public void stopRecordingFps(double animationStopTimeMs) { - if (mFrameCallback == null) { - return; - } - - mFrameCallback.stop(); - - // Casting to long is safe here since animationStopTimeMs is unix time and thus relatively small - FpsDebugFrameCallback.FpsInfo fpsInfo = mFrameCallback.getFpsInfo((long) animationStopTimeMs); - - if (fpsInfo == null) { - Toast.makeText(getReactApplicationContext(), "Unable to get FPS info", Toast.LENGTH_LONG) - .show(); - } else { - String fpsString = - String.format( - Locale.US, - "FPS: %.2f, %d frames (%d expected)", - fpsInfo.fps, - fpsInfo.totalFrames, - fpsInfo.totalExpectedFrames); - String jsFpsString = - String.format( - Locale.US, - "JS FPS: %.2f, %d frames (%d expected)", - fpsInfo.jsFps, - fpsInfo.totalJsFrames, - fpsInfo.totalExpectedFrames); - String debugString = - fpsString - + "\n" - + jsFpsString - + "\n" - + "Total Time MS: " - + String.format(Locale.US, "%d", fpsInfo.totalTimeMs); - FLog.d(ReactConstants.TAG, debugString); - Toast.makeText(getReactApplicationContext(), debugString, Toast.LENGTH_LONG).show(); - } - - mFrameCallback = null; - } - - @Override - public void invalidate() { - if (mFrameCallback != null) { - mFrameCallback.stop(); - mFrameCallback = null; - } - } -} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/debug/AnimationsDebugModule.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/debug/AnimationsDebugModule.kt new file mode 100644 index 00000000000000..d2fd0d528dc433 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/debug/AnimationsDebugModule.kt @@ -0,0 +1,90 @@ +/* + * 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.modules.debug + +import android.widget.Toast +import com.facebook.common.logging.FLog +import com.facebook.fbreact.specs.NativeAnimationsDebugModuleSpec +import com.facebook.react.bridge.JSApplicationCausedNativeException +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.common.ReactConstants +import com.facebook.react.module.annotations.ReactModule +import com.facebook.react.modules.debug.interfaces.DeveloperSettings +import java.util.Locale + +/** + * Module that records debug information during transitions (animated navigation events such as + * going from one screen to another). + */ +@ReactModule(name = NativeAnimationsDebugModuleSpec.NAME) +internal class AnimationsDebugModule( + reactContext: ReactApplicationContext?, + private val catalystSettings: DeveloperSettings? +) : NativeAnimationsDebugModuleSpec(reactContext) { + private var frameCallback: FpsDebugFrameCallback? = null + + override fun startRecordingFps() { + if (catalystSettings == null || !catalystSettings.isAnimationFpsDebugEnabled()) { + return + } + if (frameCallback != null) { + throw JSApplicationCausedNativeException("Already recording FPS!") + } + frameCallback = FpsDebugFrameCallback(getReactApplicationContext()) + frameCallback?.startAndRecordFpsAtEachFrame() + } + + /** + * Called when an animation finishes. The caller should include the animation stop time in ms + * (unix time) so that we know when the animation stopped from the JS perspective and we don't + * count time after as being part of the animation. + */ + override fun stopRecordingFps(animationStopTimeMs: Double) { + if (frameCallback == null) { + return + } + frameCallback!!.stop() + + // Casting to long is safe here since animationStopTimeMs is unix time and thus relatively small + val fpsInfo = frameCallback!!.getFpsInfo(animationStopTimeMs.toLong()) + if (fpsInfo == null) { + Toast.makeText(getReactApplicationContext(), "Unable to get FPS info", Toast.LENGTH_LONG) + .show() + } else { + val fpsString = + String.format( + Locale.US, + "FPS: %.2f, %d frames (%d expected)", + fpsInfo.fps, + fpsInfo.totalFrames, + fpsInfo.totalExpectedFrames) + val jsFpsString = + String.format( + Locale.US, + "JS FPS: %.2f, %d frames (%d expected)", + fpsInfo.jsFps, + fpsInfo.totalJsFrames, + fpsInfo.totalExpectedFrames) + val debugString = + """ + $fpsString + $jsFpsString + Total Time MS: ${String.format(Locale.US, "%d", fpsInfo.totalTimeMs)} + """ + .trimIndent() + FLog.d(ReactConstants.TAG, debugString) + Toast.makeText(getReactApplicationContext(), debugString, Toast.LENGTH_LONG).show() + } + frameCallback = null + } + + override fun invalidate() { + frameCallback?.stop() + frameCallback = null + } +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/debug/FpsDebugFrameCallback.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/debug/FpsDebugFrameCallback.java deleted file mode 100644 index 395e338e1b8974..00000000000000 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/debug/FpsDebugFrameCallback.java +++ /dev/null @@ -1,225 +0,0 @@ -/* - * 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.modules.debug; - -import android.view.Choreographer; -import androidx.annotation.Nullable; -import com.facebook.infer.annotation.Assertions; -import com.facebook.react.bridge.ReactContext; -import com.facebook.react.bridge.UiThreadUtil; -import com.facebook.react.uimanager.UIManagerModule; -import java.util.Map; -import java.util.TreeMap; - -/** - * Each time a frame is drawn, records whether it should have expected any more callbacks since the - * last time a frame was drawn (i.e. was a frame skipped?). Uses this plus total elapsed time to - * determine FPS. Can also record total and expected frame counts, though NB, since the expected - * frame rate is estimated, the expected frame count will lose accuracy over time. - * - *

Also records the JS FPS, i.e. the frames per second with which either JS updated the UI or was - * idle and not trying to update the UI. This is different from the FPS above since JS rendering is - * async. - */ -public class FpsDebugFrameCallback implements Choreographer.FrameCallback { - - public static class FpsInfo { - - public final int totalFrames; - public final int totalJsFrames; - public final int totalExpectedFrames; - public final int total4PlusFrameStutters; - public final double fps; - public final double jsFps; - public final int totalTimeMs; - - public FpsInfo( - int totalFrames, - int totalJsFrames, - int totalExpectedFrames, - int total4PlusFrameStutters, - double fps, - double jsFps, - int totalTimeMs) { - this.totalFrames = totalFrames; - this.totalJsFrames = totalJsFrames; - this.totalExpectedFrames = totalExpectedFrames; - this.total4PlusFrameStutters = total4PlusFrameStutters; - this.fps = fps; - this.jsFps = jsFps; - this.totalTimeMs = totalTimeMs; - } - } - - private static final double DEFAULT_FPS = 60.0; - - private @Nullable Choreographer mChoreographer; - private final ReactContext mReactContext; - private final UIManagerModule mUIManagerModule; - private final DidJSUpdateUiDuringFrameDetector mDidJSUpdateUiDuringFrameDetector; - - private long mFirstFrameTime = -1; - private long mLastFrameTime = -1; - private int mNumFrameCallbacks = 0; - private int mExpectedNumFramesPrev = 0; - private int m4PlusFrameStutters = 0; - private int mNumFrameCallbacksWithBatchDispatches = 0; - private boolean mIsRecordingFpsInfoAtEachFrame = false; - private double mTargetFps = DEFAULT_FPS; - private @Nullable TreeMap mTimeToFps; - - public FpsDebugFrameCallback(ReactContext reactContext) { - mReactContext = reactContext; - mUIManagerModule = reactContext.getNativeModule(UIManagerModule.class); - mDidJSUpdateUiDuringFrameDetector = new DidJSUpdateUiDuringFrameDetector(); - } - - @Override - public void doFrame(long l) { - if (mFirstFrameTime == -1) { - mFirstFrameTime = l; - } - - long lastFrameStartTime = mLastFrameTime; - mLastFrameTime = l; - - if (mDidJSUpdateUiDuringFrameDetector.getDidJSHitFrameAndCleanup(lastFrameStartTime, l)) { - mNumFrameCallbacksWithBatchDispatches++; - } - - mNumFrameCallbacks++; - int expectedNumFrames = getExpectedNumFrames(); - int framesDropped = expectedNumFrames - mExpectedNumFramesPrev - 1; - if (framesDropped >= 4) { - m4PlusFrameStutters++; - } - - if (mIsRecordingFpsInfoAtEachFrame) { - Assertions.assertNotNull(mTimeToFps); - FpsInfo info = - new FpsInfo( - getNumFrames(), - getNumJSFrames(), - expectedNumFrames, - m4PlusFrameStutters, - getFPS(), - getJSFPS(), - getTotalTimeMS()); - mTimeToFps.put(System.currentTimeMillis(), info); - } - mExpectedNumFramesPrev = expectedNumFrames; - - if (mChoreographer != null) { - mChoreographer.postFrameCallback(this); - } - } - - public void start() { - start(mTargetFps); - } - - public void start(double targetFps) { - // T172641976: re-think if we need to implement addBridgeIdleDebugListener and - // removeBridgeIdleDebugListener for Bridgeless - if (!mReactContext.isBridgeless()) { - mReactContext - .getCatalystInstance() - .addBridgeIdleDebugListener(mDidJSUpdateUiDuringFrameDetector); - } - if (mUIManagerModule != null) { - mUIManagerModule.setViewHierarchyUpdateDebugListener(mDidJSUpdateUiDuringFrameDetector); - } - mTargetFps = targetFps; - UiThreadUtil.runOnUiThread( - () -> { - mChoreographer = Choreographer.getInstance(); - mChoreographer.postFrameCallback(this); - }); - } - - public void startAndRecordFpsAtEachFrame() { - mTimeToFps = new TreeMap(); - mIsRecordingFpsInfoAtEachFrame = true; - start(); - } - - public void stop() { - if (!mReactContext.isBridgeless()) { - mReactContext - .getCatalystInstance() - .removeBridgeIdleDebugListener(mDidJSUpdateUiDuringFrameDetector); - } - if (mUIManagerModule != null) { - mUIManagerModule.setViewHierarchyUpdateDebugListener(null); - } - UiThreadUtil.runOnUiThread( - () -> { - mChoreographer = Choreographer.getInstance(); - mChoreographer.removeFrameCallback(this); - }); - } - - public double getFPS() { - if (mLastFrameTime == mFirstFrameTime) { - return 0; - } - return ((double) (getNumFrames()) * 1e9) / (mLastFrameTime - mFirstFrameTime); - } - - public double getJSFPS() { - if (mLastFrameTime == mFirstFrameTime) { - return 0; - } - return ((double) (getNumJSFrames()) * 1e9) / (mLastFrameTime - mFirstFrameTime); - } - - public int getNumFrames() { - return mNumFrameCallbacks - 1; - } - - public int getNumJSFrames() { - return mNumFrameCallbacksWithBatchDispatches - 1; - } - - public int getExpectedNumFrames() { - double totalTimeMS = getTotalTimeMS(); - int expectedFrames = (int) (mTargetFps * totalTimeMS / 1000 + 1); - return expectedFrames; - } - - public int get4PlusFrameStutters() { - return m4PlusFrameStutters; - } - - public int getTotalTimeMS() { - return (int) ((double) mLastFrameTime - mFirstFrameTime) / 1000000; - } - - /** - * Returns the FpsInfo as if stop had been called at the given upToTimeMs. Only valid if - * monitoring was started with {@link #startAndRecordFpsAtEachFrame()}. - */ - public @Nullable FpsInfo getFpsInfo(long upToTimeMs) { - Assertions.assertNotNull(mTimeToFps, "FPS was not recorded at each frame!"); - Map.Entry bestEntry = mTimeToFps.floorEntry(upToTimeMs); - if (bestEntry == null) { - return null; - } - return bestEntry.getValue(); - } - - public void reset() { - mFirstFrameTime = -1; - mLastFrameTime = -1; - mNumFrameCallbacks = 0; - m4PlusFrameStutters = 0; - mNumFrameCallbacksWithBatchDispatches = 0; - mIsRecordingFpsInfoAtEachFrame = false; - mTimeToFps = null; - } -} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/debug/FpsDebugFrameCallback.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/debug/FpsDebugFrameCallback.kt new file mode 100644 index 00000000000000..fd5b5420fcf9d2 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/debug/FpsDebugFrameCallback.kt @@ -0,0 +1,172 @@ +/* + * 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.modules.debug + +import android.view.Choreographer +import com.facebook.infer.annotation.Assertions +import com.facebook.react.bridge.ReactContext +import com.facebook.react.bridge.UiThreadUtil +import com.facebook.react.uimanager.UIManagerModule +import java.util.TreeMap + +/** + * Each time a frame is drawn, records whether it should have expected any more callbacks since the + * last time a frame was drawn (i.e. was a frame skipped?). Uses this plus total elapsed time to + * determine FPS. Can also record total and expected frame counts, though NB, since the expected + * frame rate is estimated, the expected frame count will lose accuracy over time. + * + * Also records the JS FPS, i.e. the frames per second with which either JS updated the UI or was + * idle and not trying to update the UI. This is different from the FPS above since JS rendering is + * async. + */ +public class FpsDebugFrameCallback(private val reactContext: ReactContext) : + Choreographer.FrameCallback { + public class FpsInfo( + public val totalFrames: Int, + public val totalJsFrames: Int, + public val totalExpectedFrames: Int, + public val total4PlusFrameStutters: Int, + public val fps: Double, + public val jsFps: Double, + public val totalTimeMs: Int + ) + + private var choreographer: Choreographer? = null + private val uiManagerModule: UIManagerModule? = + reactContext.getNativeModule(UIManagerModule::class.java) + private val didJSUpdateUiDuringFrameDetector: DidJSUpdateUiDuringFrameDetector = + DidJSUpdateUiDuringFrameDetector() + private var firstFrameTime: Long = -1 + private var lastFrameTime: Long = -1 + private var numFrameCallbacks = 0 + private var expectedNumFramesPrev = 0 + private var fourPlusFrameStutters = 0 + private var numFrameCallbacksWithBatchDispatches = 0 + private var isRecordingFpsInfoAtEachFrame = false + private var targetFps = DEFAULT_FPS + private var timeToFps: TreeMap? = null + + override fun doFrame(l: Long) { + if (firstFrameTime == -1L) { + firstFrameTime = l + } + val lastFrameStartTime = lastFrameTime + lastFrameTime = l + if (didJSUpdateUiDuringFrameDetector.getDidJSHitFrameAndCleanup(lastFrameStartTime, l)) { + numFrameCallbacksWithBatchDispatches++ + } + numFrameCallbacks++ + val expectedNumFrames = expectedNumFrames + val framesDropped = expectedNumFrames - expectedNumFramesPrev - 1 + if (framesDropped >= 4) { + fourPlusFrameStutters++ + } + if (isRecordingFpsInfoAtEachFrame) { + Assertions.assertNotNull(timeToFps) + val info = + FpsInfo( + numFrames, + numJSFrames, + expectedNumFrames, + fourPlusFrameStutters, + fps, + jsFPS, + totalTimeMS) + timeToFps?.put(System.currentTimeMillis(), info) + } + expectedNumFramesPrev = expectedNumFrames + choreographer?.postFrameCallback(this) + } + + @JvmOverloads + public fun start(targetFps: Double = this.targetFps) { + // T172641976: re-think if we need to implement addBridgeIdleDebugListener and + // removeBridgeIdleDebugListener for Bridgeless + @Suppress("DEPRECATION") + if (!reactContext.isBridgeless) { + reactContext.catalystInstance.addBridgeIdleDebugListener(didJSUpdateUiDuringFrameDetector) + } + uiManagerModule?.setViewHierarchyUpdateDebugListener(didJSUpdateUiDuringFrameDetector) + this.targetFps = targetFps + UiThreadUtil.runOnUiThread { + choreographer = Choreographer.getInstance() + choreographer?.postFrameCallback(this) + } + } + + public fun startAndRecordFpsAtEachFrame() { + timeToFps = TreeMap() + isRecordingFpsInfoAtEachFrame = true + start() + } + + public fun stop() { + @Suppress("DEPRECATION") + if (!reactContext.isBridgeless) { + reactContext.catalystInstance.removeBridgeIdleDebugListener(didJSUpdateUiDuringFrameDetector) + } + uiManagerModule?.setViewHierarchyUpdateDebugListener(null) + UiThreadUtil.runOnUiThread { + choreographer = Choreographer.getInstance() + choreographer?.removeFrameCallback(this) + } + } + + public val fps: Double + get() = + if (lastFrameTime == firstFrameTime) { + 0.0 + } else numFrames.toDouble() * 1e9 / (lastFrameTime - firstFrameTime) + + public val jsFPS: Double + get() = + if (lastFrameTime == firstFrameTime) { + 0.0 + } else numJSFrames.toDouble() * 1e9 / (lastFrameTime - firstFrameTime) + + public val numFrames: Int + get() = numFrameCallbacks - 1 + + public val numJSFrames: Int + get() = numFrameCallbacksWithBatchDispatches - 1 + + public val expectedNumFrames: Int + get() { + val totalTimeMS = totalTimeMS.toDouble() + return (targetFps * totalTimeMS / 1000 + 1).toInt() + } + + public fun get4PlusFrameStutters(): Int = fourPlusFrameStutters + + public val totalTimeMS: Int + get() = (lastFrameTime.toDouble() - firstFrameTime).toInt() / 1000000 + + /** + * Returns the FpsInfo as if stop had been called at the given upToTimeMs. Only valid if + * monitoring was started with [.startAndRecordFpsAtEachFrame]. + */ + public fun getFpsInfo(upToTimeMs: Long): FpsInfo? { + Assertions.assertNotNull(timeToFps, "FPS was not recorded at each frame!") + val (_, value) = timeToFps?.floorEntry(upToTimeMs) ?: return null + return value + } + + public fun reset() { + firstFrameTime = -1 + lastFrameTime = -1 + numFrameCallbacks = 0 + fourPlusFrameStutters = 0 + numFrameCallbacksWithBatchDispatches = 0 + isRecordingFpsInfoAtEachFrame = false + timeToFps = null + } + + private companion object { + private const val DEFAULT_FPS = 60.0 + } +}