| 1 | import Foundation |
| 2 | #if os(iOS) && canImport(UIKit) |
| 3 | import UIKit |
| 4 | |
| 5 | // 🤖 Frame-hitch capture (iOS): the layer between "smooth" and a watchdog |
| 6 | // hang. The first day of field data showed hangs people FEEL that never |
| 7 | // trip the 250ms watchdog — scroll-storm frame drops in the 100–250ms band |
| 8 | // (the same class HitchLogger was built to chase). A display link ticks |
| 9 | // once per rendered frame; a gap ≥120ms is a properly dropped bunch of |
| 10 | // frames and records as a "hitch" ledger event. Started/stopped with |
| 11 | // recording (Perf.setRecording), so Release builds without consent never |
| 12 | // run the link. Mac has no CADisplayLink — the watchdog covers it there |
| 13 | // (deliberately iOS-only). |
| 14 | final class HitchRecorder { |
| 15 | private var link: CADisplayLink? |
| 16 | private var last: CFTimeInterval = 0 |
| 17 | private var observers: [NSObjectProtocol] = [] |
| 18 | |
| 19 | func start() { |
| 20 | guard link == nil else { return } |
| 21 | let l = CADisplayLink(target: self, selector: #selector(tick)) |
| 22 | l.add(to: .main, forMode: .common) |
| 23 | link = l |
| 24 | // The link pauses in the background; the first tick after resume |
| 25 | // spans the whole suspension and would read as a monster hitch. |
| 26 | // Reset the baseline on every foreground transition instead. |
| 27 | let nc = NotificationCenter.default |
| 28 | observers.append(nc.addObserver(forName: UIApplication.willEnterForegroundNotification, |
| 29 | object: nil, queue: .main) { [weak self] _ in self?.last = 0 }) |
| 30 | } |
| 31 | |
| 32 | func stop() { |
| 33 | link?.invalidate() |
| 34 | link = nil |
| 35 | last = 0 |
| 36 | observers.forEach { NotificationCenter.default.removeObserver($0) } |
| 37 | observers.removeAll() |
| 38 | } |
| 39 | |
| 40 | @objc private func tick(_ l: CADisplayLink) { |
| 41 | defer { last = l.timestamp } |
| 42 | guard last > 0 else { return } |
| 43 | let gapMs = (l.timestamp - last) * 1000 |
| 44 | // >5s can only be a suspension the notification didn't cover |
| 45 | // (scene detach, system UI) — never a real render gap. |
| 46 | guard gapMs >= 120, gapMs < 5000 else { return } |
| 47 | Perf.recordHitch(ms: gapMs) |
| 48 | } |
| 49 | } |
| 50 | #else |
| 51 | final class HitchRecorder { |
| 52 | func start() {} |
| 53 | func stop() {} |
| 54 | } |
| 55 | #endif |
| 56 | |