@@ -11,7 +11,6 @@ import android.graphics.Bitmap
1111import android.os.Build
1212import android.os.Handler
1313import android.os.Looper
14- import android.os.Process
1514import android.util.Base64
1615import android.view.FrameMetrics
1716import android.view.PixelCopy
@@ -22,91 +21,104 @@ import kotlin.coroutines.resume
2221import kotlin.coroutines.suspendCoroutine
2322import kotlinx.coroutines.CoroutineScope
2423import kotlinx.coroutines.Dispatchers
25- import kotlinx.coroutines.launch
24+ import kotlinx.coroutines.SupervisorJob
25+ import kotlinx.coroutines.cancel
26+ import kotlinx.coroutines.withContext
2627
2728@DoNotStripAny
2829internal class FrameTimingsObserver (
2930 private val window : Window ,
3031 private val screenshotsEnabled : Boolean ,
3132 private val onFrameTimingSequence : (sequence: FrameTimingSequence ) -> Unit ,
3233) {
34+ // Bounds the lifetime of async frame timing and screenshot work. Cancelled in stop() to prevent
35+ // emitting any further frames once tracing is torn down.
36+ private var tracingScope: CoroutineScope ? = null
37+
3338 private val handler = Handler (Looper .getMainLooper())
3439 private var frameCounter: Int = 0
3540 private var bitmapBuffer: Bitmap ? = null
3641
3742 private val frameMetricsListener =
38- Window .OnFrameMetricsAvailableListener { _, frameMetrics, _dropCount ->
43+ Window .OnFrameMetricsAvailableListener { _, frameMetrics, _ ->
3944 val beginTimestamp = frameMetrics.getMetric(FrameMetrics .VSYNC_TIMESTAMP )
4045 val endTimestamp = beginTimestamp + frameMetrics.getMetric(FrameMetrics .TOTAL_DURATION )
4146 emitFrameTiming(beginTimestamp, endTimestamp)
4247 }
4348
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()
49+ private suspend fun captureScreenshot (): String? =
50+ withContext(Dispatchers .Main ) {
51+ if (Build .VERSION .SDK_INT < Build .VERSION_CODES .O ) {
52+ return @withContext null
53+ }
54+
55+ val decorView = window.decorView
56+ val width = decorView.width
57+ val height = decorView.height
58+
59+ // Reuse bitmap if dimensions haven't changed
60+ val bitmap =
61+ bitmapBuffer?.let {
62+ if (it.width == width && it.height == height) {
63+ it
64+ } else {
65+ it.recycle()
66+ null
9467 }
9568 }
96- } else {
97- continuation.resume(null )
69+ ? : Bitmap .createBitmap(width, height, Bitmap .Config .ARGB_8888 ).also {
70+ bitmapBuffer = it
71+ }
72+
73+ // Suspend for PixelCopy callback
74+ val copySuccess = suspendCoroutine { continuation ->
75+ PixelCopy .request(
76+ window,
77+ bitmap,
78+ { copyResult -> continuation.resume(copyResult == PixelCopy .SUCCESS ) },
79+ handler,
80+ )
81+ }
82+
83+ if (! copySuccess) {
84+ return @withContext null
85+ }
86+
87+ // Switch to background thread for CPU-intensive scaling/encoding work
88+ withContext(Dispatchers .Default ) {
89+ var scaledBitmap: Bitmap ? = null
90+ try {
91+ val scaleFactor = 0.25f
92+ val scaledWidth = (width * scaleFactor).toInt()
93+ val scaledHeight = (height * scaleFactor).toInt()
94+ scaledBitmap = Bitmap .createScaledBitmap(bitmap, scaledWidth, scaledHeight, true )
95+
96+ val compressFormat =
97+ if (Build .VERSION .SDK_INT >= Build .VERSION_CODES .R ) Bitmap .CompressFormat .WEBP_LOSSY
98+ else Bitmap .CompressFormat .WEBP
99+
100+ ByteArrayOutputStream ().use { outputStream ->
101+ scaledBitmap.compress(compressFormat, 0 , outputStream)
102+ Base64 .encodeToString(outputStream.toByteArray(), Base64 .NO_WRAP )
103+ }
104+ } catch (e: Exception ) {
105+ null
106+ } finally {
107+ scaledBitmap?.recycle()
98108 }
99- },
100- handler,
101- )
102- }
109+ }
110+ }
103111
104112 fun start () {
105- frameCounter = 0
106113 if (Build .VERSION .SDK_INT < Build .VERSION_CODES .N ) {
107114 return
108115 }
109116
117+ frameCounter = 0
118+
119+ // Use SupervisorJob so a failed capture on one frame doesn't cancel others
120+ tracingScope = CoroutineScope (SupervisorJob () + Dispatchers .Default )
121+
110122 // Capture initial screenshot to ensure there's always at least one frame
111123 // recorded at the start of tracing, even if no UI changes occur
112124 val timestamp = System .nanoTime()
@@ -116,10 +128,13 @@ internal class FrameTimingsObserver(
116128 }
117129
118130 private fun emitFrameTiming (beginTimestamp : Long , endTimestamp : Long ) {
131+ // Guard against calls arriving after stop() has cancelled the scope
132+ val scope = tracingScope ? : return
133+
119134 val frameId = frameCounter++
120135 val threadId = Process .myTid()
121136
122- CoroutineScope ( Dispatchers . Default ) .launch {
137+ scope .launch {
123138 val screenshot = if (screenshotsEnabled) captureScreenshot() else null
124139
125140 onFrameTimingSequence(
@@ -142,6 +157,10 @@ internal class FrameTimingsObserver(
142157 window.removeOnFrameMetricsAvailableListener(frameMetricsListener)
143158 handler.removeCallbacksAndMessages(null )
144159
160+ // Cancel any in-flight screenshot captures before releasing the bitmap buffer
161+ tracingScope?.cancel()
162+ tracingScope = null
163+
145164 bitmapBuffer?.recycle()
146165 bitmapBuffer = null
147166 }
0 commit comments