@@ -22,91 +22,105 @@ import kotlin.coroutines.resume
2222import kotlin.coroutines.suspendCoroutine
2323import kotlinx.coroutines.CoroutineScope
2424import kotlinx.coroutines.Dispatchers
25+ import kotlinx.coroutines.SupervisorJob
26+ import kotlinx.coroutines.cancel
2527import kotlinx.coroutines.launch
28+ import kotlinx.coroutines.withContext
2629
2730@DoNotStripAny
2831internal class FrameTimingsObserver (
2932 private val window : Window ,
3033 private val screenshotsEnabled : Boolean ,
3134 private val onFrameTimingSequence : (sequence: FrameTimingSequence ) -> Unit ,
3235) {
36+ // Bounds the lifetime of async frame timing and screenshot work. Cancelled in stop() to prevent
37+ // emitting any further frames once tracing is torn down.
38+ private var tracingScope: CoroutineScope ? = null
39+
3340 private val handler = Handler (Looper .getMainLooper())
3441 private var frameCounter: Int = 0
3542 private var bitmapBuffer: Bitmap ? = null
3643
3744 private val frameMetricsListener =
38- Window .OnFrameMetricsAvailableListener { _, frameMetrics, _dropCount ->
45+ Window .OnFrameMetricsAvailableListener { _, frameMetrics, _ ->
3946 val beginTimestamp = frameMetrics.getMetric(FrameMetrics .VSYNC_TIMESTAMP )
4047 val endTimestamp = beginTimestamp + frameMetrics.getMetric(FrameMetrics .TOTAL_DURATION )
4148 emitFrameTiming(beginTimestamp, endTimestamp)
4249 }
4350
44- private suspend fun captureScreenshot (): String? = suspendCoroutine { continuation ->
45- if (Build .VERSION .SDK_INT < Build .VERSION_CODES .O ) {
46- continuation.resume(null )
47- return @suspendCoroutine
48- }
49-
50- val decorView = window.decorView
51- val width = decorView.width
52- val height = decorView.height
53-
54- // Reuse bitmap if dimensions haven't changed
55- val bitmap =
56- bitmapBuffer?.let {
57- if (it.width == width && it.height == height) {
58- it
59- } else {
60- it.recycle()
61- null
62- }
63- } ? : Bitmap .createBitmap(width, height, Bitmap .Config .ARGB_8888 ).also { bitmapBuffer = it }
64-
65- PixelCopy .request(
66- window,
67- bitmap,
68- { copyResult ->
69- if (copyResult == PixelCopy .SUCCESS ) {
70- CoroutineScope (Dispatchers .Default ).launch {
71- var scaledBitmap: Bitmap ? = null
72- try {
73- val scaleFactor = 0.25f
74- val scaledWidth = (width * scaleFactor).toInt()
75- val scaledHeight = (height * scaleFactor).toInt()
76- scaledBitmap = Bitmap .createScaledBitmap(bitmap, scaledWidth, scaledHeight, true )
77-
78- val compressFormat =
79- if (Build .VERSION .SDK_INT >= Build .VERSION_CODES .R )
80- Bitmap .CompressFormat .WEBP_LOSSY
81- else Bitmap .CompressFormat .WEBP
82-
83- val base64 =
84- ByteArrayOutputStream ().use { outputStream ->
85- scaledBitmap.compress(compressFormat, 0 , outputStream)
86- Base64 .encodeToString(outputStream.toByteArray(), Base64 .NO_WRAP )
87- }
88-
89- continuation.resume(base64)
90- } catch (e: Exception ) {
91- continuation.resume(null )
92- } finally {
93- scaledBitmap?.recycle()
51+ private suspend fun captureScreenshot (): String? =
52+ withContext(Dispatchers .Main ) {
53+ if (Build .VERSION .SDK_INT < Build .VERSION_CODES .O ) {
54+ return @withContext null
55+ }
56+
57+ val decorView = window.decorView
58+ val width = decorView.width
59+ val height = decorView.height
60+
61+ // Reuse bitmap if dimensions haven't changed
62+ val bitmap =
63+ bitmapBuffer?.let {
64+ if (it.width == width && it.height == height) {
65+ it
66+ } else {
67+ it.recycle()
68+ null
9469 }
9570 }
96- } else {
97- continuation.resume(null )
71+ ? : Bitmap .createBitmap(width, height, Bitmap .Config .ARGB_8888 ).also {
72+ bitmapBuffer = it
73+ }
74+
75+ // Suspend for PixelCopy callback
76+ val copySuccess = suspendCoroutine { continuation ->
77+ PixelCopy .request(
78+ window,
79+ bitmap,
80+ { copyResult -> continuation.resume(copyResult == PixelCopy .SUCCESS ) },
81+ handler,
82+ )
83+ }
84+
85+ if (! copySuccess) {
86+ return @withContext null
87+ }
88+
89+ // Switch to background thread for CPU-intensive scaling/encoding work
90+ withContext(Dispatchers .Default ) {
91+ var scaledBitmap: Bitmap ? = null
92+ try {
93+ val scaleFactor = 0.25f
94+ val scaledWidth = (width * scaleFactor).toInt()
95+ val scaledHeight = (height * scaleFactor).toInt()
96+ scaledBitmap = Bitmap .createScaledBitmap(bitmap, scaledWidth, scaledHeight, true )
97+
98+ val compressFormat =
99+ if (Build .VERSION .SDK_INT >= Build .VERSION_CODES .R ) Bitmap .CompressFormat .WEBP_LOSSY
100+ else Bitmap .CompressFormat .WEBP
101+
102+ ByteArrayOutputStream ().use { outputStream ->
103+ scaledBitmap.compress(compressFormat, 0 , outputStream)
104+ Base64 .encodeToString(outputStream.toByteArray(), Base64 .NO_WRAP )
105+ }
106+ } catch (e: Exception ) {
107+ null
108+ } finally {
109+ scaledBitmap?.recycle()
98110 }
99- },
100- handler,
101- )
102- }
111+ }
112+ }
103113
104114 fun start () {
105- frameCounter = 0
106115 if (Build .VERSION .SDK_INT < Build .VERSION_CODES .N ) {
107116 return
108117 }
109118
119+ frameCounter = 0
120+
121+ // Use SupervisorJob so a failed capture on one frame doesn't cancel others
122+ tracingScope = CoroutineScope (SupervisorJob () + Dispatchers .Default )
123+
110124 // Capture initial screenshot to ensure there's always at least one frame
111125 // recorded at the start of tracing, even if no UI changes occur
112126 val timestamp = System .nanoTime()
@@ -116,10 +130,13 @@ internal class FrameTimingsObserver(
116130 }
117131
118132 private fun emitFrameTiming (beginTimestamp : Long , endTimestamp : Long ) {
133+ // Guard against calls arriving after stop() has cancelled the scope
134+ val scope = tracingScope ? : return
135+
119136 val frameId = frameCounter++
120137 val threadId = Process .myTid()
121138
122- CoroutineScope ( Dispatchers . Default ) .launch {
139+ scope .launch {
123140 val screenshot = if (screenshotsEnabled) captureScreenshot() else null
124141
125142 onFrameTimingSequence(
@@ -142,6 +159,10 @@ internal class FrameTimingsObserver(
142159 window.removeOnFrameMetricsAvailableListener(frameMetricsListener)
143160 handler.removeCallbacksAndMessages(null )
144161
162+ // Cancel any in-flight screenshot captures before releasing the bitmap buffer
163+ tracingScope?.cancel()
164+ tracingScope = null
165+
145166 bitmapBuffer?.recycle()
146167 bitmapBuffer = null
147168 }
0 commit comments