ListlessiOS/Helpers/FPSOverlay.swift (2025B)
1import SwiftUI
2import UIKit
3
4struct FPSOverlay: View {
5 @State private var fps: Int = 0
6 @State private var targetFPS: Int = 0
7 @State private var monitor = FPSMonitor()
8
9 var body: some View {
10 Text("\(fps) / \(targetFPS) FPS")
11 .font(.system(size: 12, weight: .bold, design: .monospaced))
12 .foregroundStyle(fps >= 55 ? .green : fps >= 45 ? .yellow : .red)
13 .padding(.horizontal, 8)
14 .padding(.vertical, 4)
15 .background(.black.opacity(0.7), in: Capsule())
16 .onAppear {
17 monitor.start { actual, target in
18 fps = actual
19 targetFPS = target
20 }
21 }
22 .onDisappear {
23 monitor.stop()
24 }
25 }
26}
27
28@MainActor
29private class FPSMonitor {
30 private var displayLink: CADisplayLink?
31 private var lastTimestamp: CFTimeInterval = 0
32 private var frameCount: Int = 0
33 private var lastTargetFPS: Int = 0
34 private var onUpdate: ((Int, Int) -> Void)?
35
36 func start(onUpdate: @escaping (Int, Int) -> Void) {
37 self.onUpdate = onUpdate
38 let link = CADisplayLink(target: self, selector: #selector(tick))
39 link.preferredFrameRateRange = CAFrameRateRange(minimum: 80, maximum: 120, preferred: 120)
40 link.add(to: .main, forMode: .common)
41 displayLink = link
42 }
43
44 func stop() {
45 displayLink?.invalidate()
46 displayLink = nil
47 }
48
49 @objc private func tick(_ link: CADisplayLink) {
50 let interval = link.targetTimestamp - link.timestamp
51 if interval > 0 {
52 lastTargetFPS = Int(round(1.0 / interval))
53 }
54 if lastTimestamp == 0 {
55 lastTimestamp = link.timestamp
56 return
57 }
58 frameCount += 1
59 let elapsed = link.timestamp - lastTimestamp
60 if elapsed >= 1.0 {
61 onUpdate?(frameCount, lastTargetFPS)
62 frameCount = 0
63 lastTimestamp = link.timestamp
64 }
65 }
66}