listless

A simple list app for Apple platforms
Log | Files | Refs | README | LICENSE

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 }