Skip to content

Commit ee173c8

Browse files
authored
Implement recomposition time profiling (#151)
1 parent 050a693 commit ee173c8

25 files changed

Lines changed: 654 additions & 52 deletions

File tree

compose-stability-analyzer-idea/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ kotlin {
2626
}
2727

2828
group = "com.github.skydoves"
29-
version = "0.7.3"
29+
version = "0.7.4-SNAPSHOT"
3030

3131
repositories {
3232
mavenLocal()

compose-stability-analyzer-idea/src/main/kotlin/com/skydoves/compose/stability/idea/heatmap/AdbLogcatService.kt

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,9 @@ internal class AdbLogcatService(
190190
)
191191

192192
val maxRecent = settings.heatmapMaxRecentEvents
193+
val paramChanges = buildParameterChangeLines(event)
194+
val stateChanges = event.stateEntries.map { "[state] $it" }
195+
193196
dataMap.compute(event.composableName) { _, existing ->
194197
if (existing == null) {
195198
ComposableHeatmapData(
@@ -202,27 +205,63 @@ internal class AdbLogcatService(
202205
.filter { it.status == ParameterStatus.CHANGED }
203206
.associate { it.name to 1 },
204207
unstableParameters = event.unstableParameters.toSet(),
208+
lastDurationMs = event.durationMs,
209+
totalDurationMs = event.durationMs,
210+
lastParameterChanges = paramChanges,
211+
lastStateChanges = stateChanges,
205212
)
206213
} else {
207-
val mergedChanged = existing.changedParameters.toMutableMap()
214+
val mergedChanged =
215+
existing.changedParameters.toMutableMap()
208216
event.parameterEntries
209217
.filter { it.status == ParameterStatus.CHANGED }
210-
.forEach { mergedChanged[it.name] = (mergedChanged[it.name] ?: 0) + 1 }
218+
.forEach {
219+
mergedChanged[it.name] =
220+
(mergedChanged[it.name] ?: 0) + 1
221+
}
211222

212-
val recentCapped = (existing.recentEvents + event).takeLast(maxRecent)
223+
val recentCapped =
224+
(existing.recentEvents + event).takeLast(maxRecent)
213225

214226
existing.copy(
215-
totalRecompositionCount = existing.totalRecompositionCount + 1,
216-
maxSingleCount = maxOf(existing.maxSingleCount, event.recompositionCount),
227+
totalRecompositionCount =
228+
existing.totalRecompositionCount + 1,
229+
maxSingleCount = maxOf(
230+
existing.maxSingleCount,
231+
event.recompositionCount,
232+
),
217233
recentEvents = recentCapped,
218234
lastSeenTimestampMs = event.timestampMs,
219235
changedParameters = mergedChanged,
220-
unstableParameters = existing.unstableParameters + event.unstableParameters,
236+
unstableParameters =
237+
existing.unstableParameters + event.unstableParameters,
238+
lastDurationMs = event.durationMs,
239+
totalDurationMs =
240+
existing.totalDurationMs + event.durationMs,
241+
lastParameterChanges = paramChanges,
242+
lastStateChanges = stateChanges,
221243
)
222244
}
223245
}
224246
}
225247

248+
/**
249+
* Format parameter entries into readable lines for tooltip
250+
* display (e.g. `[param] user: User changed (User@abc)`).
251+
*/
252+
private fun buildParameterChangeLines(
253+
event: ParsedRecompositionEvent,
254+
): List<String> {
255+
return event.parameterEntries.map { entry ->
256+
val detail = if (entry.detail.isNotEmpty()) {
257+
" (${entry.detail})"
258+
} else {
259+
""
260+
}
261+
"[param] ${entry.name}: ${entry.type} ${entry.status.name.lowercase()}$detail"
262+
}
263+
}
264+
226265
// ── ADB helpers ────────────────────────────────────────────────────────
227266

228267
private fun resolveAdbPath(): String? {

compose-stability-analyzer-idea/src/main/kotlin/com/skydoves/compose/stability/idea/heatmap/HeatmapInlayManager.kt

Lines changed: 123 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
*/
1616
package com.skydoves.compose.stability.idea.heatmap
1717

18+
import com.intellij.ide.IdeTooltip
19+
import com.intellij.ide.IdeTooltipManager
1820
import com.intellij.openapi.Disposable
1921
import com.intellij.openapi.application.ApplicationManager
2022
import com.intellij.openapi.application.ModalityState
@@ -78,7 +80,7 @@ internal class HeatmapInlayManager(
7880
private var refreshTask: ScheduledFuture<*>? = null
7981

8082
@Volatile
81-
private var clickListenerRegistered = false
83+
private var listenersRegistered = false
8284

8385
/**
8486
* Mouse listener that detects clicks on heatmap block inlays
@@ -105,11 +107,59 @@ internal class HeatmapInlayManager(
105107
}
106108
}
107109

110+
/** Tracks the inlay currently showing a tooltip. */
111+
private var activeTooltipInlay: Inlay<*>? = null
112+
113+
/**
114+
* Mouse motion listener that shows a tooltip when the cursor
115+
* hovers over a heatmap block inlay. Uses [IdeTooltipManager]
116+
* which handles positioning automatically.
117+
*/
118+
private val hoverListener =
119+
object : com.intellij.openapi.editor.event.EditorMouseMotionListener {
120+
override fun mouseMoved(event: EditorMouseEvent) {
121+
val editor = event.editor
122+
if (editor.project != project) return
123+
val key = System.identityHashCode(editor)
124+
val entries = editorState[key] ?: return
125+
val pt = event.mouseEvent.point
126+
127+
for ((name, entry) in entries) {
128+
if (!entry.inlay.isValid) continue
129+
val b = entry.inlay.bounds ?: continue
130+
if (!b.contains(pt)) continue
131+
if (activeTooltipInlay == entry.inlay) return
132+
activeTooltipInlay = entry.inlay
133+
val renderer =
134+
entry.inlay.renderer as? HeatmapBlockRenderer
135+
?: return
136+
val html = renderer.tooltipHtml
137+
if (html.isEmpty()) return
138+
val label = javax.swing.JLabel(html).apply {
139+
border = JBUI.Borders.empty(4, 8)
140+
}
141+
val tip = IdeTooltip(
142+
editor.contentComponent,
143+
pt,
144+
label,
145+
)
146+
IdeTooltipManager.getInstance().show(tip, true)
147+
return
148+
}
149+
activeTooltipInlay = null
150+
}
151+
}
152+
108153
fun start() {
109-
if (!clickListenerRegistered) {
110-
clickListenerRegistered = true
111-
EditorFactory.getInstance().eventMulticaster
112-
.addEditorMouseListener(clickListener, this)
154+
if (!listenersRegistered) {
155+
listenersRegistered = true
156+
val multicaster =
157+
EditorFactory.getInstance().eventMulticaster
158+
multicaster.addEditorMouseListener(clickListener, this)
159+
multicaster.addEditorMouseMotionListener(
160+
hoverListener,
161+
this,
162+
)
113163
}
114164

115165
refreshTask = com.intellij.util.concurrency.AppExecutorUtil
@@ -137,7 +187,11 @@ internal class HeatmapInlayManager(
137187
refreshTask?.cancel(false)
138188
refreshTask = null
139189
ApplicationManager.getApplication().invokeLater(
140-
{ clearAllInlays() },
190+
{
191+
if (!project.isDisposed) {
192+
clearAllInlays()
193+
}
194+
},
141195
ModalityState.any(),
142196
)
143197
}
@@ -212,7 +266,9 @@ internal class HeatmapInlayManager(
212266

213267
val data = service.getHeatmapData(name) ?: continue
214268
val color = severityColor(data.totalRecompositionCount)
215-
val renderer = HeatmapBlockRenderer(newText, color, editor)
269+
val tooltip = buildTooltipHtml(data)
270+
val renderer =
271+
HeatmapBlockRenderer(newText, color, editor, tooltip)
216272
val inlay = editor.inlayModel.addBlockElement(
217273
offset,
218274
true, // relatesToPrecedingText
@@ -275,6 +331,7 @@ internal class HeatmapInlayManager(
275331
private val text: String,
276332
private val color: Color,
277333
editor: Editor,
334+
val tooltipHtml: String = "",
278335
) : EditorCustomElementRenderer {
279336

280337
// Pre-compute the background color once at creation time
@@ -320,6 +377,65 @@ internal class HeatmapInlayManager(
320377
}
321378
}
322379

380+
/**
381+
* Builds an HTML tooltip string summarising the most recent
382+
* recomposition event and cumulative totals.
383+
*/
384+
private fun buildTooltipHtml(
385+
data: ComposableHeatmapData,
386+
): String {
387+
return buildString {
388+
append("<html><body style='font-size:11px'>")
389+
append("<b>Last Recomposition (#")
390+
append(data.totalRecompositionCount)
391+
append(")</b>")
392+
if (data.lastDurationMs > 0) {
393+
append(" &mdash; ")
394+
append("%.2f".format(data.lastDurationMs))
395+
append("ms")
396+
}
397+
append("<br>")
398+
399+
if (data.lastParameterChanges.isNotEmpty()) {
400+
data.lastParameterChanges.forEach { line ->
401+
append(escapeHtml(line))
402+
append("<br>")
403+
}
404+
}
405+
406+
if (data.lastStateChanges.isNotEmpty()) {
407+
data.lastStateChanges.forEach { line ->
408+
append(escapeHtml(line))
409+
append("<br>")
410+
}
411+
}
412+
413+
if (data.unstableParameters.isNotEmpty()) {
414+
append("Unstable: ")
415+
append(escapeHtml(data.unstableParameters.toString()))
416+
append("<br>")
417+
}
418+
419+
append("<br><i>Total: ")
420+
append(data.totalRecompositionCount)
421+
append(" recomposition")
422+
if (data.totalRecompositionCount != 1) append("s")
423+
if (data.totalDurationMs > 0) {
424+
append(", ")
425+
append("%.1f".format(data.totalDurationMs))
426+
append("ms cumulative")
427+
}
428+
append("</i></body></html>")
429+
}
430+
}
431+
432+
private fun escapeHtml(text: String): String {
433+
return text
434+
.replace("&", "&amp;")
435+
.replace("<", "&lt;")
436+
.replace(">", "&gt;")
437+
}
438+
323439
/**
324440
* Opens the Heatmap tab in the tool window and displays
325441
* recomposition data for the given composable.

compose-stability-analyzer-idea/src/main/kotlin/com/skydoves/compose/stability/idea/heatmap/LogcatParser.kt

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,30 +24,58 @@ package com.skydoves.compose.stability.idea.heatmap
2424
*
2525
* Expected format:
2626
* ```
27-
* [Recomposition #3] UserProfile (tag: user-screen)
28-
* ├─ user: User changed (User@abc123 → User@def456)
29-
* ├─ count: Int stable (42)
27+
* [Recomposition #3] UserProfile (tag: user-screen) (2.30ms)
28+
* ├─ [param] user: User changed (User@abc123 → User@def456)
29+
* ├─ [param] count: Int stable (42)
30+
* ├─ [state] counter: Int changed (5 → 6)
3031
* └─ Unstable parameters: [user]
3132
* ```
33+
*
34+
* Also supports the legacy format without `[param]`/`[state]` prefixes
35+
* and without duration.
3236
*/
3337
internal class LogcatParser(
3438
private val onEvent: (ParsedRecompositionEvent) -> Unit,
3539
) {
3640

3741
private companion object {
42+
/** Header: `[Recomposition #N] Name (tag: t) (2.30ms)` */
3843
val HEADER_REGEX =
39-
"""\[Recomposition #(\d+)] (\S+)(?:\s+\(tag:\s+(.+?)\))?""".toRegex()
44+
"""\[Recomposition #(\d+)] (\S+)(?:\s+\(tag:\s+(.+?)\))?(?:\s+\((\d+\.?\d*)ms\))?"""
45+
.toRegex()
46+
47+
/**
48+
* Parameter line with optional `[param]` prefix:
49+
* `├─ [param] user: User changed (User@abc → User@def)`
50+
* `├─ user: User changed (User@abc → User@def)`
51+
*/
4052
val PARAM_REGEX =
41-
"""\s*[├└]─\s+(\w+):\s+(.+?)\s+(changed|stable|unstable)(?:\s+\((.+)\))?""".toRegex()
53+
("""\s*[├└]─\s+(?:\[param]\s+)?""" +
54+
"""(\w+):\s+(.+?)\s+(changed|stable|unstable)""" +
55+
"""(?:\s+\((.+)\))?""").toRegex()
56+
57+
/**
58+
* State change line:
59+
* `├─ [state] counter: Int changed (5 → 6)`
60+
*/
61+
val STATE_REGEX =
62+
"""\s*[├└]─\s+\[state]\s+(.+)"""
63+
.toRegex()
64+
4265
val UNSTABLE_SUMMARY_REGEX =
4366
"""\s*[├└]─\s+Unstable parameters:\s+\[(.+)]""".toRegex()
67+
68+
val STATE_SUMMARY_REGEX =
69+
"""\s*[├└]─\s+State changes:\s+\[(.+)]""".toRegex()
4470
}
4571

4672
private var currentName: String? = null
4773
private var currentTag: String = ""
4874
private var currentCount: Int = 0
75+
private var currentDurationMs: Double = 0.0
4976
private var currentParams: MutableList<ParsedParameterEntry> = mutableListOf()
5077
private var currentUnstable: MutableList<String> = mutableListOf()
78+
private var currentStateEntries: MutableList<String> = mutableListOf()
5179

5280
/**
5381
* Feed a single logcat message line (after stripping the `D/Recomposition: ` prefix).
@@ -56,11 +84,15 @@ internal class LogcatParser(
5684
val headerMatch = HEADER_REGEX.find(line)
5785
if (headerMatch != null) {
5886
emitCurrent()
59-
currentCount = headerMatch.groupValues[1].toIntOrNull() ?: 0
87+
currentCount =
88+
headerMatch.groupValues[1].toIntOrNull() ?: 0
6089
currentName = headerMatch.groupValues[2]
6190
currentTag = headerMatch.groupValues[3]
91+
currentDurationMs =
92+
headerMatch.groupValues[4].toDoubleOrNull() ?: 0.0
6293
currentParams = mutableListOf()
6394
currentUnstable = mutableListOf()
95+
currentStateEntries = mutableListOf()
6496
return
6597
}
6698

@@ -76,6 +108,16 @@ internal class LogcatParser(
76108
return
77109
}
78110

111+
// Skip state summary lines (not individual state entries)
112+
if (STATE_SUMMARY_REGEX.find(line) != null) return
113+
114+
// [state] lines: capture the raw detail text
115+
val stateMatch = STATE_REGEX.find(line)
116+
if (stateMatch != null) {
117+
currentStateEntries.add(stateMatch.groupValues[1])
118+
return
119+
}
120+
79121
val paramMatch = PARAM_REGEX.find(line)
80122
if (paramMatch != null) {
81123
val status = when (paramMatch.groupValues[3]) {
@@ -111,6 +153,8 @@ internal class LogcatParser(
111153
parameterEntries = currentParams.toList(),
112154
unstableParameters = currentUnstable.toList(),
113155
timestampMs = System.currentTimeMillis(),
156+
durationMs = currentDurationMs,
157+
stateEntries = currentStateEntries.toList(),
114158
),
115159
)
116160
currentName = null

compose-stability-analyzer-idea/src/main/kotlin/com/skydoves/compose/stability/idea/heatmap/RecompositionHeatmapData.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ internal data class ParsedRecompositionEvent(
2525
val parameterEntries: List<ParsedParameterEntry>,
2626
val unstableParameters: List<String>,
2727
val timestampMs: Long,
28+
val durationMs: Double = 0.0,
29+
val stateEntries: List<String> = emptyList(),
2830
)
2931

3032
/**
@@ -57,6 +59,10 @@ internal data class ComposableHeatmapData(
5759
val lastSeenTimestampMs: Long,
5860
val changedParameters: Map<String, Int>,
5961
val unstableParameters: Set<String>,
62+
val lastDurationMs: Double = 0.0,
63+
val totalDurationMs: Double = 0.0,
64+
val lastParameterChanges: List<String> = emptyList(),
65+
val lastStateChanges: List<String> = emptyList(),
6066
)
6167

6268
/**

0 commit comments

Comments
 (0)