@keepsnappy/react-native 0.0.0-stage → 0.1.0-alpha.12
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 +260 -2
- package/android/build.gradle +35 -0
- package/android/src/main/java/expo/modules/perflab/PerfLabModule.kt +445 -0
- package/android/src/main/java/expo/modules/perflab/PerfLabSampler.kt +515 -0
- package/app.plugin.js +105 -0
- package/expo-module.config.json +9 -0
- package/ios/PerfLab.podspec +29 -0
- package/ios/PerfLabHermes.h +18 -0
- package/ios/PerfLabHermes.mm +64 -0
- package/ios/PerfLabModule.swift +371 -0
- package/ios/PerfLabSampler.swift +364 -0
- package/js/babel-transformer.js +71 -0
- package/js/hook.polyfill.js +333 -0
- package/js/renderer-switch.js +16 -0
- package/js/sdk/activation.ts +375 -0
- package/js/sdk/active.ts +62 -0
- package/js/sdk/artifacts.ts +632 -0
- package/js/sdk/boot.ts +21 -0
- package/js/sdk/causes.ts +932 -0
- package/js/sdk/core.ts +2403 -0
- package/js/sdk/cpuprof.ts +133 -0
- package/js/sdk/dormant.ts +77 -0
- package/js/sdk/env.d.ts +2 -0
- package/js/sdk/fiber.ts +591 -0
- package/js/sdk/interactions.ts +531 -0
- package/js/sdk/links.ts +201 -0
- package/js/sdk/nav.ts +383 -0
- package/js/sdk/net-install.ts +49 -0
- package/js/sdk/net.ts +684 -0
- package/js/sdk/profile-export.ts +257 -0
- package/js/sdk/profile.ts +539 -0
- package/js/sdk/push-install.ts +158 -0
- package/js/sdk/push.ts +819 -0
- package/js/sdk/retain-floor.ts +11 -0
- package/js/sdk/sampler.ts +236 -0
- package/js/sdk/screens.ts +586 -0
- package/js/sdk/stream.ts +125 -0
- package/js/zustand-vanilla.js +31 -0
- package/metro.js +237 -0
- package/package.json +26 -4
|
@@ -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_KEEPSNAPPY 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 (KEEPSNAPPY_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'])];
|
|
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) === '1';
|
|
27
|
+
|
|
28
|
+
const profilingRenderer = (value = (process.env.KEEPSNAPPY_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 withKeepSnappy = 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 = withKeepSnappy;
|
|
103
|
+
module.exports.editPodfile = editPodfile;
|
|
104
|
+
module.exports.editSettingsGradle = editSettingsGradle;
|
|
105
|
+
module.exports.profilingRenderer = profilingRenderer;
|
|
@@ -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
|