listless

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

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 }