← All repositories

listless

A simple list app for Apple platforms

git clone https://code.inqk.net/listless.git


ListlessiOS/Helpers/PerfSampler.swift (6679B)

1import Foundation
2import 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
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    /// 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}