SwifUI Performance With Rapid UI Updates

The code below is a test to trigger UI updates every 30 seconds. I'm trying to keep most work off main and only push to main once I have the string (which is cached). Why is updating SwiftUI 30 times per second so expensive? This code causes 10% CPU on my M4 Mac, but comment out the following line:

Text(model.timeString)

and it's 0% CPU. The reason why I think I have too much work on main is because of this from instruments. But I'm no instruments expert.

import SwiftUI
import UniformTypeIdentifiers

@main
struct RapidUIUpdateTestApp: App {
    var body: some Scene {
        DocumentGroup(newDocument: RapidUIUpdateTestDocument()) { file in
            ContentView(document: file.$document)
        }
    }
}


struct ContentView: View {
    @Binding var document: RapidUIUpdateTestDocument
    @State private var model = PlayerModel()
    var body: some View {
        VStack(spacing: 16) {
            Text(model.timeString) // only this changes
                .font(.system(size: 44, weight: .semibold, design: .monospaced))
                .transaction { $0.animation = nil } // no implicit animations
            HStack {
                Button(model.running ? "Pause" : "Play") {
                    model.running ? model.pause() : model.start()
                }
                Button("Reset") { model.seek(0) }
                Stepper("FPS: \(Int(model.fps))", value: $model.fps, in: 10...120, step: 1)
                    .onChange(of: model.fps) { _, _ in model.applyFPS() }
            }
        }
        .padding()
        .onAppear { model.start() }
        .onDisappear { model.stop() }
    }
}

@Observable
final class PlayerModel {
    // Publish ONE value to minimize invalidations
    var timeString: String = "0.000 s"
    var fps: Double = 30
    var running = false
    

    private var formatter: NumberFormatter = {
        let f = NumberFormatter()
        f.minimumFractionDigits = 3
        f.maximumFractionDigits = 3
        return f
    }()

    @ObservationIgnored private let q = DispatchQueue(label: "tc.timer", qos: .userInteractive)
    @ObservationIgnored private var timer: DispatchSourceTimer?
    @ObservationIgnored private var startHost: UInt64 = 0
    @ObservationIgnored private var pausedAt: Double = 0
    @ObservationIgnored private var lastFrame: Int = -1

    // cache timebase once
    private static let secsPerTick: Double = {
        var info = mach_timebase_info_data_t()
        mach_timebase_info(&info)
        return Double(info.numer) / Double(info.denom) / 1_000_000_000.0
    }()

    func start() {
        guard timer == nil else { running = true; return }
        
        let desiredUIFPS: Double = 30        // or 60, 24, etc.
        let periodNs = UInt64(1_000_000_000 / desiredUIFPS)
        
        running = true
        startHost = mach_absolute_time()

        let t = DispatchSource.makeTimerSource(queue: q)
        // ~30 fps, with leeway to let the kernel coalesce wakeups
        t.schedule(
            deadline: .now(),
            repeating: .nanoseconds(Int(periodNs)), // 33_333_333 ns ≈ 30 fps
            leeway: .milliseconds(30)                // allow coalescing
        )
        t.setEventHandler { [weak self] in self?.tick() }
        timer = t
        t.resume()
    }

    func pause() {
        guard running else { return }
        pausedAt = now()
        running = false
    }

    func stop() {
        timer?.cancel()
        timer = nil
        running = false
        pausedAt = 0
        lastFrame = -1
    }

    func seek(_ seconds: Double) {
        pausedAt = max(0, seconds)
        startHost = mach_absolute_time()
        lastFrame = -1 // force next UI update
    }

    func applyFPS() { lastFrame = -1 } // next tick will refresh string

    // MARK: - Tick on background queue
    private func tick() {
        let s = now()
     
        let str = formatter.string(from: s as NSNumber) ?? String(format: "%.3f", s)
        let display = "\(str) s"

        DispatchQueue.main.async { [weak self] in
            self?.timeString = display
        }
    }

    private func now() -> Double {
        guard running else { return pausedAt }
        let delta = mach_absolute_time() &- startHost
        return pausedAt + Double(delta) * Self.secsPerTick
    }
}

nonisolated struct RapidUIUpdateTestDocument: FileDocument {
    var text: String

    init(text: String = "Hello, world!") {
        self.text = text
    }

    static let readableContentTypes = [
        UTType(importedAs: "com.example.plain-text")
    ]

    init(configuration: ReadConfiguration) throws {
        guard let data = configuration.file.regularFileContents,
              let string = String(data: data, encoding: .utf8)
        else {
            throw CocoaError(.fileReadCorruptFile)
        }
        text = string
    }
    
    func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
        let data = text.data(using: .utf8)!
        return .init(regularFileWithContents: data)
    }
}

In your case, you're probably re-evaluating the whole ContentView body 30 times per second, which includes laying out your stacks. I'd recommend using the SwiftUI instrument in Instruments 26 to see how many times your views are being updated.

There's a WWDC talk from this year about the SwiftUI instrument that I'd highly recommend, and also, if you check the Apple Developer channel on YouTube, there's a video from a recent event called "Optimize your app's speed and efficiency" that has a deeper dive into how to use it about an hour into the video.

You might be able to get better performance from your code by isolating the updates to a child view that contains just the Text, but you also may want to look at TimelineView (docs), which specifically designed for cases like this.

SwifUI Performance With Rapid UI Updates
 
 
Q