ListlessiOS/Views/PerfDebugView.swift (7261B)
1import SwiftUI
2import UIKit
3
4struct PerfDebugView: View {
5 @State private var refreshTick = 0
6
7 var body: some View {
8 List {
9 Section("Summary (current launch)") {
10 let stats = perLabelStats(for: PerfSampler.shared.samplesForCurrentLaunch())
11 if stats.isEmpty {
12 Text("No samples yet. Interact with the app and return here.")
13 .foregroundStyle(.secondary)
14 } else {
15 ForEach(stats, id: \.label) { stat in
16 StatRow(stat: stat)
17 }
18 }
19 }
20
21 ForEach(launchesMostRecentFirst()) { launch in
22 Section(sectionTitle(for: launch)) {
23 let launchSamples = samples(for: launch.launchID)
24 if launchSamples.isEmpty {
25 Text("No samples recorded.")
26 .foregroundStyle(.secondary)
27 } else {
28 ForEach(launchSamples) { sample in
29 SampleRow(sample: sample)
30 }
31 }
32 }
33 }
34 }
35 .id(refreshTick)
36 .navigationTitle("Perf Samples")
37 .navigationBarTitleDisplayMode(.inline)
38 .toolbar {
39 ToolbarItem(placement: .topBarTrailing) {
40 Menu {
41 Button("Refresh") { refreshTick &+= 1 }
42 Button("Copy Summary") { copySummary() }
43 Button("Copy All Samples") { copyAllSamples() }
44 Button("Clear All", role: .destructive) {
45 PerfSampler.shared.clear()
46 refreshTick &+= 1
47 }
48 } label: {
49 Image(systemName: "ellipsis.circle")
50 }
51 }
52 }
53 }
54
55 private func launchesMostRecentFirst() -> [PerfSampler.Launch] {
56 PerfSampler.shared.allLaunches().sorted { $0.startedAt > $1.startedAt }
57 }
58
59 private func samples(for launchID: UUID) -> [PerfSampler.Sample] {
60 PerfSampler.shared.allSamples()
61 .filter { $0.launchID == launchID }
62 .sorted { $0.msSinceLaunch < $1.msSinceLaunch }
63 }
64
65 private func sectionTitle(for launch: PerfSampler.Launch) -> String {
66 let formatter = DateFormatter()
67 formatter.dateFormat = "MMM d, HH:mm:ss"
68 let isCurrent = launch.launchID == PerfSampler.shared.currentLaunch.launchID
69 return formatter.string(from: launch.startedAt) + (isCurrent ? " (current)" : "")
70 }
71
72 private func perLabelStats(for samples: [PerfSampler.Sample]) -> [LabelStat] {
73 let grouped = Dictionary(grouping: samples, by: \.label)
74 return grouped.map { label, entries in
75 let sorted = entries.sorted(by: { $0.callIndex < $1.callIndex })
76 let durations = sorted.map(\.durationMs)
77 return LabelStat(
78 label: label,
79 count: sorted.count,
80 firstCallMs: durations.first ?? 0,
81 medianMs: median(durations),
82 maxMs: durations.max() ?? 0,
83 firstCallMsSinceLaunch: sorted.first?.msSinceLaunch ?? 0
84 )
85 }
86 .sorted { $0.firstCallMs > $1.firstCallMs }
87 }
88
89 private func median(_ values: [Double]) -> Double {
90 guard !values.isEmpty else { return 0 }
91 let sorted = values.sorted()
92 let mid = sorted.count / 2
93 if sorted.count % 2 == 0 {
94 return (sorted[mid - 1] + sorted[mid]) / 2
95 }
96 return sorted[mid]
97 }
98
99 private func copySummary() {
100 var lines: [String] = []
101 for launch in launchesMostRecentFirst() {
102 lines.append("=== Launch \(launch.startedAt) ===")
103 let stats = perLabelStats(for: samples(for: launch.launchID))
104 for stat in stats {
105 lines.append(String(
106 format: "%@ n=%d first=%.2fms median=%.2fms max=%.2fms firstAt=%.0fms",
107 stat.label,
108 stat.count,
109 stat.firstCallMs,
110 stat.medianMs,
111 stat.maxMs,
112 stat.firstCallMsSinceLaunch
113 ))
114 }
115 lines.append("")
116 }
117 UIPasteboard.general.string = lines.joined(separator: "\n")
118 }
119
120 private func copyAllSamples() {
121 var lines = ["launchStart,msSinceLaunch,callIndex,durationMs,label"]
122 for launch in launchesMostRecentFirst() {
123 let iso = ISO8601DateFormatter().string(from: launch.startedAt)
124 for sample in samples(for: launch.launchID) {
125 lines.append(String(
126 format: "%@,%.3f,%d,%.3f,%@",
127 iso,
128 sample.msSinceLaunch,
129 sample.callIndex,
130 sample.durationMs,
131 sample.label
132 ))
133 }
134 }
135 UIPasteboard.general.string = lines.joined(separator: "\n")
136 }
137}
138
139private struct LabelStat {
140 let label: String
141 let count: Int
142 let firstCallMs: Double
143 let medianMs: Double
144 let maxMs: Double
145 let firstCallMsSinceLaunch: Double
146}
147
148private struct StatRow: View {
149 let stat: LabelStat
150
151 var body: some View {
152 VStack(alignment: .leading, spacing: 2) {
153 Text(stat.label)
154 .font(.callout.monospaced())
155 HStack(spacing: 12) {
156 Text("n=\(stat.count)")
157 Text(String(format: "1st=%.1fms", stat.firstCallMs))
158 .foregroundStyle(stat.firstCallMs > 16 ? .red : .primary)
159 Text(String(format: "med=%.1f", stat.medianMs))
160 Text(String(format: "max=%.1f", stat.maxMs))
161 }
162 .font(.caption.monospaced())
163 .foregroundStyle(.secondary)
164 Text(String(format: "first call at %.0f ms since launch", stat.firstCallMsSinceLaunch))
165 .font(.caption2.monospaced())
166 .foregroundStyle(.tertiary)
167 }
168 .padding(.vertical, 2)
169 }
170}
171
172private struct SampleRow: View {
173 let sample: PerfSampler.Sample
174
175 var body: some View {
176 HStack(spacing: 8) {
177 Text(String(format: "%5.0fms", sample.msSinceLaunch))
178 .font(.caption.monospaced())
179 .foregroundStyle(.secondary)
180 Text("#\(sample.callIndex)")
181 .font(.caption.monospaced())
182 .foregroundStyle(.tertiary)
183 .frame(width: 32, alignment: .leading)
184 Text(shortLabel(sample.label))
185 .font(.caption.monospaced())
186 .lineLimit(1)
187 .truncationMode(.middle)
188 Spacer(minLength: 4)
189 Text(String(format: "%.2f ms", sample.durationMs))
190 .font(.caption.monospaced())
191 .foregroundStyle(sample.durationMs > 16 ? .red : .primary)
192 }
193 }
194
195 private func shortLabel(_ label: String) -> String {
196 if let range = label.range(of: ".") {
197 return String(label[range.upperBound...])
198 }
199 return label
200 }
201}