@onekeyfe/react-native-perf-stats 3.0.26 → 3.0.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. package/README.md +25 -16
  2. package/android/src/main/java/com/margelo/nitro/reactnativeperfstats/ReactNativePerfStats.kt +170 -3
  3. package/ios/ReactNativePerfStats.swift +247 -31
  4. package/lib/module/index.js +83 -2
  5. package/lib/module/index.js.map +1 -1
  6. package/lib/typescript/src/ReactNativePerfStats.nitro.d.ts +22 -0
  7. package/lib/typescript/src/ReactNativePerfStats.nitro.d.ts.map +1 -1
  8. package/lib/typescript/src/index.d.ts +21 -2
  9. package/lib/typescript/src/index.d.ts.map +1 -1
  10. package/nitrogen/generated/android/c++/JHybridReactNativePerfStatsSpec.cpp +4 -0
  11. package/nitrogen/generated/android/c++/JHybridReactNativePerfStatsSpec.hpp +1 -0
  12. package/nitrogen/generated/android/c++/JPerfSample.hpp +9 -1
  13. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativeperfstats/HybridReactNativePerfStatsSpec.kt +4 -0
  14. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativeperfstats/PerfSample.kt +8 -2
  15. package/nitrogen/generated/ios/c++/HybridReactNativePerfStatsSpecSwift.hpp +6 -0
  16. package/nitrogen/generated/ios/swift/HybridReactNativePerfStatsSpec.swift +1 -0
  17. package/nitrogen/generated/ios/swift/HybridReactNativePerfStatsSpec_cxx.swift +11 -0
  18. package/nitrogen/generated/ios/swift/PerfSample.swift +24 -2
  19. package/nitrogen/generated/shared/c++/HybridReactNativePerfStatsSpec.cpp +1 -0
  20. package/nitrogen/generated/shared/c++/HybridReactNativePerfStatsSpec.hpp +1 -0
  21. package/nitrogen/generated/shared/c++/PerfSample.hpp +9 -1
  22. package/package.json +1 -1
  23. package/src/ReactNativePerfStats.nitro.ts +23 -0
  24. package/src/index.tsx +86 -2
package/README.md CHANGED
@@ -1,9 +1,10 @@
1
1
  # @onekeyfe/react-native-perf-stats
2
2
 
3
- Native CPU and RAM sampler for React Native, with an optional draggable on-screen overlay. Built on [Nitro Modules](https://nitro.margelo.com/) — the sampler runs on a dedicated background thread so the overlay keeps updating even when the JS thread is frozen.
3
+ Native CPU, RAM, UI-FPS and JS-FPS sampler for React Native, with an optional draggable on-screen overlay. Built on [Nitro Modules](https://nitro.margelo.com/) — the sampler runs on a dedicated background thread so the overlay keeps updating even when the JS thread is frozen.
4
4
 
5
- - **Android**: HandlerThread + `/proc/self/stat` (CPU ticks) + `/proc/self/statm` (RSS); overlay attached via `WindowManager` to the current Activity (no `SYSTEM_ALERT_WINDOW` permission).
6
- - **iOS**: GCD dispatch source + `task_info` (`phys_footprint`); overlay added as a `UILabel` on the key `UIWindow`.
5
+ - **Android**: HandlerThread + `/proc/self/stat` (CPU ticks) + `/proc/self/statm` (RSS) + `Choreographer.FrameCallback` (UI FPS); overlay attached via `WindowManager` to the current Activity (no `SYSTEM_ALERT_WINDOW` permission).
6
+ - **iOS**: GCD dispatch source + `task_info` (`phys_footprint`) + `CADisplayLink` (UI FPS); overlay added as a `UILabel` on the key `UIWindow`.
7
+ - **JS FPS**: opt-in JS-side `requestAnimationFrame` ticker that pushes its per-second count to native via `setJsFpsHint`. Started by `startJsFpsTracker()`.
7
8
 
8
9
  ## Installation
9
10
 
@@ -19,6 +20,7 @@ yarn add @onekeyfe/react-native-perf-stats react-native-nitro-modules
19
20
  import { ReactNativePerfStats } from '@onekeyfe/react-native-perf-stats';
20
21
 
21
22
  // Start sampling at 1 Hz. Idempotent — calling again just updates the interval.
23
+ // Also kicks off the JS-side rAF tracker so PerfSample.jsFps is populated.
22
24
  ReactNativePerfStats.start(1000);
23
25
 
24
26
  // Show the floating overlay (drag to reposition).
@@ -26,36 +28,43 @@ ReactNativePerfStats.showOverlay();
26
28
 
27
29
  // One-shot read without touching the overlay timer.
28
30
  const sample = await ReactNativePerfStats.sample();
29
- console.log(sample); // { cpu: 12.3, rss: 187654144, timestamp: 1730000000000 }
31
+ console.log(sample);
32
+ // { cpu: 12.3, rss: 187654144, uiFps: 59, jsFps: 60, timestamp: 1730000000000 }
30
33
 
31
- // Tear down.
34
+ // Tear down. Also stops the JS-side rAF tracker.
32
35
  ReactNativePerfStats.hideOverlay();
33
36
  ReactNativePerfStats.stop();
34
37
  ```
35
38
 
36
39
  ### `PerfSample`
37
40
 
38
- | field | unit | notes |
39
- | ----------- | -------------------------- | ------------------------------------------------------------------------------------------------------ |
40
- | `cpu` | percent of one core | `(Δcpu / Δwall) * 100`. May exceed 100 on multi-core saturation. First sample after launch is `0`. |
41
- | `rss` | bytes | iOS `phys_footprint`, Android `VmRSS`. |
42
- | `timestamp` | ms since unix epoch | Wall-clock at sample time. |
41
+ | field | unit | notes |
42
+ | ----------- | --------------------- | ------------------------------------------------------------------------------------------------------ |
43
+ | `cpu` | percent of one core | `(Δcpu / Δwall) * 100`. May exceed 100 on multi-core saturation. First sample after launch is `0`. |
44
+ | `rss` | bytes | iOS `phys_footprint`, Android `VmRSS`. |
45
+ | `uiFps` | frames per second | UI thread frame rate over the last sampling window. `0` until at least one window has elapsed. |
46
+ | `jsFps` | frames per second | JS thread rAF count. `0` unless `startJsFpsTracker()` has been called and reported at least once. |
47
+ | `timestamp` | ms since unix epoch | Wall-clock at sample time. |
43
48
 
44
49
  ### API
45
50
 
46
- - `start(intervalMs: number): void` — minimum interval is clamped to 200 ms.
47
- - `stop(): void` — also hides the overlay.
51
+ - `start(intervalMs: number): void` — minimum interval is clamped to 200 ms. Also starts the JS-side rAF tracker (matched to `intervalMs`) so `jsFps` flows automatically.
52
+ - `stop(): void` — stops the JS-side tracker, the native sampler, and hides the overlay.
48
53
  - `showOverlay(): void` / `hideOverlay(): void` — overlay shows `--` until `start` runs.
49
54
  - `sample(): Promise<PerfSample>` — runs off the JS thread, shares baseline with the overlay sampler.
55
+ - `setJsFpsHint(fps: number): void` — low-level escape hatch; normally driven by the auto-managed tracker.
56
+ - `startJsFpsTracker(reportIntervalMs?: number): void` / `stopJsFpsTracker(): void` — manual control of the JS-side rAF tracker. Useful if you want JS FPS without enabling the native sampler. Calling `startJsFpsTracker` with a different `reportIntervalMs` while running restarts the loop with the new interval.
50
57
 
51
58
  ### Anomaly logging
52
59
 
53
60
  While the sampler is running, the native side emits a warn to `@onekeyfe/react-native-native-logger` (`OneKeyLog.warn`, tag `PerfStats`) whenever a metric stays over its threshold for **5 consecutive samples**. Each category has an independent **30 s cooldown** so a sustained spike produces one log every 30 s rather than one per sample.
54
61
 
55
- | metric | threshold |
56
- | ------ | --------- |
57
- | CPU | ≥ 150 % |
58
- | RSS | ≥ 800 MB |
62
+ | metric | threshold |
63
+ | ------ | ---------------- |
64
+ | CPU | ≥ 150 % |
65
+ | RSS | ≥ 800 MB |
66
+ | UI FPS | ≤ 45 fps (and > 0) |
67
+ | JS FPS | ≤ 30 fps (and > 0) |
59
68
 
60
69
  One-off `sample()` calls do **not** trip this path — only the periodic timer started by `start()` does.
61
70
 
@@ -12,10 +12,12 @@ import android.os.HandlerThread
12
12
  import android.os.Looper
13
13
  import android.os.SystemClock
14
14
  import android.util.TypedValue
15
+ import android.view.Choreographer
15
16
  import android.view.Gravity
16
17
  import android.view.MotionEvent
17
18
  import android.view.WindowManager
18
19
  import android.widget.TextView
20
+ import java.util.concurrent.atomic.AtomicInteger
19
21
  import com.facebook.proguard.annotations.DoNotStrip
20
22
  import com.margelo.nitro.NitroModules
21
23
  import com.margelo.nitro.core.Promise
@@ -37,8 +39,18 @@ private const val CLOCK_TICKS_PER_SECOND = 100L
37
39
  private const val CPU_ANOMALY_PCT = 150.0
38
40
  // 800 MiB.
39
41
  private const val RSS_ANOMALY_BYTES = 838_860_800.0
42
+ // Below ~45 fps the UI thread feels janky on a 60 Hz panel. On 90/120 Hz
43
+ // devices this still represents a clearly degraded experience, so we keep
44
+ // one threshold rather than introducing refresh-rate detection.
45
+ private const val UI_FPS_ANOMALY_BELOW = 45.0
46
+ // Below ~30 fps the JS thread is missing every other frame's RAF callback.
47
+ private const val JS_FPS_ANOMALY_BELOW = 30.0
40
48
  private const val ANOMALY_SUSTAIN_SAMPLES = 5
41
49
  private const val ANOMALY_COOLDOWN_MS = 30_000L
50
+ // JS FPS hints older than this are treated as stale and reported as 0,
51
+ // so the overlay doesn't keep showing a fossilised number after the
52
+ // JS-side tracker has been stopped (or is still booting).
53
+ private const val JS_FPS_HINT_TTL_MS = 2_000L
42
54
 
43
55
  @DoNotStrip
44
56
  class ReactNativePerfStats : HybridReactNativePerfStatsSpec() {
@@ -64,6 +76,10 @@ class ReactNativePerfStats : HybridReactNativePerfStatsSpec() {
64
76
  Sampler.takeSample()
65
77
  }
66
78
  }
79
+
80
+ override fun setJsFpsHint(fps: Double) {
81
+ JsFpsHolder.set(fps)
82
+ }
67
83
  }
68
84
 
69
85
  // ---- Sampler ----------------------------------------------------------
@@ -94,6 +110,10 @@ private object Sampler {
94
110
  private val lock = Any()
95
111
  @Volatile private var lastCpuTicks: Long = -1L
96
112
  @Volatile private var lastMonoNs: Long = -1L
113
+ // Last UI FPS computed by the periodic tick. takeSample() reads this
114
+ // directly so one-shot sample() calls do not steal frames from the
115
+ // UiFpsMonitor counter (which is owned by the periodic tick).
116
+ @Volatile private var lastUiFps: Double = 0.0
97
117
 
98
118
  // Anomaly state lives on the sampler HandlerThread (single consumer),
99
119
  // so no synchronization is needed. lastLogMs intentionally persists
@@ -101,8 +121,12 @@ private object Sampler {
101
121
  // caller toggles the sampler rapidly.
102
122
  private var cpuOverCount = 0
103
123
  private var rssOverCount = 0
124
+ private var uiFpsUnderCount = 0
125
+ private var jsFpsUnderCount = 0
104
126
  private var lastCpuLogMs = 0L
105
127
  private var lastRssLogMs = 0L
128
+ private var lastUiFpsLogMs = 0L
129
+ private var lastJsFpsLogMs = 0L
106
130
 
107
131
  fun start(intervalMsNew: Long) {
108
132
  synchronized(schedulerLock) {
@@ -116,6 +140,7 @@ private object Sampler {
116
140
  running = true
117
141
  scheduleTick(generation, handler!!)
118
142
  }
143
+ UiFpsMonitor.start()
119
144
  }
120
145
 
121
146
  fun stop() {
@@ -128,7 +153,9 @@ private object Sampler {
128
153
  handlerThread?.quitSafely()
129
154
  handlerThread = null
130
155
  handler = null
156
+ lastUiFps = 0.0
131
157
  }
158
+ UiFpsMonitor.stop()
132
159
  Overlay.hide()
133
160
  }
134
161
 
@@ -140,6 +167,9 @@ private object Sampler {
140
167
  genAtStart == generation && running
141
168
  }
142
169
  if (!active) return
170
+ // Refresh the cached UI FPS *before* takeSample reads it,
171
+ // so the value covers exactly the just-elapsed interval.
172
+ lastUiFps = UiFpsMonitor.readAndReset(System.nanoTime())
143
173
  val sample = takeSample()
144
174
  Overlay.update(sample)
145
175
  checkAnomalyAndLog(sample)
@@ -200,6 +230,49 @@ private object Sampler {
200
230
  } else {
201
231
  rssOverCount = 0
202
232
  }
233
+
234
+ // FPS thresholds. We require uiFps > 0 to avoid logging during the
235
+ // first sample (when no interval has elapsed yet); jsFps > 0 to skip
236
+ // when the JS-side tracker hasn't been started.
237
+ if (s.uiFps in 0.001..UI_FPS_ANOMALY_BELOW) {
238
+ uiFpsUnderCount++
239
+ if (uiFpsUnderCount >= ANOMALY_SUSTAIN_SAMPLES &&
240
+ nowMs - lastUiFpsLogMs >= ANOMALY_COOLDOWN_MS
241
+ ) {
242
+ OneKeyLog.warn(
243
+ TAG,
244
+ String.format(
245
+ Locale.US,
246
+ "Sustained low UI FPS: %.1f over %d samples",
247
+ s.uiFps, uiFpsUnderCount,
248
+ ),
249
+ )
250
+ lastUiFpsLogMs = nowMs
251
+ uiFpsUnderCount = 0
252
+ }
253
+ } else {
254
+ uiFpsUnderCount = 0
255
+ }
256
+
257
+ if (s.jsFps in 0.001..JS_FPS_ANOMALY_BELOW) {
258
+ jsFpsUnderCount++
259
+ if (jsFpsUnderCount >= ANOMALY_SUSTAIN_SAMPLES &&
260
+ nowMs - lastJsFpsLogMs >= ANOMALY_COOLDOWN_MS
261
+ ) {
262
+ OneKeyLog.warn(
263
+ TAG,
264
+ String.format(
265
+ Locale.US,
266
+ "Sustained low JS FPS: %.1f over %d samples",
267
+ s.jsFps, jsFpsUnderCount,
268
+ ),
269
+ )
270
+ lastJsFpsLogMs = nowMs
271
+ jsFpsUnderCount = 0
272
+ }
273
+ } else {
274
+ jsFpsUnderCount = 0
275
+ }
203
276
  }
204
277
 
205
278
  fun takeSample(): PerfSample {
@@ -230,6 +303,8 @@ private object Sampler {
230
303
  return PerfSample(
231
304
  cpu = cpuPct,
232
305
  rss = rssBytes.toDouble(),
306
+ uiFps = lastUiFps,
307
+ jsFps = JsFpsHolder.read(nowMonoNs),
233
308
  timestamp = nowWallMs,
234
309
  )
235
310
  }
@@ -282,6 +357,96 @@ private object Sampler {
282
357
  }
283
358
  }
284
359
 
360
+ // ---- UiFpsMonitor -----------------------------------------------------
361
+ //
362
+ // Counts main-thread frames via Choreographer.FrameCallback. The
363
+ // callback fires once per refresh cycle (60/90/120 Hz) when the UI
364
+ // thread is responsive, so the per-second count is a faithful proxy
365
+ // for "did the UI thread service its frame callbacks".
366
+ //
367
+ // `Choreographer.getInstance()` returns the per-thread instance, so
368
+ // the registration must run on the main looper to observe main-thread
369
+ // frames; the worker HandlerThread has no Choreographer.
370
+
371
+ private object UiFpsMonitor {
372
+ private val mainHandler = Handler(Looper.getMainLooper())
373
+ private val frameCounter = AtomicInteger(0)
374
+ @Volatile private var registered = false
375
+ @Volatile private var lastReadMonoNs: Long = -1L
376
+
377
+ private val callback = object : Choreographer.FrameCallback {
378
+ override fun doFrame(frameTimeNanos: Long) {
379
+ frameCounter.incrementAndGet()
380
+ if (registered) {
381
+ Choreographer.getInstance().postFrameCallback(this)
382
+ }
383
+ }
384
+ }
385
+
386
+ fun start() {
387
+ if (registered) return
388
+ registered = true
389
+ mainHandler.post {
390
+ // Re-check inside the post: a stop() may have raced ahead
391
+ // before this lambda ran.
392
+ if (!registered) return@post
393
+ Choreographer.getInstance().postFrameCallback(callback)
394
+ }
395
+ }
396
+
397
+ fun stop() {
398
+ if (!registered) return
399
+ registered = false
400
+ mainHandler.post {
401
+ Choreographer.getInstance().removeFrameCallback(callback)
402
+ }
403
+ frameCounter.set(0)
404
+ lastReadMonoNs = -1L
405
+ }
406
+
407
+ /**
408
+ * Returns the FPS observed since the previous call. The first call
409
+ * after [start] returns 0 because there's no baseline interval yet.
410
+ * Safe to invoke from any thread.
411
+ */
412
+ fun readAndReset(nowMonoNs: Long): Double {
413
+ val prev = lastReadMonoNs
414
+ val frames = frameCounter.getAndSet(0)
415
+ lastReadMonoNs = nowMonoNs
416
+ if (prev <= 0) return 0.0
417
+ val dWallSec = (nowMonoNs - prev).toDouble() / 1_000_000_000.0
418
+ if (dWallSec <= 0) return 0.0
419
+ return frames / dWallSec
420
+ }
421
+ }
422
+
423
+ // ---- JsFpsHolder ------------------------------------------------------
424
+ //
425
+ // Caches the most recent JS-thread FPS hint pushed via
426
+ // `setJsFpsHint`. The value is computed entirely on the JS side via
427
+ // the `requestAnimationFrame` ticker started by `startJsFpsTracker`,
428
+ // then reported here. We stale out hints older than [JS_FPS_HINT_TTL_MS]
429
+ // to avoid surfacing a fossilised number when the JS tracker has been
430
+ // stopped or has not yet booted.
431
+
432
+ private object JsFpsHolder {
433
+ @Volatile private var lastFps: Double = 0.0
434
+ @Volatile private var lastSetMonoNs: Long = -1L
435
+
436
+ fun set(fps: Double) {
437
+ lastFps = fps
438
+ lastSetMonoNs = System.nanoTime()
439
+ }
440
+
441
+ fun read(nowMonoNs: Long): Double {
442
+ val last = lastSetMonoNs
443
+ if (last < 0) return 0.0
444
+ val ageMs = (nowMonoNs - last) / 1_000_000L
445
+ if (ageMs > JS_FPS_HINT_TTL_MS) return 0.0
446
+ return lastFps
447
+ }
448
+ }
449
+
285
450
  // ---- Overlay ----------------------------------------------------------
286
451
  //
287
452
  // Attaches a TextView via WindowManager.addView at z=1005, the AOSP slot
@@ -452,8 +617,8 @@ internal object Overlay : Application.ActivityLifecycleCallbacks {
452
617
  )
453
618
  gravity = Gravity.START
454
619
  // Min width prevents re-layout when digit count changes
455
- minWidth = dp(activity, 110)
456
- text = "CPU: --\nRAM: --"
620
+ minWidth = dp(activity, 130)
621
+ text = "CPU: --\nRAM: --\nUI: --\nJS: --"
457
622
  }
458
623
  }
459
624
 
@@ -491,7 +656,9 @@ internal object Overlay : Application.ActivityLifecycleCallbacks {
491
656
  val cpu = if (s.cpu > 0) String.format(Locale.US, "%.1f%%", s.cpu) else "--"
492
657
  val mb = s.rss / 1024.0 / 1024.0
493
658
  val mem = if (mb > 0) String.format(Locale.US, "%.1f MB", mb) else "--"
494
- return "CPU: $cpu\nRAM: $mem"
659
+ val ui = if (s.uiFps > 0) String.format(Locale.US, "%.0f fps", s.uiFps) else "--"
660
+ val js = if (s.jsFps > 0) String.format(Locale.US, "%.0f fps", s.jsFps) else "--"
661
+ return "CPU: $cpu\nRAM: $mem\nUI: $ui\nJS: $js"
495
662
  }
496
663
 
497
664
  private fun dp(activity: Activity, v: Int): Int =
@@ -12,8 +12,16 @@ private let kMinIntervalMs: Double = 200
12
12
  // for kAnomalyCooldownSec to avoid flooding native-logger.
13
13
  private let kCpuAnomalyPct: Double = 150.0
14
14
  private let kRssAnomalyBytes: Double = 800.0 * 1024.0 * 1024.0
15
+ // Below ~45 fps the UI thread feels janky on a 60 Hz panel. On 90/120 Hz
16
+ // devices this still represents a clearly degraded experience, so we keep
17
+ // one threshold rather than introducing refresh-rate detection.
18
+ private let kUiFpsAnomalyBelow: Double = 45.0
19
+ // Below ~30 fps the JS thread is missing every other frame's RAF callback.
20
+ private let kJsFpsAnomalyBelow: Double = 30.0
15
21
  private let kAnomalySustainSamples: Int = 5
16
22
  private let kAnomalyCooldownSec: Double = 30.0
23
+ // JS FPS hints older than this are treated as stale and reported as 0.
24
+ private let kJsFpsHintTtlSec: Double = 2.0
17
25
 
18
26
  class ReactNativePerfStats: HybridReactNativePerfStatsSpec {
19
27
 
@@ -38,6 +46,10 @@ class ReactNativePerfStats: HybridReactNativePerfStatsSpec {
38
46
  return Sampler.shared.takeSample()
39
47
  }
40
48
  }
49
+
50
+ func setJsFpsHint(fps: Double) throws {
51
+ JsFpsHolder.shared.set(fps: fps)
52
+ }
41
53
  }
42
54
 
43
55
  // MARK: - Sampler
@@ -63,8 +75,17 @@ private final class Sampler {
63
75
  // toggles the sampler rapidly.
64
76
  private var cpuOverCount: Int = 0
65
77
  private var rssOverCount: Int = 0
78
+ private var uiFpsUnderCount: Int = 0
79
+ private var jsFpsUnderCount: Int = 0
66
80
  private var lastCpuLogSec: Double = 0
67
81
  private var lastRssLogSec: Double = 0
82
+ private var lastUiFpsLogSec: Double = 0
83
+ private var lastJsFpsLogSec: Double = 0
84
+
85
+ // Last UI FPS computed by the periodic timer. takeSample() reads this
86
+ // directly so one-shot sample() calls do not steal frames from the
87
+ // UiFpsMonitor counter (which is owned by the periodic timer).
88
+ private var lastUiFps: Double = 0
68
89
 
69
90
  func start(intervalMs ms: Double) {
70
91
  queue.async { [weak self] in
@@ -91,6 +112,9 @@ private final class Sampler {
91
112
  )
92
113
  t.setEventHandler { [weak self] in
93
114
  guard let self = self, self.running else { return }
115
+ // Refresh cached UI FPS *before* takeSample reads it, so
116
+ // the value covers exactly the just-elapsed interval.
117
+ self.lastUiFps = UiFpsMonitor.shared.readAndReset(nowSec: self.monotonicSec())
94
118
  let s = self.takeSample()
95
119
  Overlay.shared.update(sample: s)
96
120
  self.checkAnomalyAndLog(sample: s)
@@ -98,6 +122,7 @@ private final class Sampler {
98
122
  self.timer = t
99
123
  t.resume()
100
124
  }
125
+ UiFpsMonitor.shared.start()
101
126
  }
102
127
 
103
128
  func stop() {
@@ -106,7 +131,9 @@ private final class Sampler {
106
131
  self.running = false
107
132
  self.timer?.cancel()
108
133
  self.timer = nil
134
+ self.lastUiFps = 0
109
135
  }
136
+ UiFpsMonitor.shared.stop()
110
137
  Overlay.shared.hide()
111
138
  }
112
139
 
@@ -134,6 +161,8 @@ private final class Sampler {
134
161
  return PerfSample(
135
162
  cpu: cpuPct,
136
163
  rss: Double(rssBytes),
164
+ uiFps: lastUiFps,
165
+ jsFps: JsFpsHolder.shared.read(nowSec: nowMono),
137
166
  timestamp: nowWallMs
138
167
  )
139
168
  }
@@ -181,6 +210,45 @@ private final class Sampler {
181
210
  } else {
182
211
  rssOverCount = 0
183
212
  }
213
+
214
+ // FPS thresholds. Require uiFps > 0 to avoid logging the very first
215
+ // sample (no interval yet), and jsFps > 0 to skip when no JS-side
216
+ // tracker has been started.
217
+ if s.uiFps > 0 && s.uiFps <= kUiFpsAnomalyBelow {
218
+ uiFpsUnderCount += 1
219
+ if uiFpsUnderCount >= kAnomalySustainSamples,
220
+ nowSec - lastUiFpsLogSec >= kAnomalyCooldownSec {
221
+ OneKeyLog.warn(
222
+ kTag,
223
+ String(
224
+ format: "Sustained low UI FPS: %.1f over %d samples",
225
+ s.uiFps, uiFpsUnderCount
226
+ )
227
+ )
228
+ lastUiFpsLogSec = nowSec
229
+ uiFpsUnderCount = 0
230
+ }
231
+ } else {
232
+ uiFpsUnderCount = 0
233
+ }
234
+
235
+ if s.jsFps > 0 && s.jsFps <= kJsFpsAnomalyBelow {
236
+ jsFpsUnderCount += 1
237
+ if jsFpsUnderCount >= kAnomalySustainSamples,
238
+ nowSec - lastJsFpsLogSec >= kAnomalyCooldownSec {
239
+ OneKeyLog.warn(
240
+ kTag,
241
+ String(
242
+ format: "Sustained low JS FPS: %.1f over %d samples",
243
+ s.jsFps, jsFpsUnderCount
244
+ )
245
+ )
246
+ lastJsFpsLogSec = nowSec
247
+ jsFpsUnderCount = 0
248
+ }
249
+ } else {
250
+ jsFpsUnderCount = 0
251
+ }
184
252
  }
185
253
 
186
254
  private func monotonicSec() -> Double {
@@ -234,21 +302,153 @@ private final class Sampler {
234
302
  }
235
303
  }
236
304
 
305
+ // MARK: - UiFpsMonitor
306
+ //
307
+ // CADisplayLink fires once per main-thread refresh cycle (60/90/120 Hz)
308
+ // when the UI thread is responsive, so the per-second count is a faithful
309
+ // proxy for "did the main thread service its frame callbacks". The link
310
+ // must be attached to the main RunLoop, so start/stop dispatch to .main.
311
+
312
+ private final class UiFpsMonitor: NSObject {
313
+ static let shared = UiFpsMonitor()
314
+
315
+ private override init() { super.init() }
316
+
317
+ private var displayLink: CADisplayLink?
318
+ // Read-modify-write of frameCounter happens on the display-link
319
+ // callback (main thread) and on readAndReset (sampler thread), so
320
+ // guard with a lock. Lightweight: two integer ops per call.
321
+ private let counterLock = NSLock()
322
+ private var frameCounter: Int = 0
323
+ private var lastReadSec: Double = -1
324
+
325
+ func start() {
326
+ DispatchQueue.main.async { [weak self] in
327
+ guard let self = self else { return }
328
+ if self.displayLink != nil { return }
329
+ let link = CADisplayLink(target: self, selector: #selector(self.tick))
330
+ link.add(to: .main, forMode: .common)
331
+ self.displayLink = link
332
+ }
333
+ }
334
+
335
+ func stop() {
336
+ DispatchQueue.main.async { [weak self] in
337
+ guard let self = self else { return }
338
+ self.displayLink?.invalidate()
339
+ self.displayLink = nil
340
+ }
341
+ counterLock.lock()
342
+ frameCounter = 0
343
+ lastReadSec = -1
344
+ counterLock.unlock()
345
+ }
346
+
347
+ @objc private func tick(link: CADisplayLink) {
348
+ counterLock.lock()
349
+ frameCounter += 1
350
+ counterLock.unlock()
351
+ }
352
+
353
+ /// Returns the FPS observed since the previous call. The first call
354
+ /// after `start` returns 0 because there's no baseline interval yet.
355
+ /// Safe to invoke from any thread.
356
+ func readAndReset(nowSec: Double) -> Double {
357
+ counterLock.lock()
358
+ let frames = frameCounter
359
+ frameCounter = 0
360
+ let prev = lastReadSec
361
+ lastReadSec = nowSec
362
+ counterLock.unlock()
363
+ if prev <= 0 { return 0 }
364
+ let dWall = nowSec - prev
365
+ if dWall <= 0 { return 0 }
366
+ return Double(frames) / dWall
367
+ }
368
+ }
369
+
370
+ // MARK: - JsFpsHolder
371
+ //
372
+ // Caches the most recent JS-thread FPS hint pushed via `setJsFpsHint`.
373
+ // The value is computed entirely on the JS side via the
374
+ // `requestAnimationFrame` ticker started by `startJsFpsTracker`, then
375
+ // reported here. Hints older than `kJsFpsHintTtlSec` are reported as 0
376
+ // so the overlay doesn't keep showing a fossilised number after the
377
+ // JS tracker has been stopped (or has not yet booted).
378
+
379
+ private final class JsFpsHolder {
380
+ static let shared = JsFpsHolder()
381
+
382
+ private init() {}
383
+
384
+ private let lock = NSLock()
385
+ private var lastFps: Double = 0
386
+ private var lastSetSec: Double = -1
387
+
388
+ func set(fps: Double) {
389
+ let nowSec: Double = {
390
+ var ts = timespec()
391
+ if clock_gettime(CLOCK_MONOTONIC, &ts) != 0 {
392
+ return Date().timeIntervalSince1970
393
+ }
394
+ return Double(ts.tv_sec) + Double(ts.tv_nsec) / 1_000_000_000.0
395
+ }()
396
+ lock.lock()
397
+ lastFps = fps
398
+ lastSetSec = nowSec
399
+ lock.unlock()
400
+ }
401
+
402
+ func read(nowSec: Double) -> Double {
403
+ lock.lock()
404
+ let last = lastSetSec
405
+ let fps = lastFps
406
+ lock.unlock()
407
+ if last < 0 { return 0 }
408
+ if nowSec - last > kJsFpsHintTtlSec { return 0 }
409
+ return fps
410
+ }
411
+ }
412
+
237
413
  // MARK: - Overlay
238
414
  //
239
- // Singleton UILabel attached to the current key UIWindow. Updates always
240
- // dispatch to main. No floating-window permission needed; overlay only
241
- // shows while the app is in the foreground.
415
+ // Singleton overlay rendered on its own dedicated UIWindow at
416
+ // `.alert + 1` windowLevel. The dedicated window is required because
417
+ // React Native's <Modal> presents view controllers via UIKit's modal
418
+ // presentation, which renders above any subview added to the host
419
+ // app's main UIWindow — a UILabel attached to keyWindow ends up behind
420
+ // the modal regardless of view-hierarchy z-order. A separate UIWindow
421
+ // with a higher `windowLevel` sits above modal-presented view
422
+ // controllers and system alerts, so the overlay stays visible.
423
+ //
424
+ // `OverlayPassthroughWindow` overrides `hitTest` to return nil for
425
+ // touches outside the label, letting them fall through to the
426
+ // underlying app windows — otherwise the overlay window would swallow
427
+ // every tap on the screen.
242
428
  //
243
429
  // Inherits NSObject so UIPanGestureRecognizer's target/action selector
244
430
  // dispatch resolves cleanly via Obj-C runtime.
245
431
 
432
+ private final class OverlayPassthroughWindow: UIWindow {
433
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
434
+ let hit = super.hitTest(point, with: event)
435
+ // Anything that bubbles up to the window itself or its empty
436
+ // root view means the touch missed our overlay subview — pass it
437
+ // through to whatever's below this window.
438
+ if hit === self || hit === self.rootViewController?.view {
439
+ return nil
440
+ }
441
+ return hit
442
+ }
443
+ }
444
+
246
445
  private final class Overlay: NSObject {
247
446
  static let shared = Overlay()
248
447
 
249
448
  private override init() { super.init() }
250
449
 
251
450
  private var label: UILabel?
451
+ private var overlayWindow: UIWindow?
252
452
  private var visible = false
253
453
 
254
454
  // `_lastSample` is written by the Sampler timer thread and read by the
@@ -308,27 +508,44 @@ private final class Overlay: NSObject {
308
508
 
309
509
  private func attach() {
310
510
  if label != nil { return }
311
- guard let window = currentKeyWindow() else {
312
- OneKeyLog.warn(kTag, "No key UIWindow available; overlay deferred")
511
+ guard let scene = currentForegroundScene() else {
512
+ OneKeyLog.warn(kTag, "No foreground UIWindowScene; overlay deferred")
313
513
  return
314
514
  }
315
515
 
316
- let lbl = UILabel(frame: CGRect(x: 30, y: 100, width: 160, height: 60))
516
+ // Dedicated host VC keeps the window's view hierarchy minimal
517
+ // the empty UIView serves only as the parent for the label and
518
+ // as the hitTest sentinel checked by OverlayPassthroughWindow.
519
+ let host = UIViewController()
520
+ host.view.backgroundColor = .clear
521
+
522
+ let window = OverlayPassthroughWindow(windowScene: scene)
523
+ window.frame = scene.coordinateSpace.bounds
524
+ // .alert is 2000 on iOS; +1 puts the overlay above modal-presented
525
+ // controllers, system alerts and action sheets. Status bar is
526
+ // .statusBar (1000), which we stay below intentionally.
527
+ window.windowLevel = UIWindow.Level.alert + 1
528
+ window.backgroundColor = .clear
529
+ window.isHidden = false
530
+ window.rootViewController = host
531
+
532
+ let lbl = UILabel(frame: CGRect(x: 30, y: 100, width: 170, height: 92))
317
533
  lbl.backgroundColor = UIColor.black.withAlphaComponent(0.7)
318
534
  lbl.layer.cornerRadius = 8
319
535
  lbl.layer.masksToBounds = true
320
536
  lbl.textColor = .white
321
537
  lbl.font = .systemFont(ofSize: 13, weight: .bold)
322
538
  lbl.textAlignment = .center
323
- lbl.numberOfLines = 2
324
- lbl.text = "CPU: --\nRAM: --"
539
+ lbl.numberOfLines = 4
540
+ lbl.text = "CPU: --\nRAM: --\nUI: --\nJS: --"
325
541
  lbl.isUserInteractionEnabled = true
326
542
 
327
543
  let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
328
544
  lbl.addGestureRecognizer(pan)
329
545
 
330
- window.addSubview(lbl)
546
+ host.view.addSubview(lbl)
331
547
  label = lbl
548
+ overlayWindow = window
332
549
 
333
550
  if let s = lastSample {
334
551
  lbl.text = renderText(s)
@@ -338,6 +555,13 @@ private final class Overlay: NSObject {
338
555
  private func detach() {
339
556
  label?.removeFromSuperview()
340
557
  label = nil
558
+ // Hide before nilling so UIKit can release the window cleanly;
559
+ // assigning nil to rootViewController first avoids a fleeting
560
+ // warning when the window is torn down with a controller still
561
+ // attached.
562
+ overlayWindow?.isHidden = true
563
+ overlayWindow?.rootViewController = nil
564
+ overlayWindow = nil
341
565
  }
342
566
 
343
567
  @objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
@@ -360,32 +584,24 @@ private final class Overlay: NSObject {
360
584
  let cpuStr = s.cpu > 0 ? String(format: "%.1f%%", s.cpu) : "--"
361
585
  let mb = s.rss / 1024.0 / 1024.0
362
586
  let memStr = mb > 0 ? String(format: "%.1f MB", mb) : "--"
363
- return "CPU: \(cpuStr)\nRAM: \(memStr)"
587
+ let uiStr = s.uiFps > 0 ? String(format: "%.0f fps", s.uiFps) : "--"
588
+ let jsStr = s.jsFps > 0 ? String(format: "%.0f fps", s.jsFps) : "--"
589
+ return "CPU: \(cpuStr)\nRAM: \(memStr)\nUI: \(uiStr)\nJS: \(jsStr)"
364
590
  }
365
591
 
366
- private func currentKeyWindow() -> UIWindow? {
367
- // iOS 15+ preferred path
368
- if #available(iOS 15.0, *) {
369
- for scene in UIApplication.shared.connectedScenes {
370
- guard
371
- let windowScene = scene as? UIWindowScene,
372
- windowScene.activationState == .foregroundActive
373
- else { continue }
374
- if let kw = windowScene.keyWindow {
375
- return kw
376
- }
377
- }
378
- }
379
- // Fallback: scan all connected scenes
592
+ /// Locate an active foreground `UIWindowScene` to host the overlay
593
+ /// window. Prefers `.foregroundActive`; falls back to the first
594
+ /// connected `UIWindowScene` so we still attach during transient
595
+ /// states like cold launch when no scene is `.foregroundActive` yet.
596
+ private func currentForegroundScene() -> UIWindowScene? {
597
+ var fallback: UIWindowScene?
380
598
  for scene in UIApplication.shared.connectedScenes {
381
- guard let windowScene = scene as? UIWindowScene else { continue }
382
- if let kw = windowScene.windows.first(where: { $0.isKeyWindow }) {
383
- return kw
384
- }
385
- if let any = windowScene.windows.first {
386
- return any
599
+ guard let ws = scene as? UIWindowScene else { continue }
600
+ if ws.activationState == .foregroundActive {
601
+ return ws
387
602
  }
603
+ if fallback == nil { fallback = ws }
388
604
  }
389
- return nil
605
+ return fallback
390
606
  }
391
607
  }
@@ -1,6 +1,87 @@
1
1
  "use strict";
2
2
 
3
3
  import { NitroModules } from 'react-native-nitro-modules';
4
- const ReactNativePerfStatsHybridObject = NitroModules.createHybridObject('ReactNativePerfStats');
5
- export const ReactNativePerfStats = ReactNativePerfStatsHybridObject;
4
+ const nativeImpl = NitroModules.createHybridObject('ReactNativePerfStats');
5
+ // ---- JS FPS tracker ----------------------------------------------------
6
+ //
7
+ // Counts frames the JS event loop services per second using
8
+ // `requestAnimationFrame` and pushes the value to native via
9
+ // `setJsFpsHint`. Native then surfaces it as `PerfSample.jsFps`.
10
+ //
11
+ // Why rAF and not a setTimeout-based ticker: rAF is scheduled by RN's
12
+ // UIManager from the platform vsync (Choreographer / CADisplayLink)
13
+ // and dispatched as a JS task. When the JS thread is blocked, rAF
14
+ // callbacks are coalesced and the per-second count drops, exactly
15
+ // reflecting "how many frame opportunities did JS service" — which is
16
+ // what users mean by "JS FPS". A setTimeout(16) loop measures timer
17
+ // drift, which is correlated but coarser.
18
+ //
19
+ // Cost: one int increment + one rAF call per frame (~60–120/s) plus one
20
+ // `setJsFpsHint` call per second. Negligible vs. typical app workload.
21
+
22
+ let jsFpsRafId = null;
23
+ let jsFpsIntervalId = null;
24
+ let jsFpsFrameCount = 0;
25
+ let jsFpsCurrentInterval = null;
26
+
27
+ /**
28
+ * Start the JS-side FPS ticker. Normally invoked automatically by
29
+ * `ReactNativePerfStats.start` and stopped by `.stop`; exported as
30
+ * an escape hatch for advanced flows (e.g. measuring JS FPS without
31
+ * the native sampler running).
32
+ *
33
+ * Calling with a new `reportIntervalMs` while already running
34
+ * restarts the loop with the new interval; calling with the same
35
+ * interval is a no-op.
36
+ */
37
+ export function startJsFpsTracker(reportIntervalMs = 1000) {
38
+ if (jsFpsCurrentInterval === reportIntervalMs) return;
39
+ stopJsFpsTracker();
40
+ jsFpsCurrentInterval = reportIntervalMs;
41
+ const tick = () => {
42
+ jsFpsFrameCount += 1;
43
+ jsFpsRafId = requestAnimationFrame(tick);
44
+ };
45
+ jsFpsRafId = requestAnimationFrame(tick);
46
+ jsFpsIntervalId = setInterval(() => {
47
+ const fps = jsFpsFrameCount * 1000 / reportIntervalMs;
48
+ jsFpsFrameCount = 0;
49
+ nativeImpl.setJsFpsHint(fps);
50
+ }, reportIntervalMs);
51
+ }
52
+
53
+ /** Stop the JS-side FPS ticker. Idempotent. */
54
+ export function stopJsFpsTracker() {
55
+ if (jsFpsRafId != null) {
56
+ cancelAnimationFrame(jsFpsRafId);
57
+ jsFpsRafId = null;
58
+ }
59
+ if (jsFpsIntervalId != null) {
60
+ clearInterval(jsFpsIntervalId);
61
+ jsFpsIntervalId = null;
62
+ }
63
+ jsFpsFrameCount = 0;
64
+ jsFpsCurrentInterval = null;
65
+ }
66
+
67
+ // `start` / `stop` wrap the native sampler so the JS-side rAF tracker
68
+ // shares its lifetime: callers never have to remember to enable it
69
+ // separately, and `PerfSample.jsFps` is populated as soon as the
70
+ // sampler is running. Other methods pass straight through to the
71
+ // HybridObject.
72
+
73
+ export const ReactNativePerfStats = {
74
+ start(intervalMs) {
75
+ nativeImpl.start(intervalMs);
76
+ startJsFpsTracker(intervalMs);
77
+ },
78
+ stop() {
79
+ stopJsFpsTracker();
80
+ nativeImpl.stop();
81
+ },
82
+ showOverlay: () => nativeImpl.showOverlay(),
83
+ hideOverlay: () => nativeImpl.hideOverlay(),
84
+ sample: () => nativeImpl.sample(),
85
+ setJsFpsHint: fps => nativeImpl.setJsFpsHint(fps)
86
+ };
6
87
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"names":["NitroModules","ReactNativePerfStatsHybridObject","createHybridObject","ReactNativePerfStats"],"sourceRoot":"../../src","sources":["index.tsx"],"mappings":";;AAAA,SAASA,YAAY,QAAQ,4BAA4B;AAGzD,MAAMC,gCAAgC,GACpCD,YAAY,CAACE,kBAAkB,CAA2B,sBAAsB,CAAC;AAEnF,OAAO,MAAMC,oBAAoB,GAAGF,gCAAgC","ignoreList":[]}
1
+ {"version":3,"names":["NitroModules","nativeImpl","createHybridObject","jsFpsRafId","jsFpsIntervalId","jsFpsFrameCount","jsFpsCurrentInterval","startJsFpsTracker","reportIntervalMs","stopJsFpsTracker","tick","requestAnimationFrame","setInterval","fps","setJsFpsHint","cancelAnimationFrame","clearInterval","ReactNativePerfStats","start","intervalMs","stop","showOverlay","hideOverlay","sample"],"sourceRoot":"../../src","sources":["index.tsx"],"mappings":";;AAAA,SAASA,YAAY,QAAQ,4BAA4B;AAGzD,MAAMC,UAAU,GACdD,YAAY,CAACE,kBAAkB,CAA2B,sBAAsB,CAAC;AAInF;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA,IAAIC,UAAyB,GAAG,IAAI;AACpC,IAAIC,eAAsD,GAAG,IAAI;AACjE,IAAIC,eAAe,GAAG,CAAC;AACvB,IAAIC,oBAAmC,GAAG,IAAI;;AAE9C;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,OAAO,SAASC,iBAAiBA,CAACC,gBAAwB,GAAG,IAAI,EAAQ;EACvE,IAAIF,oBAAoB,KAAKE,gBAAgB,EAAE;EAC/CC,gBAAgB,CAAC,CAAC;EAClBH,oBAAoB,GAAGE,gBAAgB;EAEvC,MAAME,IAAI,GAAGA,CAAA,KAAM;IACjBL,eAAe,IAAI,CAAC;IACpBF,UAAU,GAAGQ,qBAAqB,CAACD,IAAI,CAAC;EAC1C,CAAC;EACDP,UAAU,GAAGQ,qBAAqB,CAACD,IAAI,CAAC;EAExCN,eAAe,GAAGQ,WAAW,CAAC,MAAM;IAClC,MAAMC,GAAG,GAAIR,eAAe,GAAG,IAAI,GAAIG,gBAAgB;IACvDH,eAAe,GAAG,CAAC;IACnBJ,UAAU,CAACa,YAAY,CAACD,GAAG,CAAC;EAC9B,CAAC,EAAEL,gBAAgB,CAAC;AACtB;;AAEA;AACA,OAAO,SAASC,gBAAgBA,CAAA,EAAS;EACvC,IAAIN,UAAU,IAAI,IAAI,EAAE;IACtBY,oBAAoB,CAACZ,UAAU,CAAC;IAChCA,UAAU,GAAG,IAAI;EACnB;EACA,IAAIC,eAAe,IAAI,IAAI,EAAE;IAC3BY,aAAa,CAACZ,eAAe,CAAC;IAC9BA,eAAe,GAAG,IAAI;EACxB;EACAC,eAAe,GAAG,CAAC;EACnBC,oBAAoB,GAAG,IAAI;AAC7B;;AAEA;AACA;AACA;AACA;AACA;;AAEA,OAAO,MAAMW,oBAAoB,GAAG;EAClCC,KAAKA,CAACC,UAAkB,EAAQ;IAC9BlB,UAAU,CAACiB,KAAK,CAACC,UAAU,CAAC;IAC5BZ,iBAAiB,CAACY,UAAU,CAAC;EAC/B,CAAC;EACDC,IAAIA,CAAA,EAAS;IACXX,gBAAgB,CAAC,CAAC;IAClBR,UAAU,CAACmB,IAAI,CAAC,CAAC;EACnB,CAAC;EACDC,WAAW,EAAEA,CAAA,KAAYpB,UAAU,CAACoB,WAAW,CAAC,CAAC;EACjDC,WAAW,EAAEA,CAAA,KAAYrB,UAAU,CAACqB,WAAW,CAAC,CAAC;EACjDC,MAAM,EAAEA,CAAA,KAAMtB,UAAU,CAACsB,MAAM,CAAC,CAAC;EACjCT,YAAY,EAAGD,GAAW,IAAWZ,UAAU,CAACa,YAAY,CAACD,GAAG;AAClE,CAAC","ignoreList":[]}
@@ -9,6 +9,18 @@ export interface PerfSample {
9
9
  cpu: number;
10
10
  /** Resident set size in bytes. iOS: phys_footprint; Android: VmRSS. */
11
11
  rss: number;
12
+ /**
13
+ * UI thread frame rate, frames per second over the last 1 s window.
14
+ * Measured on the platform's main thread via Choreographer (Android)
15
+ * or CADisplayLink (iOS). `0` until at least one window has elapsed.
16
+ */
17
+ uiFps: number;
18
+ /**
19
+ * JS thread frame rate, frames per second reported by the JS-side
20
+ * `requestAnimationFrame` ticker (see `startJsFpsTracker`). `0` if
21
+ * the tracker has not been started or no hint has been received yet.
22
+ */
23
+ jsFps: number;
12
24
  /** Wall-clock timestamp (ms since unix epoch) when the sample was taken. */
13
25
  timestamp: number;
14
26
  }
@@ -47,5 +59,15 @@ export interface ReactNativePerfStats extends HybridObject<{
47
59
  * Shares the same delta baseline as the overlay sampler.
48
60
  */
49
61
  sample(): Promise<PerfSample>;
62
+ /**
63
+ * Push the latest JS-thread frame count from the JS-side
64
+ * `requestAnimationFrame` ticker. Native stores the value and
65
+ * surfaces it as `PerfSample.jsFps` on the next sample. Cheap;
66
+ * intended to be called once per second.
67
+ *
68
+ * Prefer the `startJsFpsTracker` helper exported from JS rather
69
+ * than calling this directly.
70
+ */
71
+ setJsFpsHint(fps: number): void;
50
72
  }
51
73
  //# sourceMappingURL=ReactNativePerfStats.nitro.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ReactNativePerfStats.nitro.d.ts","sourceRoot":"","sources":["../../../src/ReactNativePerfStats.nitro.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,4BAA4B,CAAC;AAE/D,MAAM,WAAW,UAAU;IACzB;;;;;OAKG;IACH,GAAG,EAAE,MAAM,CAAC;IACZ,uEAAuE;IACvE,GAAG,EAAE,MAAM,CAAC;IACZ,4EAA4E;IAC5E,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,oBACf,SAAQ,YAAY,CAAC;IAAE,GAAG,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,QAAQ,CAAA;CAAE,CAAC;IACzD;;;;;;;;OAQG;IACH,KAAK,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAEhC,+EAA+E;IAC/E,IAAI,IAAI,IAAI,CAAC;IAEb;;;;;;;;;OASG;IACH,WAAW,IAAI,IAAI,CAAC;IAEpB,gFAAgF;IAChF,WAAW,IAAI,IAAI,CAAC;IAEpB;;;;OAIG;IACH,MAAM,IAAI,OAAO,CAAC,UAAU,CAAC,CAAC;CAC/B"}
1
+ {"version":3,"file":"ReactNativePerfStats.nitro.d.ts","sourceRoot":"","sources":["../../../src/ReactNativePerfStats.nitro.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,4BAA4B,CAAC;AAE/D,MAAM,WAAW,UAAU;IACzB;;;;;OAKG;IACH,GAAG,EAAE,MAAM,CAAC;IACZ,uEAAuE;IACvE,GAAG,EAAE,MAAM,CAAC;IACZ;;;;OAIG;IACH,KAAK,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,KAAK,EAAE,MAAM,CAAC;IACd,4EAA4E;IAC5E,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,oBACf,SAAQ,YAAY,CAAC;IAAE,GAAG,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,QAAQ,CAAA;CAAE,CAAC;IACzD;;;;;;;;OAQG;IACH,KAAK,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAEhC,+EAA+E;IAC/E,IAAI,IAAI,IAAI,CAAC;IAEb;;;;;;;;;OASG;IACH,WAAW,IAAI,IAAI,CAAC;IAEpB,gFAAgF;IAChF,WAAW,IAAI,IAAI,CAAC;IAEpB;;;;OAIG;IACH,MAAM,IAAI,OAAO,CAAC,UAAU,CAAC,CAAC;IAE9B;;;;;;;;OAQG;IACH,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;CACjC"}
@@ -1,4 +1,23 @@
1
- import type { ReactNativePerfStats as ReactNativePerfStatsType } from './ReactNativePerfStats.nitro';
2
- export declare const ReactNativePerfStats: ReactNativePerfStatsType;
3
1
  export type * from './ReactNativePerfStats.nitro';
2
+ /**
3
+ * Start the JS-side FPS ticker. Normally invoked automatically by
4
+ * `ReactNativePerfStats.start` and stopped by `.stop`; exported as
5
+ * an escape hatch for advanced flows (e.g. measuring JS FPS without
6
+ * the native sampler running).
7
+ *
8
+ * Calling with a new `reportIntervalMs` while already running
9
+ * restarts the loop with the new interval; calling with the same
10
+ * interval is a no-op.
11
+ */
12
+ export declare function startJsFpsTracker(reportIntervalMs?: number): void;
13
+ /** Stop the JS-side FPS ticker. Idempotent. */
14
+ export declare function stopJsFpsTracker(): void;
15
+ export declare const ReactNativePerfStats: {
16
+ start(intervalMs: number): void;
17
+ stop(): void;
18
+ showOverlay: () => void;
19
+ hideOverlay: () => void;
20
+ sample: () => Promise<import("./ReactNativePerfStats.nitro").PerfSample>;
21
+ setJsFpsHint: (fps: number) => void;
22
+ };
4
23
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/index.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,oBAAoB,IAAI,wBAAwB,EAAE,MAAM,8BAA8B,CAAC;AAKrG,eAAO,MAAM,oBAAoB,0BAAmC,CAAC;AACrE,mBAAmB,8BAA8B,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/index.tsx"],"names":[],"mappings":"AAMA,mBAAmB,8BAA8B,CAAC;AAwBlD;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAAC,gBAAgB,GAAE,MAAa,GAAG,IAAI,CAgBvE;AAED,+CAA+C;AAC/C,wBAAgB,gBAAgB,IAAI,IAAI,CAWvC;AAQD,eAAO,MAAM,oBAAoB;sBACb,MAAM,GAAG,IAAI;YAIvB,IAAI;uBAIK,IAAI;uBACJ,IAAI;;wBAED,MAAM,KAAG,IAAI;CAClC,CAAC"}
@@ -79,5 +79,9 @@ namespace margelo::nitro::reactnativeperfstats {
79
79
  return __promise;
80
80
  }();
81
81
  }
82
+ void JHybridReactNativePerfStatsSpec::setJsFpsHint(double fps) {
83
+ static const auto method = javaClassStatic()->getMethod<void(double /* fps */)>("setJsFpsHint");
84
+ method(_javaPart, fps);
85
+ }
82
86
 
83
87
  } // namespace margelo::nitro::reactnativeperfstats
@@ -59,6 +59,7 @@ namespace margelo::nitro::reactnativeperfstats {
59
59
  void showOverlay() override;
60
60
  void hideOverlay() override;
61
61
  std::shared_ptr<Promise<PerfSample>> sample() override;
62
+ void setJsFpsHint(double fps) override;
62
63
 
63
64
  private:
64
65
  friend HybridBase;
@@ -35,11 +35,17 @@ namespace margelo::nitro::reactnativeperfstats {
35
35
  double cpu = this->getFieldValue(fieldCpu);
36
36
  static const auto fieldRss = clazz->getField<double>("rss");
37
37
  double rss = this->getFieldValue(fieldRss);
38
+ static const auto fieldUiFps = clazz->getField<double>("uiFps");
39
+ double uiFps = this->getFieldValue(fieldUiFps);
40
+ static const auto fieldJsFps = clazz->getField<double>("jsFps");
41
+ double jsFps = this->getFieldValue(fieldJsFps);
38
42
  static const auto fieldTimestamp = clazz->getField<double>("timestamp");
39
43
  double timestamp = this->getFieldValue(fieldTimestamp);
40
44
  return PerfSample(
41
45
  cpu,
42
46
  rss,
47
+ uiFps,
48
+ jsFps,
43
49
  timestamp
44
50
  );
45
51
  }
@@ -50,13 +56,15 @@ namespace margelo::nitro::reactnativeperfstats {
50
56
  */
51
57
  [[maybe_unused]]
52
58
  static jni::local_ref<JPerfSample::javaobject> fromCpp(const PerfSample& value) {
53
- using JSignature = JPerfSample(double, double, double);
59
+ using JSignature = JPerfSample(double, double, double, double, double);
54
60
  static const auto clazz = javaClassStatic();
55
61
  static const auto create = clazz->getStaticMethod<JSignature>("fromCpp");
56
62
  return create(
57
63
  clazz,
58
64
  value.cpu,
59
65
  value.rss,
66
+ value.uiFps,
67
+ value.jsFps,
60
68
  value.timestamp
61
69
  );
62
70
  }
@@ -65,6 +65,10 @@ abstract class HybridReactNativePerfStatsSpec: HybridObject() {
65
65
  @DoNotStrip
66
66
  @Keep
67
67
  abstract fun sample(): Promise<PerfSample>
68
+
69
+ @DoNotStrip
70
+ @Keep
71
+ abstract fun setJsFpsHint(fps: Double): Unit
68
72
 
69
73
  private external fun initHybrid(): HybridData
70
74
 
@@ -25,6 +25,12 @@ data class PerfSample(
25
25
  val rss: Double,
26
26
  @DoNotStrip
27
27
  @Keep
28
+ val uiFps: Double,
29
+ @DoNotStrip
30
+ @Keep
31
+ val jsFps: Double,
32
+ @DoNotStrip
33
+ @Keep
28
34
  val timestamp: Double
29
35
  ) {
30
36
  /* primary constructor */
@@ -37,8 +43,8 @@ data class PerfSample(
37
43
  @Keep
38
44
  @Suppress("unused")
39
45
  @JvmStatic
40
- private fun fromCpp(cpu: Double, rss: Double, timestamp: Double): PerfSample {
41
- return PerfSample(cpu, rss, timestamp)
46
+ private fun fromCpp(cpu: Double, rss: Double, uiFps: Double, jsFps: Double, timestamp: Double): PerfSample {
47
+ return PerfSample(cpu, rss, uiFps, jsFps, timestamp)
42
48
  }
43
49
  }
44
50
  }
@@ -94,6 +94,12 @@ namespace margelo::nitro::reactnativeperfstats {
94
94
  auto __value = std::move(__result.value());
95
95
  return __value;
96
96
  }
97
+ inline void setJsFpsHint(double fps) override {
98
+ auto __result = _swiftPart.setJsFpsHint(std::forward<decltype(fps)>(fps));
99
+ if (__result.hasError()) [[unlikely]] {
100
+ std::rethrow_exception(__result.error());
101
+ }
102
+ }
97
103
 
98
104
  private:
99
105
  ReactNativePerfStats::HybridReactNativePerfStatsSpec_cxx _swiftPart;
@@ -19,6 +19,7 @@ public protocol HybridReactNativePerfStatsSpec_protocol: HybridObject {
19
19
  func showOverlay() throws -> Void
20
20
  func hideOverlay() throws -> Void
21
21
  func sample() throws -> Promise<PerfSample>
22
+ func setJsFpsHint(fps: Double) throws -> Void
22
23
  }
23
24
 
24
25
  public extension HybridReactNativePerfStatsSpec_protocol {
@@ -179,4 +179,15 @@ open class HybridReactNativePerfStatsSpec_cxx {
179
179
  return bridge.create_Result_std__shared_ptr_Promise_PerfSample___(__exceptionPtr)
180
180
  }
181
181
  }
182
+
183
+ @inline(__always)
184
+ public final func setJsFpsHint(fps: Double) -> bridge.Result_void_ {
185
+ do {
186
+ try self.__implementation.setJsFpsHint(fps: fps)
187
+ return bridge.create_Result_void_()
188
+ } catch (let __error) {
189
+ let __exceptionPtr = __error.toCpp()
190
+ return bridge.create_Result_void_(__exceptionPtr)
191
+ }
192
+ }
182
193
  }
@@ -19,8 +19,8 @@ public extension PerfSample {
19
19
  /**
20
20
  * Create a new instance of `PerfSample`.
21
21
  */
22
- init(cpu: Double, rss: Double, timestamp: Double) {
23
- self.init(cpu, rss, timestamp)
22
+ init(cpu: Double, rss: Double, uiFps: Double, jsFps: Double, timestamp: Double) {
23
+ self.init(cpu, rss, uiFps, jsFps, timestamp)
24
24
  }
25
25
 
26
26
  var cpu: Double {
@@ -45,6 +45,28 @@ public extension PerfSample {
45
45
  }
46
46
  }
47
47
 
48
+ var uiFps: Double {
49
+ @inline(__always)
50
+ get {
51
+ return self.__uiFps
52
+ }
53
+ @inline(__always)
54
+ set {
55
+ self.__uiFps = newValue
56
+ }
57
+ }
58
+
59
+ var jsFps: Double {
60
+ @inline(__always)
61
+ get {
62
+ return self.__jsFps
63
+ }
64
+ @inline(__always)
65
+ set {
66
+ self.__jsFps = newValue
67
+ }
68
+ }
69
+
48
70
  var timestamp: Double {
49
71
  @inline(__always)
50
72
  get {
@@ -19,6 +19,7 @@ namespace margelo::nitro::reactnativeperfstats {
19
19
  prototype.registerHybridMethod("showOverlay", &HybridReactNativePerfStatsSpec::showOverlay);
20
20
  prototype.registerHybridMethod("hideOverlay", &HybridReactNativePerfStatsSpec::hideOverlay);
21
21
  prototype.registerHybridMethod("sample", &HybridReactNativePerfStatsSpec::sample);
22
+ prototype.registerHybridMethod("setJsFpsHint", &HybridReactNativePerfStatsSpec::setJsFpsHint);
22
23
  });
23
24
  }
24
25
 
@@ -55,6 +55,7 @@ namespace margelo::nitro::reactnativeperfstats {
55
55
  virtual void showOverlay() = 0;
56
56
  virtual void hideOverlay() = 0;
57
57
  virtual std::shared_ptr<Promise<PerfSample>> sample() = 0;
58
+ virtual void setJsFpsHint(double fps) = 0;
58
59
 
59
60
  protected:
60
61
  // Hybrid Setup
@@ -36,11 +36,13 @@ namespace margelo::nitro::reactnativeperfstats {
36
36
  public:
37
37
  double cpu SWIFT_PRIVATE;
38
38
  double rss SWIFT_PRIVATE;
39
+ double uiFps SWIFT_PRIVATE;
40
+ double jsFps SWIFT_PRIVATE;
39
41
  double timestamp SWIFT_PRIVATE;
40
42
 
41
43
  public:
42
44
  PerfSample() = default;
43
- explicit PerfSample(double cpu, double rss, double timestamp): cpu(cpu), rss(rss), timestamp(timestamp) {}
45
+ explicit PerfSample(double cpu, double rss, double uiFps, double jsFps, double timestamp): cpu(cpu), rss(rss), uiFps(uiFps), jsFps(jsFps), timestamp(timestamp) {}
44
46
  };
45
47
 
46
48
  } // namespace margelo::nitro::reactnativeperfstats
@@ -55,6 +57,8 @@ namespace margelo::nitro {
55
57
  return margelo::nitro::reactnativeperfstats::PerfSample(
56
58
  JSIConverter<double>::fromJSI(runtime, obj.getProperty(runtime, "cpu")),
57
59
  JSIConverter<double>::fromJSI(runtime, obj.getProperty(runtime, "rss")),
60
+ JSIConverter<double>::fromJSI(runtime, obj.getProperty(runtime, "uiFps")),
61
+ JSIConverter<double>::fromJSI(runtime, obj.getProperty(runtime, "jsFps")),
58
62
  JSIConverter<double>::fromJSI(runtime, obj.getProperty(runtime, "timestamp"))
59
63
  );
60
64
  }
@@ -62,6 +66,8 @@ namespace margelo::nitro {
62
66
  jsi::Object obj(runtime);
63
67
  obj.setProperty(runtime, "cpu", JSIConverter<double>::toJSI(runtime, arg.cpu));
64
68
  obj.setProperty(runtime, "rss", JSIConverter<double>::toJSI(runtime, arg.rss));
69
+ obj.setProperty(runtime, "uiFps", JSIConverter<double>::toJSI(runtime, arg.uiFps));
70
+ obj.setProperty(runtime, "jsFps", JSIConverter<double>::toJSI(runtime, arg.jsFps));
65
71
  obj.setProperty(runtime, "timestamp", JSIConverter<double>::toJSI(runtime, arg.timestamp));
66
72
  return obj;
67
73
  }
@@ -75,6 +81,8 @@ namespace margelo::nitro {
75
81
  }
76
82
  if (!JSIConverter<double>::canConvert(runtime, obj.getProperty(runtime, "cpu"))) return false;
77
83
  if (!JSIConverter<double>::canConvert(runtime, obj.getProperty(runtime, "rss"))) return false;
84
+ if (!JSIConverter<double>::canConvert(runtime, obj.getProperty(runtime, "uiFps"))) return false;
85
+ if (!JSIConverter<double>::canConvert(runtime, obj.getProperty(runtime, "jsFps"))) return false;
78
86
  if (!JSIConverter<double>::canConvert(runtime, obj.getProperty(runtime, "timestamp"))) return false;
79
87
  return true;
80
88
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/react-native-perf-stats",
3
- "version": "3.0.26",
3
+ "version": "3.0.28",
4
4
  "description": "react-native-perf-stats",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",
@@ -10,6 +10,18 @@ export interface PerfSample {
10
10
  cpu: number;
11
11
  /** Resident set size in bytes. iOS: phys_footprint; Android: VmRSS. */
12
12
  rss: number;
13
+ /**
14
+ * UI thread frame rate, frames per second over the last 1 s window.
15
+ * Measured on the platform's main thread via Choreographer (Android)
16
+ * or CADisplayLink (iOS). `0` until at least one window has elapsed.
17
+ */
18
+ uiFps: number;
19
+ /**
20
+ * JS thread frame rate, frames per second reported by the JS-side
21
+ * `requestAnimationFrame` ticker (see `startJsFpsTracker`). `0` if
22
+ * the tracker has not been started or no hint has been received yet.
23
+ */
24
+ jsFps: number;
13
25
  /** Wall-clock timestamp (ms since unix epoch) when the sample was taken. */
14
26
  timestamp: number;
15
27
  }
@@ -51,4 +63,15 @@ export interface ReactNativePerfStats
51
63
  * Shares the same delta baseline as the overlay sampler.
52
64
  */
53
65
  sample(): Promise<PerfSample>;
66
+
67
+ /**
68
+ * Push the latest JS-thread frame count from the JS-side
69
+ * `requestAnimationFrame` ticker. Native stores the value and
70
+ * surfaces it as `PerfSample.jsFps` on the next sample. Cheap;
71
+ * intended to be called once per second.
72
+ *
73
+ * Prefer the `startJsFpsTracker` helper exported from JS rather
74
+ * than calling this directly.
75
+ */
76
+ setJsFpsHint(fps: number): void;
54
77
  }
package/src/index.tsx CHANGED
@@ -1,8 +1,92 @@
1
1
  import { NitroModules } from 'react-native-nitro-modules';
2
2
  import type { ReactNativePerfStats as ReactNativePerfStatsType } from './ReactNativePerfStats.nitro';
3
3
 
4
- const ReactNativePerfStatsHybridObject =
4
+ const nativeImpl =
5
5
  NitroModules.createHybridObject<ReactNativePerfStatsType>('ReactNativePerfStats');
6
6
 
7
- export const ReactNativePerfStats = ReactNativePerfStatsHybridObject;
8
7
  export type * from './ReactNativePerfStats.nitro';
8
+
9
+ // ---- JS FPS tracker ----------------------------------------------------
10
+ //
11
+ // Counts frames the JS event loop services per second using
12
+ // `requestAnimationFrame` and pushes the value to native via
13
+ // `setJsFpsHint`. Native then surfaces it as `PerfSample.jsFps`.
14
+ //
15
+ // Why rAF and not a setTimeout-based ticker: rAF is scheduled by RN's
16
+ // UIManager from the platform vsync (Choreographer / CADisplayLink)
17
+ // and dispatched as a JS task. When the JS thread is blocked, rAF
18
+ // callbacks are coalesced and the per-second count drops, exactly
19
+ // reflecting "how many frame opportunities did JS service" — which is
20
+ // what users mean by "JS FPS". A setTimeout(16) loop measures timer
21
+ // drift, which is correlated but coarser.
22
+ //
23
+ // Cost: one int increment + one rAF call per frame (~60–120/s) plus one
24
+ // `setJsFpsHint` call per second. Negligible vs. typical app workload.
25
+
26
+ let jsFpsRafId: number | null = null;
27
+ let jsFpsIntervalId: ReturnType<typeof setInterval> | null = null;
28
+ let jsFpsFrameCount = 0;
29
+ let jsFpsCurrentInterval: number | null = null;
30
+
31
+ /**
32
+ * Start the JS-side FPS ticker. Normally invoked automatically by
33
+ * `ReactNativePerfStats.start` and stopped by `.stop`; exported as
34
+ * an escape hatch for advanced flows (e.g. measuring JS FPS without
35
+ * the native sampler running).
36
+ *
37
+ * Calling with a new `reportIntervalMs` while already running
38
+ * restarts the loop with the new interval; calling with the same
39
+ * interval is a no-op.
40
+ */
41
+ export function startJsFpsTracker(reportIntervalMs: number = 1000): void {
42
+ if (jsFpsCurrentInterval === reportIntervalMs) return;
43
+ stopJsFpsTracker();
44
+ jsFpsCurrentInterval = reportIntervalMs;
45
+
46
+ const tick = () => {
47
+ jsFpsFrameCount += 1;
48
+ jsFpsRafId = requestAnimationFrame(tick);
49
+ };
50
+ jsFpsRafId = requestAnimationFrame(tick);
51
+
52
+ jsFpsIntervalId = setInterval(() => {
53
+ const fps = (jsFpsFrameCount * 1000) / reportIntervalMs;
54
+ jsFpsFrameCount = 0;
55
+ nativeImpl.setJsFpsHint(fps);
56
+ }, reportIntervalMs);
57
+ }
58
+
59
+ /** Stop the JS-side FPS ticker. Idempotent. */
60
+ export function stopJsFpsTracker(): void {
61
+ if (jsFpsRafId != null) {
62
+ cancelAnimationFrame(jsFpsRafId);
63
+ jsFpsRafId = null;
64
+ }
65
+ if (jsFpsIntervalId != null) {
66
+ clearInterval(jsFpsIntervalId);
67
+ jsFpsIntervalId = null;
68
+ }
69
+ jsFpsFrameCount = 0;
70
+ jsFpsCurrentInterval = null;
71
+ }
72
+
73
+ // `start` / `stop` wrap the native sampler so the JS-side rAF tracker
74
+ // shares its lifetime: callers never have to remember to enable it
75
+ // separately, and `PerfSample.jsFps` is populated as soon as the
76
+ // sampler is running. Other methods pass straight through to the
77
+ // HybridObject.
78
+
79
+ export const ReactNativePerfStats = {
80
+ start(intervalMs: number): void {
81
+ nativeImpl.start(intervalMs);
82
+ startJsFpsTracker(intervalMs);
83
+ },
84
+ stop(): void {
85
+ stopJsFpsTracker();
86
+ nativeImpl.stop();
87
+ },
88
+ showOverlay: (): void => nativeImpl.showOverlay(),
89
+ hideOverlay: (): void => nativeImpl.hideOverlay(),
90
+ sample: () => nativeImpl.sample(),
91
+ setJsFpsHint: (fps: number): void => nativeImpl.setJsFpsHint(fps),
92
+ };