PerfDebugView.swift (7261B)
1 import SwiftUI 2 import UIKit 3 4 struct 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 139 private 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 148 private 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 172 private 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 }