0

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. enter image description here

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)
    }
}
10
  • You sound as if keeping things off the main thread would magically reduce CPU usage. In any case, what kind of performance were you expecting? Doing the same with AppKit is only slightly less CPU intensive, from my experiments. Commented Nov 9 at 12:41
  • State is only for structs. You can use .task for a custom timer if TimelineView won't do. Observable is only for models. Use Text for formatting. Commented Nov 9 at 12:58
  • 2
    @State is not only just for struct, as Apple clearly says, see using @State with @Observable class Store observable objects also Apple recommends Managing model data in your app where @State is used to store the source of truth for model data. Commented Nov 9 at 13:56
  • 3
    Your post is titled "Updating UI every 30 seconds..." but then for the rest of your post you talk about updating the UI 30 TIMES A SECOND. 30 times a second is 900 times more frequent then every 30 seconds. You should fix your question title. Commented Nov 10 at 17:05
  • 1
    I suspect that when you change ANY UI element, SwiftUI rebuilds the whole view hierarchy. By adding more Text objects, you are rebuilding a slightly more complex view hierarchy, so I would expect a modest incremental increase in CPU. Commented Nov 10 at 17:07

0

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.