@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.
- package/README.md +25 -16
- package/android/src/main/java/com/margelo/nitro/reactnativeperfstats/ReactNativePerfStats.kt +170 -3
- package/ios/ReactNativePerfStats.swift +247 -31
- package/lib/module/index.js +83 -2
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/ReactNativePerfStats.nitro.d.ts +22 -0
- package/lib/typescript/src/ReactNativePerfStats.nitro.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +21 -2
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/nitrogen/generated/android/c++/JHybridReactNativePerfStatsSpec.cpp +4 -0
- package/nitrogen/generated/android/c++/JHybridReactNativePerfStatsSpec.hpp +1 -0
- package/nitrogen/generated/android/c++/JPerfSample.hpp +9 -1
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativeperfstats/HybridReactNativePerfStatsSpec.kt +4 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativeperfstats/PerfSample.kt +8 -2
- package/nitrogen/generated/ios/c++/HybridReactNativePerfStatsSpecSwift.hpp +6 -0
- package/nitrogen/generated/ios/swift/HybridReactNativePerfStatsSpec.swift +1 -0
- package/nitrogen/generated/ios/swift/HybridReactNativePerfStatsSpec_cxx.swift +11 -0
- package/nitrogen/generated/ios/swift/PerfSample.swift +24 -2
- package/nitrogen/generated/shared/c++/HybridReactNativePerfStatsSpec.cpp +1 -0
- package/nitrogen/generated/shared/c++/HybridReactNativePerfStatsSpec.hpp +1 -0
- package/nitrogen/generated/shared/c++/PerfSample.hpp +9 -1
- package/package.json +1 -1
- package/src/ReactNativePerfStats.nitro.ts +23 -0
- 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
|
|
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);
|
|
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
|
|
39
|
-
| ----------- |
|
|
40
|
-
| `cpu` | percent of one core
|
|
41
|
-
| `rss` | bytes
|
|
42
|
-
| `
|
|
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` —
|
|
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
|
|
package/android/src/main/java/com/margelo/nitro/reactnativeperfstats/ReactNativePerfStats.kt
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
240
|
-
//
|
|
241
|
-
//
|
|
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
|
|
312
|
-
OneKeyLog.warn(kTag, "No
|
|
511
|
+
guard let scene = currentForegroundScene() else {
|
|
512
|
+
OneKeyLog.warn(kTag, "No foreground UIWindowScene; overlay deferred")
|
|
313
513
|
return
|
|
314
514
|
}
|
|
315
515
|
|
|
316
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
|
382
|
-
if
|
|
383
|
-
return
|
|
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
|
|
605
|
+
return fallback
|
|
390
606
|
}
|
|
391
607
|
}
|
package/lib/module/index.js
CHANGED
|
@@ -1,6 +1,87 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
import { NitroModules } from 'react-native-nitro-modules';
|
|
4
|
-
const
|
|
5
|
-
|
|
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
|
package/lib/module/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"names":["NitroModules","
|
|
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;
|
|
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":"
|
|
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
|
|
@@ -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
|
|
package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativeperfstats/PerfSample.kt
CHANGED
|
@@ -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
|
@@ -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
|
|
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
|
+
};
|