ListlessiOS/Helpers/PerfSampler.swift (6679B)
1import Foundation
2import UIKit
3
4
5
6
7
8
9@MainActor
10final 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
42
43
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
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}