@keepsnappy/react-native 0.0.0-stage → 0.1.0-alpha.11

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.
@@ -0,0 +1,515 @@
1
+ package expo.modules.perflab
2
+
3
+ import android.app.Activity
4
+ import android.app.Application
5
+ import android.content.Context
6
+ import android.hardware.display.DisplayManager
7
+ import android.os.Build
8
+ import android.os.Bundle
9
+ import android.os.Handler
10
+ import android.os.HandlerThread
11
+ import android.os.Looper
12
+ import android.os.PowerManager
13
+ import android.os.Process
14
+ import android.system.Os
15
+ import android.system.OsConstants
16
+ import android.view.Choreographer
17
+ import android.view.Display
18
+ import android.view.FrameMetrics
19
+ import android.view.ViewTreeObserver
20
+ import android.view.Window
21
+ import org.json.JSONObject
22
+ import java.io.File
23
+ import java.util.concurrent.Executors
24
+ import java.util.concurrent.ScheduledExecutorService
25
+ import java.util.concurrent.TimeUnit
26
+
27
+ // System.nanoTime() is CLOCK_MONOTONIC, the clock of performance.now() in React Native.
28
+ internal fun perfLabMonotonicMs(): Double = System.nanoTime() / 1_000_000.0
29
+
30
+ // PowerManager.THERMAL_STATUS_* (0 none .. 6 shutdown) mapped to the iOS ProcessInfo.thermalState
31
+ // scale (0 nominal .. 3 critical) that derive and the heat rule read (`thermal >= 2` = serious):
32
+ // none and light -> 0, moderate -> 1, severe -> 2, critical, emergency, shutdown -> 3.
33
+ private const val THERMAL_ANDROID_MODERATE = 2
34
+ private const val THERMAL_ANDROID_SEVERE = 3
35
+ private const val THERMAL_ANDROID_CRITICAL = 4
36
+
37
+ internal fun perfLabThermalLevel(raw: Int): Int = when {
38
+ raw >= THERMAL_ANDROID_CRITICAL -> 3
39
+ raw >= THERMAL_ANDROID_SEVERE -> 2
40
+ raw >= THERMAL_ANDROID_MODERATE -> 1
41
+ else -> 0
42
+ }
43
+
44
+ // Fallback until the SDK configures the sampler; same value as SAMPLER_MS_DEFAULT in js/sdk/sampler.ts.
45
+ private const val SAMPLER_MS_DEFAULT = 4000.0
46
+
47
+ // Longest take() waits for the sampler thread: a sample costs ~2 ms, so this is some twenty queued samples. A reconfigure
48
+ // that shuts the executor down discards the queued task; the wait then ends here with null instead of hanging the JS thread.
49
+ private const val TAKE_TIMEOUT_MS = 50L
50
+
51
+ private val JS_THREAD_NAMES = listOf("mqt_v_js", "mqt_js")
52
+
53
+ // Counts presented and missed frames on the main thread's Choreographer. The budget follows the
54
+ // display's current refresh rate (read off the frame path), so 90/120 Hz screens are judged right.
55
+ // The callback allocates nothing: it reuses itself and touches only primitive fields.
56
+ internal class PerfLabFrameCounter : Choreographer.FrameCallback {
57
+ private val lock = Any()
58
+ private val main = Handler(Looper.getMainLooper())
59
+ @Volatile private var running = false
60
+ @Volatile var budgetNs: Long = 16_666_667L
61
+ private var lastFrameNs = 0L
62
+ private var presentedTotal = 0L
63
+ private var missedTotal = 0L
64
+ private var longestGapNs = 0L
65
+
66
+ private val startOnMain = Runnable {
67
+ lastFrameNs = 0L
68
+ Choreographer.getInstance().postFrameCallback(this)
69
+ }
70
+
71
+ private val stopOnMain = Runnable {
72
+ Choreographer.getInstance().removeFrameCallback(this)
73
+ }
74
+
75
+ fun start() {
76
+ if (running) return
77
+ running = true
78
+ main.post(startOnMain)
79
+ }
80
+
81
+ fun stop() {
82
+ running = false
83
+ main.post(stopOnMain)
84
+ }
85
+
86
+ override fun doFrame(frameTimeNanos: Long) {
87
+ if (!running) return
88
+ if (lastFrameNs > 0L) {
89
+ val gap = frameTimeNanos - lastFrameNs
90
+ val budget = budgetNs
91
+ val missed = if (budget > 0L) maxOf(0L, Math.round(gap.toDouble() / budget) - 1L) else 0L
92
+ synchronized(lock) {
93
+ presentedTotal++
94
+ missedTotal += missed
95
+ if (gap > longestGapNs) longestGapNs = gap
96
+ }
97
+ }
98
+ lastFrameNs = frameTimeNanos
99
+ Choreographer.getInstance().postFrameCallback(this)
100
+ }
101
+
102
+ // Totals are cumulative; the longest gap restarts with every read.
103
+ fun read(): LongArray = synchronized(lock) {
104
+ val out = longArrayOf(presentedTotal, missedTotal, longestGapNs)
105
+ longestGapNs = 0L
106
+ out
107
+ }
108
+ }
109
+
110
+ // The frame budget when the display rate is unknown: 60 Hz.
111
+ private const val BUDGET_NS_FALLBACK = 16_666_667L
112
+
113
+ internal const val FRAMES_SOURCE = "framemetrics"
114
+ internal const val JANK_BASIS_DEADLINE = "deadline"
115
+ internal const val JANK_BASIS_DISPLAY_RATE = "display-rate"
116
+
117
+ // Counts the frames the app's foreground activity window draws (Window.OnFrameMetricsAvailableListener, API 24+) and those that
118
+ // overran their deadline (TOTAL_DURATION > DEADLINE, "overrun > 0" like JankStats; gfxinfo's JankTracker also skips
119
+ // buffer-stuffing frames, so its count can be lower). Unlike the Choreographer counter it sees only frames that were drawn,
120
+ // so idle time on an adaptive-refresh panel is no longer a miss. API 31+: the frame's own FrameMetrics.DEADLINE. Below 31:
121
+ // the current display frame interval (`budgetNs`, from Display.getRefreshRate()); `jankBasis` says which.
122
+ // The first draw after a window attach is neither jank nor the longest frame (it pays for the attach). Frames the framework
123
+ // dropped before reporting them (dropCountSinceLastInvocation) are counted in `droppedTotal`, not as drawn.
124
+ // Each frame is counted when it ends (INTENDED_VSYNC_TIMESTAMP + TOTAL_DURATION, the clock of System.nanoTime) at the first
125
+ // read taken after that moment, so a frame that finished before a sample is in that sample's interval and not the next one.
126
+ // A report that reaches the listener after the sample it ended before (a few ms of delivery delay) lands in the next
127
+ // interval. Not seen: other windows (RN Modal dialogs, popups) and SurfaceView/TextureView content (video, Skia canvases);
128
+ // `windowCovered` says another window held focus during the interval, so the counts of that interval are unknown.
129
+ // Follows the foreground activity through lifecycle callbacks; the listener runs on its own thread, never the UI thread.
130
+ internal class PerfLabFrameMetrics(private val context: () -> Context?) :
131
+ Window.OnFrameMetricsAvailableListener, Application.ActivityLifecycleCallbacks, ViewTreeObserver.OnWindowFocusChangeListener {
132
+ private class Frame(val endNs: Long, val jank: Boolean, val firstDraw: Boolean, val durationNs: Long, val dropped: Int)
133
+
134
+ private val lock = Any()
135
+ private val main = Handler(Looper.getMainLooper())
136
+ @Volatile var budgetNs: Long = BUDGET_NS_FALLBACK
137
+ private var thread: HandlerThread? = null
138
+ private var handler: Handler? = null
139
+ private var application: Application? = null
140
+ private var attached: Window? = null
141
+ private val pending = ArrayList<Frame>()
142
+ private var drawnTotal = 0L
143
+ private var jankTotal = 0L
144
+ private var droppedTotal = 0L
145
+ private var covered = false
146
+ private var coveredInInterval = false
147
+
148
+ val jankBasis: String
149
+ get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) JANK_BASIS_DEADLINE else JANK_BASIS_DISPLAY_RATE
150
+
151
+ fun start(current: Activity?) {
152
+ if (thread != null) return
153
+ val app = context()?.applicationContext as? Application ?: return
154
+ val t = HandlerThread("perflab-framemetrics").also { it.start() }
155
+ thread = t
156
+ handler = Handler(t.looper)
157
+ application = app
158
+ main.post {
159
+ app.registerActivityLifecycleCallbacks(this)
160
+ current?.let { attach(it) }
161
+ }
162
+ }
163
+
164
+ fun stop() {
165
+ val app = application ?: return
166
+ val t = thread
167
+ application = null
168
+ thread = null
169
+ handler = null
170
+ main.post {
171
+ app.unregisterActivityLifecycleCallbacks(this)
172
+ detach()
173
+ t?.quitSafely()
174
+ }
175
+ synchronized(lock) { pending.clear() }
176
+ }
177
+
178
+ // Main thread only.
179
+ private fun attach(activity: Activity) {
180
+ val h = handler ?: return
181
+ if (activity.isFinishing || activity.isDestroyed) return
182
+ val window = activity.window ?: return
183
+ if (window === attached) return
184
+ detach()
185
+ try {
186
+ window.addOnFrameMetricsAvailableListener(this, h)
187
+ window.decorView.viewTreeObserver.addOnWindowFocusChangeListener(this)
188
+ attached = window
189
+ synchronized(lock) { covered = false }
190
+ } catch (e: IllegalStateException) {
191
+ // Window not hardware accelerated: no frame metrics from it.
192
+ }
193
+ }
194
+
195
+ // Main thread only.
196
+ private fun detach() {
197
+ attached?.let {
198
+ try {
199
+ it.removeOnFrameMetricsAvailableListener(this)
200
+ it.decorView.viewTreeObserver.removeOnWindowFocusChangeListener(this)
201
+ } catch (e: IllegalArgumentException) {
202
+ // Already gone with its window.
203
+ }
204
+ }
205
+ attached = null
206
+ }
207
+
208
+ override fun onWindowFocusChanged(hasFocus: Boolean) {
209
+ synchronized(lock) {
210
+ covered = !hasFocus
211
+ if (!hasFocus) coveredInInterval = true
212
+ }
213
+ }
214
+
215
+ override fun onActivityResumed(activity: Activity) = attach(activity)
216
+
217
+ override fun onActivityPaused(activity: Activity) {
218
+ if (attached === activity.window) detach()
219
+ }
220
+
221
+ override fun onActivityDestroyed(activity: Activity) {
222
+ if (attached === activity.window) detach()
223
+ }
224
+
225
+ override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
226
+ override fun onActivityStarted(activity: Activity) {}
227
+ override fun onActivityStopped(activity: Activity) {}
228
+ override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
229
+
230
+ override fun onFrameMetricsAvailable(window: Window, frameMetrics: FrameMetrics, dropCountSinceLastInvocation: Int) {
231
+ val total = frameMetrics.getMetric(FrameMetrics.TOTAL_DURATION)
232
+ val limit = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) frameMetrics.getMetric(FrameMetrics.DEADLINE) else budgetNs
233
+ val firstDraw = frameMetrics.getMetric(FrameMetrics.FIRST_DRAW_FRAME) == 1L
234
+ val end = frameMetrics.getMetric(FrameMetrics.INTENDED_VSYNC_TIMESTAMP) + total
235
+ synchronized(lock) {
236
+ pending.add(Frame(end, !firstDraw && limit > 0L && total > limit, firstDraw, total, dropCountSinceLastInvocation))
237
+ }
238
+ }
239
+
240
+ // Counts every frame that ended at or before untilNs. Totals are cumulative; the longest frame (-1: none was drawn) and
241
+ // the covered flag restart with every read.
242
+ fun read(untilNs: Long): LongArray = synchronized(lock) {
243
+ var longest = -1L
244
+ val it = pending.iterator()
245
+ while (it.hasNext()) {
246
+ val f = it.next()
247
+ if (f.endNs > untilNs) continue
248
+ it.remove()
249
+ drawnTotal++
250
+ droppedTotal += f.dropped
251
+ if (f.jank) jankTotal++
252
+ if (!f.firstDraw && f.durationNs > longest) longest = f.durationNs
253
+ }
254
+ val out = longArrayOf(drawnTotal, jankTotal, longest, droppedTotal, if (coveredInInterval) 1L else 0L)
255
+ coveredInInterval = covered
256
+ out
257
+ }
258
+ }
259
+
260
+ // Off unless configured. `auto` samples on its own thread every intervalMs and needs no JS;
261
+ // `manual` only keeps the frame counter running and samples when asked. Every sample is logged
262
+ // as `PERF_LAB sample {json}` and kept in a short ring for windows.
263
+ // UNTESTED ON DEVICE: written against the iOS implementation, compiled only.
264
+ internal class PerfLabSampler(
265
+ private val context: () -> Context?,
266
+ private val activity: () -> Activity?,
267
+ private val log: (String) -> Unit,
268
+ ) {
269
+ private val lock = Any()
270
+ private val frames = PerfLabFrameCounter()
271
+ private val frameMetrics = PerfLabFrameMetrics(context)
272
+ private var executor: ScheduledExecutorService? = null
273
+ private var mode = "off"
274
+ private var intervalMs = SAMPLER_MS_DEFAULT
275
+ private var seq = 0
276
+ private var previous: DoubleArray? = null
277
+ private val ring = ArrayDeque<Map<String, Any?>>()
278
+ private val ringSize = 64
279
+ // Records of samplerRequest, kept outside the ring until taken, so a long window still finds its open sample.
280
+ private val pinned = ArrayDeque<Pair<String, Map<String, Any?>>>()
281
+ private val pinnedMax = 32
282
+ private var jsTid = -1
283
+ private var jsThreadName: String? = null
284
+ private var scans = 0
285
+ private var maxHz = 0
286
+ private val tickMs = 1000.0 / Os.sysconf(OsConstants._SC_CLK_TCK).coerceAtLeast(1L)
287
+
288
+ fun configure(newMode: String, newIntervalMs: Double): Map<String, Any?> = synchronized(lock) {
289
+ mode = if (newMode == "auto" || newMode == "manual") newMode else "off"
290
+ intervalMs = maxOf(250.0, newIntervalMs)
291
+ executor?.shutdownNow()
292
+ executor = null
293
+ if (mode == "off") {
294
+ frames.stop()
295
+ frameMetrics.stop()
296
+ previous = null
297
+ ring.clear()
298
+ pinned.clear()
299
+ jsTid = -1
300
+ jsThreadName = null
301
+ } else {
302
+ updateBudget()
303
+ maxHz = maxRefreshHz()
304
+ frames.start()
305
+ frameMetrics.start(activity())
306
+ val ex = Executors.newSingleThreadScheduledExecutor { r -> Thread(r, "perflab-sampler") }
307
+ if (mode == "auto") {
308
+ val period = intervalMs.toLong()
309
+ ex.scheduleAtFixedRate({ synchronized(lock) { collect("auto") } }, period, period, TimeUnit.MILLISECONDS)
310
+ }
311
+ executor = ex
312
+ }
313
+ mapOf("mode" to mode, "intervalMs" to intervalMs, "nowMs" to perfLabMonotonicMs())
314
+ }
315
+
316
+ fun sample(reason: String): Map<String, Any?>? = synchronized(lock) {
317
+ if (mode == "off") null else collect(reason)
318
+ }
319
+
320
+ // Returns at once; the sample is taken on the sampler thread (record time is taken there) and lands in the ring.
321
+ fun request(reason: String) {
322
+ val ex = synchronized(lock) { executor } ?: return
323
+ try {
324
+ ex.execute {
325
+ synchronized(lock) {
326
+ if (mode != "off") {
327
+ pinned.addLast(reason to collect(reason))
328
+ if (pinned.size > pinnedMax) pinned.removeFirst()
329
+ }
330
+ }
331
+ }
332
+ } catch (e: java.util.concurrent.RejectedExecutionException) {
333
+ // Reconfigured meanwhile: the request is dropped.
334
+ }
335
+ }
336
+
337
+ // Waits behind the requests already queued on the sampler thread, so a just-made request is found.
338
+ fun take(reason: String): Map<String, Any?>? {
339
+ val ex = synchronized(lock) { executor } ?: return null
340
+ val task = try {
341
+ ex.submit<Map<String, Any?>?> {
342
+ synchronized(lock) {
343
+ val index = pinned.indexOfFirst { it.first == reason }
344
+ if (index < 0) null else pinned.removeAt(index).second
345
+ }
346
+ }
347
+ } catch (e: Exception) {
348
+ return null
349
+ }
350
+ return try {
351
+ task.get(TAKE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
352
+ } catch (e: Exception) {
353
+ task.cancel(false)
354
+ null
355
+ }
356
+ }
357
+
358
+ fun recent(since: Double): List<Map<String, Any?>> = synchronized(lock) {
359
+ ring.filter { ((it["t"] as? Double) ?: 0.0) >= since }
360
+ }
361
+
362
+ private fun updateBudget() {
363
+ val hz = display()?.refreshRate ?: 60f
364
+ if (hz > 1f) {
365
+ val ns = (1_000_000_000.0 / hz).toLong()
366
+ frames.budgetNs = ns
367
+ frameMetrics.budgetNs = ns
368
+ }
369
+ }
370
+
371
+ // Highest refresh rate the panel can run (iOS: maximumFramesPerSecond), rounded like a marketing
372
+ // figure: modes report 120.00001 for a 120 Hz panel.
373
+ private fun maxRefreshHz(): Int {
374
+ val modes = display()?.supportedModes ?: return 0
375
+ log("PERF_LAB display modes " + modes.joinToString(" ") { "${it.modeId}:${it.physicalWidth}x${it.physicalHeight}@${it.refreshRate}" })
376
+ return modes.maxOfOrNull { Math.round(it.refreshRate) } ?: 0
377
+ }
378
+
379
+ private fun display(): Display? {
380
+ val ctx = context() ?: return null
381
+ val manager = ctx.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager ?: return null
382
+ return manager.getDisplay(Display.DEFAULT_DISPLAY)
383
+ }
384
+
385
+ // utime + stime (fields 14 and 15) of a /proc stat line, after the parenthesised comm.
386
+ private fun statCpuMs(path: String): Double? = try {
387
+ val text = File(path).readText()
388
+ val fields = text.substring(text.lastIndexOf(')') + 2).split(' ')
389
+ (fields[11].toLong() + fields[12].toLong()) * tickMs
390
+ } catch (e: Exception) {
391
+ null
392
+ }
393
+
394
+ private fun findJsThread() {
395
+ val tasks = File("/proc/self/task").listFiles() ?: return
396
+ for (task in tasks) {
397
+ val name = try { File(task, "comm").readText().trim() } catch (e: Exception) { continue }
398
+ if (JS_THREAD_NAMES.any { name == it }) {
399
+ jsTid = task.name.toIntOrNull() ?: continue
400
+ jsThreadName = name
401
+ return
402
+ }
403
+ }
404
+ }
405
+
406
+ private fun jsCpuMs(): Double? {
407
+ if (jsTid > 0) {
408
+ statCpuMs("/proc/self/task/$jsTid/stat")?.let { return it }
409
+ jsTid = -1
410
+ jsThreadName = null
411
+ }
412
+ if (scans % 5 == 0) findJsThread()
413
+ scans++
414
+ return if (jsTid > 0) statCpuMs("/proc/self/task/$jsTid/stat") else null
415
+ }
416
+
417
+ // Closest analog of iOS phys_footprint: anonymous resident memory plus swapped (zram) pages.
418
+ private fun memoryMb(): Map<String, Any?> = try {
419
+ var rss = -1L
420
+ var anon = -1L
421
+ var swap = 0L
422
+ File("/proc/self/status").forEachLine { line ->
423
+ when {
424
+ line.startsWith("VmRSS:") -> rss = kb(line)
425
+ line.startsWith("RssAnon:") -> anon = kb(line)
426
+ line.startsWith("VmSwap:") -> swap = kb(line)
427
+ }
428
+ }
429
+ mapOf(
430
+ "footprintMb" to if (anon >= 0) (anon + swap) / 1024.0 else null,
431
+ "residentMb" to if (rss >= 0) rss / 1024.0 else null,
432
+ ).filterValues { it != null }
433
+ } catch (e: Exception) {
434
+ emptyMap()
435
+ }
436
+
437
+ private fun kb(line: String): Long = line.substringAfter(':').trim().substringBefore(' ').toLongOrNull() ?: -1L
438
+
439
+ private fun thermalRaw(): Int? {
440
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return null
441
+ val pm = context()?.getSystemService(Context.POWER_SERVICE) as? PowerManager ?: return null
442
+ return pm.currentThermalStatus
443
+ }
444
+
445
+ private fun collect(reason: String): Map<String, Any?> {
446
+ val started = perfLabMonotonicMs()
447
+ val t = started
448
+ val processMs = Process.getElapsedCpuTime().toDouble()
449
+ val mainMs = statCpuMs("/proc/self/task/${Process.myPid()}/stat")
450
+ val jsMs = jsCpuMs()
451
+ val memory = memoryMb()
452
+ updateBudget()
453
+ val frame = frames.read()
454
+ val drawn = frameMetrics.read(System.nanoTime())
455
+ seq++
456
+
457
+ val cpu = mutableMapOf<String, Any?>("processMs" to processMs)
458
+ mainMs?.let { cpu["mainMs"] = it }
459
+ jsMs?.let { cpu["jsMs"] = it }
460
+ jsThreadName?.let { cpu["jsThread"] = it }
461
+ val framesOut = mutableMapOf<String, Any?>(
462
+ "presentedTotal" to frame[0].toDouble(),
463
+ "missedTotal" to frame[1].toDouble(),
464
+ "longestGapMs" to frame[2] / 1_000_000.0,
465
+ "budgetMs" to frames.budgetNs / 1_000_000.0,
466
+ "drawnTotal" to drawn[0].toDouble(),
467
+ "jankTotal" to drawn[1].toDouble(),
468
+ "droppedTotal" to drawn[3].toDouble(),
469
+ "windowCovered" to (drawn[4] == 1L),
470
+ "source" to FRAMES_SOURCE,
471
+ "jankBasis" to frameMetrics.jankBasis,
472
+ )
473
+ if (drawn[2] >= 0L) framesOut["longestMs"] = drawn[2] / 1_000_000.0
474
+ maxHz.takeIf { it > 0 }?.let { framesOut["maxHz"] = it }
475
+ val record = mutableMapOf<String, Any?>(
476
+ "v" to 1,
477
+ "platform" to "android",
478
+ "seq" to seq,
479
+ "reason" to reason,
480
+ "t" to t,
481
+ "wallMs" to System.currentTimeMillis().toDouble(),
482
+ "intervalMs" to intervalMs,
483
+ "mode" to mode,
484
+ "mem" to memory,
485
+ )
486
+ thermalRaw()?.let {
487
+ record["thermal"] = perfLabThermalLevel(it)
488
+ record["thermalRaw"] = it
489
+ }
490
+ val prev = previous
491
+ if (prev != null && t > prev[0]) {
492
+ val dt = t - prev[0]
493
+ record["dtMs"] = dt
494
+ cpu["processCores"] = (processMs - prev[1]) / dt
495
+ if (mainMs != null && !prev[2].isNaN()) cpu["mainCores"] = (mainMs - prev[2]) / dt
496
+ if (jsMs != null && !prev[3].isNaN() && jsMs >= prev[3]) cpu["jsCores"] = (jsMs - prev[3]) / dt
497
+ framesOut["presented"] = frame[0] - prev[4]
498
+ framesOut["missed"] = frame[1] - prev[5]
499
+ framesOut["drawn"] = drawn[0] - prev[6]
500
+ framesOut["jank"] = drawn[1] - prev[7]
501
+ }
502
+ previous = doubleArrayOf(
503
+ t, processMs, mainMs ?: Double.NaN, jsMs ?: Double.NaN, frame[0].toDouble(), frame[1].toDouble(),
504
+ drawn[0].toDouble(), drawn[1].toDouble(),
505
+ )
506
+ record["cpu"] = cpu
507
+ record["frames"] = framesOut
508
+ record["collectMs"] = perfLabMonotonicMs() - started
509
+
510
+ ring.addLast(record)
511
+ while (ring.size > ringSize) ring.removeFirst()
512
+ log("PERF_LAB sample ${JSONObject(record as Map<*, *>)}")
513
+ return record
514
+ }
515
+ }
package/app.plugin.js ADDED
@@ -0,0 +1,105 @@
1
+ // Expo config plugin for the PerfLab SDK.
2
+ //
3
+ // The native module (iOS pod PerfLab, Android expo.modules.perflab) is linked
4
+ // by Expo autolinking from expo-module.config.json. With the build flag off
5
+ // (EXPO_PUBLIC_PERF_LAB is not "1" when `expo prebuild` runs) this plugin
6
+ // excludes the package from Expo autolinking, so a store build carries no SDK
7
+ // native code: no pod, no Android project, no ExpoModulesProvider entry. With
8
+ // the flag on it removes that exclusion again (prebuild without --clean edits
9
+ // the existing files) and records the flag in the app config, so the native
10
+ // fingerprints of flag-on and flag-off builds differ. It also records which
11
+ // React renderer the bundle carries (PERF_LAB_REACT_PROFILING, see metro.js):
12
+ // `always` (=1), `switch` (=switch) or `off`.
13
+ //
14
+ // expo-modules-autolinking reads exclusions only from the app's package.json and
15
+ // from command arguments, so the Podfile's `use_expo_modules!` call and
16
+ // settings.gradle's `expoAutolinking` extension are where the exclusion goes.
17
+
18
+ const { withPodfile, withSettingsGradle } = require('expo/config-plugins');
19
+
20
+ const PACKAGE_NAME = require('./package.json').name;
21
+ const PACKAGE_NAMES = [...new Set([PACKAGE_NAME, '@keepsnappy/react-native', '@perf-labs/react-native'])];
22
+ const namesFor = name => name === PACKAGE_NAME ? PACKAGE_NAMES : [name];
23
+ // Keep this generated-line marker stable so old exclusions can be removed.
24
+ const TAG = 'perflab: native module excluded, EXPO_PUBLIC_PERF_LAB is not 1';
25
+
26
+ const isEnabled = () => (process.env.EXPO_PUBLIC_KEEPSNAPPY ?? process.env.EXPO_PUBLIC_PERF_LAB) === '1';
27
+
28
+ const profilingRenderer = (value = (process.env.KEEPSNAPPY_REACT_PROFILING ?? process.env.PERF_LAB_REACT_PROFILING)) => {
29
+ if (value === '1') {
30
+ return 'always';
31
+ }
32
+ return value === 'switch' ? 'switch' : 'off';
33
+ };
34
+
35
+ const quoted = name => `'${name.replace(/'/g, "\\'")}'`;
36
+
37
+ // `use_expo_modules!` with no arguments, or the form this plugin writes.
38
+ const PODFILE_CALL = /^([ \t]*)use_expo_modules!(?:\(\{ exclude: \[[^\]]*\] \}\))?(?:[ \t]*#[^\n]*)?$/m;
39
+
40
+ const editPodfile = (contents, enabled, name = PACKAGE_NAME) => {
41
+ const match = contents.match(PODFILE_CALL);
42
+ if (!match) {
43
+ if (enabled || contents.includes(TAG)) {
44
+ return contents;
45
+ }
46
+ throw new Error(
47
+ `${name}: cannot exclude the native module, the Podfile has no plain \`use_expo_modules!\` call. ` +
48
+ `Add exclude: [${quoted(name)}] to it yourself, or set EXPO_PUBLIC_KEEPSNAPPY=1.`,
49
+ );
50
+ }
51
+ const indent = match[1];
52
+ const line = enabled
53
+ ? `${indent}use_expo_modules!`
54
+ : `${indent}use_expo_modules!({ exclude: [${namesFor(name).map(quoted).join(', ')}] }) # ${TAG}`;
55
+ return contents.replace(PODFILE_CALL, line);
56
+ };
57
+
58
+ const gradleLine = (language, name) =>
59
+ language === 'kt'
60
+ ? `expoAutolinking.exclude = (expoAutolinking.exclude ?: emptyList()) + listOf("${namesFor(name).join('", "')}") // ${TAG}`
61
+ : `expoAutolinking.exclude = (expoAutolinking.exclude ?: []) + [${namesFor(name).map(quoted).join(', ')}] // ${TAG}`;
62
+
63
+ const editSettingsGradle = (contents, enabled, language = 'groovy', name = PACKAGE_NAME) => {
64
+ const lines = contents.split('\n').filter(line => !line.includes(TAG));
65
+ if (enabled) {
66
+ return lines.join('\n');
67
+ }
68
+ const at = lines.findIndex(line => /^\s*expoAutolinking\.useExpoModules\(/.test(line));
69
+ if (at < 0) {
70
+ throw new Error(
71
+ `${name}: cannot exclude the native module, settings.gradle has no \`expoAutolinking.useExpoModules()\` call. ` +
72
+ 'Set expoAutolinking.exclude yourself, or set EXPO_PUBLIC_KEEPSNAPPY=1.',
73
+ );
74
+ }
75
+ lines.splice(at, 0, gradleLine(language, name));
76
+ return lines.join('\n');
77
+ };
78
+
79
+ const withPerfLab = config => {
80
+ const enabled = isEnabled();
81
+ if (enabled) {
82
+ config.extra = {
83
+ ...config.extra,
84
+ perflab: { native: true, profilingRenderer: profilingRenderer() },
85
+ };
86
+ }
87
+ config = withPodfile(config, mod => {
88
+ mod.modResults.contents = editPodfile(mod.modResults.contents, enabled);
89
+ return mod;
90
+ });
91
+ config = withSettingsGradle(config, mod => {
92
+ mod.modResults.contents = editSettingsGradle(
93
+ mod.modResults.contents,
94
+ enabled,
95
+ mod.modResults.language,
96
+ );
97
+ return mod;
98
+ });
99
+ return config;
100
+ };
101
+
102
+ module.exports = withPerfLab;
103
+ module.exports.editPodfile = editPodfile;
104
+ module.exports.editSettingsGradle = editSettingsGradle;
105
+ module.exports.profilingRenderer = profilingRenderer;
@@ -0,0 +1,9 @@
1
+ {
2
+ "platforms": ["ios", "android"],
3
+ "ios": {
4
+ "modules": ["PerfLabModule"]
5
+ },
6
+ "android": {
7
+ "modules": ["expo.modules.perflab.PerfLabModule"]
8
+ }
9
+ }
@@ -0,0 +1,29 @@
1
+ require 'json'
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = 'PerfLab'
7
+ s.version = package['version']
8
+ s.summary = package['description']
9
+ s.description = package['description']
10
+ s.license = package['license']
11
+ s.author = 'TapIn'
12
+ s.homepage = 'https://tapin.io'
13
+ s.platforms = { :ios => '16.4' }
14
+ s.swift_version = '5.9'
15
+ s.source = { git: '' }
16
+ s.static_framework = true
17
+
18
+ s.dependency 'ExpoModulesCore'
19
+ s.dependency 'hermes-engine'
20
+
21
+ s.source_files = "*.{swift,h,mm}"
22
+ s.public_header_files = "PerfLabHermes.h"
23
+
24
+ s.pod_target_xcconfig = {
25
+ 'DEFINES_MODULE' => 'YES',
26
+ 'CLANG_CXX_LANGUAGE_STANDARD' => 'c++20',
27
+ 'HEADER_SEARCH_PATHS' => '"$(PODS_ROOT)/hermes-engine/destroot/include" "$(PODS_ROOT)/Headers/Public/hermes-engine"',
28
+ }
29
+ end
@@ -0,0 +1,18 @@
1
+ #import <Foundation/Foundation.h>
2
+
3
+ NS_ASSUME_NONNULL_BEGIN
4
+
5
+ // Hermes sampling profiler (C++ API) for Swift. The runtime pointer is the
6
+ // jsi::Runtime behind Expo's JavaScriptRuntime.withUnsafePointee.
7
+ @interface PerfLabHermes : NSObject
8
+
9
+ + (BOOL)startWithHz:(double)hz;
10
+ + (BOOL)stop;
11
+ // Writes the runtime's samples as a DevTools .cpuprofile (Profiler.stop
12
+ // format). Falls back to the root API's trace-event dump when the runtime
13
+ // does not expose IHermes. Returns {path, bytes, format} or nil.
14
+ + (nullable NSDictionary<NSString *, id> *)dumpRuntime:(nullable void *)runtime toPath:(NSString *)path;
15
+
16
+ @end
17
+
18
+ NS_ASSUME_NONNULL_END