PerfSampler.swift (6679B)
1 import Foundation 2 import UIKit 3 4 /// Lightweight timing collector for diagnosing cold-launch hitches without 5 /// needing Instruments. Samples live in memory during a launch and are flushed 6 /// to disk when the app backgrounds, so the next launch can display prior data 7 /// from the in-app debug screen. Call indexes restart at 0 per launch so the 8 /// first-invocation cost of each label is easy to spot. 9 @MainActor 10 final class PerfSampler { 11 static let shared = PerfSampler() 12 13 struct Sample: Codable, Identifiable { 14 var id = UUID() 15 let launchID: UUID 16 let label: String 17 let callIndex: Int 18 let durationMs: Double 19 let msSinceLaunch: Double 20 let timestamp: Date 21 } 22 23 struct Launch: Codable, Identifiable { 24 var id: UUID { launchID } 25 let launchID: UUID 26 let startedAt: Date 27 } 28 29 private let storageURL: URL 30 private let maxTotalSamples = 1000 31 private let maxPerLabelPerLaunch = 40 32 33 private(set) var currentLaunch: Launch 34 private var launches: [Launch] = [] 35 private var samples: [Sample] = [] 36 private var callCounts: [String: Int] = [:] 37 private let launchClockStart: DispatchTime 38 private var dirty = false 39 private var pendingKeyboardWillShow: DispatchTime? 40 41 /// Anchors the launch clock as early as possible. Call once from 42 /// `ListlessiOSApp.init()`; otherwise the clock starts whenever the 43 /// singleton is first accessed (usually the first `makeUIView`). 44 static func markLaunchStart() { 45 _ = shared 46 } 47 48 private init() { 49 let dir = try? FileManager.default.url( 50 for: .applicationSupportDirectory, 51 in: .userDomainMask, 52 appropriateFor: nil, 53 create: true 54 ) 55 storageURL = (dir ?? URL(fileURLWithPath: NSTemporaryDirectory())) 56 .appendingPathComponent("PerfSamples.json") 57 58 let launch = Launch(launchID: UUID(), startedAt: Date()) 59 currentLaunch = launch 60 launchClockStart = DispatchTime.now() 61 load() 62 launches.append(launch) 63 dirty = true 64 65 NotificationCenter.default.addObserver( 66 forName: UIApplication.didEnterBackgroundNotification, 67 object: nil, 68 queue: .main 69 ) { _ in 70 Task { @MainActor in PerfSampler.shared.flush() } 71 } 72 NotificationCenter.default.addObserver( 73 forName: UIApplication.willTerminateNotification, 74 object: nil, 75 queue: .main 76 ) { _ in 77 Task { @MainActor in PerfSampler.shared.flush() } 78 } 79 NotificationCenter.default.addObserver( 80 forName: UIResponder.keyboardWillShowNotification, 81 object: nil, 82 queue: .main 83 ) { note in 84 let animationMs = (note.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] 85 as? Double).map { $0 * 1000 } ?? 0 86 Task { @MainActor in PerfSampler.shared.keyboardWillShow(animationMs: animationMs) } 87 } 88 NotificationCenter.default.addObserver( 89 forName: UIResponder.keyboardDidShowNotification, 90 object: nil, 91 queue: .main 92 ) { _ in 93 Task { @MainActor in PerfSampler.shared.keyboardDidShow() } 94 } 95 NotificationCenter.default.addObserver( 96 forName: UIApplication.didBecomeActiveNotification, 97 object: nil, 98 queue: .main 99 ) { _ in 100 Task { @MainActor in 101 PerfSampler.shared.record(label: "App.didBecomeActive", durationMs: 0) 102 } 103 } 104 } 105 106 private func keyboardWillShow(animationMs: Double) { 107 pendingKeyboardWillShow = DispatchTime.now() 108 record(label: "Keyboard.willShow", durationMs: 0) 109 record(label: "Keyboard.animationDurationReported", durationMs: animationMs) 110 } 111 112 private func keyboardDidShow() { 113 let durationMs: Double 114 if let start = pendingKeyboardWillShow { 115 let ns = DispatchTime.now().uptimeNanoseconds &- start.uptimeNanoseconds 116 durationMs = Double(ns) / 1_000_000 117 } else { 118 durationMs = 0 119 } 120 pendingKeyboardWillShow = nil 121 record(label: "Keyboard.didShow", durationMs: durationMs) 122 } 123 124 @discardableResult 125 func measure<T>(_ label: String, _ work: () -> T) -> T { 126 let start = DispatchTime.now() 127 let result = work() 128 let elapsedNs = DispatchTime.now().uptimeNanoseconds &- start.uptimeNanoseconds 129 record(label: label, durationMs: Double(elapsedNs) / 1_000_000) 130 return result 131 } 132 133 func record(label: String, durationMs: Double) { 134 let index = callCounts[label, default: 0] 135 callCounts[label] = index + 1 136 guard index < maxPerLabelPerLaunch else { return } 137 138 let sinceLaunchNs = DispatchTime.now().uptimeNanoseconds 139 &- launchClockStart.uptimeNanoseconds 140 let sample = Sample( 141 launchID: currentLaunch.launchID, 142 label: label, 143 callIndex: index, 144 durationMs: durationMs, 145 msSinceLaunch: Double(sinceLaunchNs) / 1_000_000, 146 timestamp: Date() 147 ) 148 samples.append(sample) 149 if samples.count > maxTotalSamples { 150 samples.removeFirst(samples.count - maxTotalSamples) 151 } 152 dirty = true 153 } 154 155 func allSamples() -> [Sample] { samples } 156 func allLaunches() -> [Launch] { launches } 157 158 func samplesForCurrentLaunch() -> [Sample] { 159 samples.filter { $0.launchID == currentLaunch.launchID } 160 } 161 162 func clear() { 163 samples.removeAll() 164 launches = [currentLaunch] 165 callCounts.removeAll() 166 dirty = true 167 flush() 168 } 169 170 func flush() { 171 guard dirty else { return } 172 let payload = StoredPayload(launches: launches, samples: samples) 173 do { 174 let data = try JSONEncoder().encode(payload) 175 try data.write(to: storageURL, options: .atomic) 176 dirty = false 177 } catch { 178 // Best-effort: swallow errors; debug data is non-critical. 179 } 180 } 181 182 private func load() { 183 guard let data = try? Data(contentsOf: storageURL), 184 let payload = try? JSONDecoder().decode(StoredPayload.self, from: data) 185 else { return } 186 launches = payload.launches.suffix(20) 187 let keepIDs = Set(launches.map(\.launchID)) 188 samples = payload.samples.filter { keepIDs.contains($0.launchID) } 189 } 190 191 private struct StoredPayload: Codable { 192 let launches: [Launch] 193 let samples: [Sample] 194 } 195 }