How can I optimize SwiftUI CPU load on frequent updates

Dear Sirs,

I'm writing an audio application that should show up to 128 horizontal peakmeters (width for each is about 150, height is 8) stacked inside a ScrollViewReader. For the actual value of the peakmeter I have a binding to a CGFloat value. The peakmeter works as expected and is refreshing correct. For testing I added a timer to my swift application that is firing every 0.05 secs, meaning I want to show 20 values per second. Inside the timer func I'm just creating random CGFloat values in range of 0...1 for the bound values. The peakmeters refresh and flicker as expected but I can see a CPU load of 40-50% in the activity monitor on my MacBook Air with Apple M2 even when compiled in release mode. I think this is quite high and I'd like to reduce this CPU load. Should this be possible? I.e. I thought about blocking the refresh until I've set all values? How could this be done and would it help? What else could I do?

Thanks and best regards, JFreyberger

Answered by maxrys in 827080022

This example draws only in the visible window. The load is 10% at 128 stripes. The gain will be at 2048 stripes, where the load will decrease from 45% to 35%. It seems to me that you can't squeeze more out of this - 10% is the ceiling and this is an acceptable result for SWiftUI.

  1. You can always reduce the number of stripes - in real life you don't need that much.
  2. You can slow down the timer from 1/24 to 1/12.
  3. You can remake it to UIKit/AppKit.
  4. You can make virtual scrolling - this is when nothing scrolls, and changes occur only on the image or in any separate area.

In any case, I helped both myself and you. I need exactly the same functionality and was interested in how much can be squeezed out of optimization.

import SwiftUI

@main struct app: App {

    static var ITEM_HEIGHT: CGFloat = 10.0

    var equalizerState = EqualizerState()
    @State var canvasMinY: CGFloat = 0
    @State var canvasMaxY: CGFloat = 0

    var body: some Scene {
        WindowGroup {
            ScrollView(.vertical) {
                Canvas { context, size in
                    for index in 0 ..< EqualizerState.MAX_ITEMS {
                        let h = Self.ITEM_HEIGHT
                        let y = h * CGFloat(index)
                        if (self.canvasMinY ... self.canvasMaxY).contains(y) {
                            let value = self.equalizerState.values[index]
                            let wPart = size.width / 3
                            let wFull = size.width * value
                            let x1 = wPart * 0
                            let x2 = wPart * 1
                            let x3 = wPart * 2
                            let w1 = wFull - (wPart * 0)
                            let w2 = wFull - (wPart * 1)
                            let w3 = wFull - (wPart * 2)
                            if (0.00 ... 1.00).contains(value) { context.fill(Path(CGRect(x: x1, y: y, width: w1, height: h)), with: .color(.green )) }
                            if (0.33 ... 1.00).contains(value) { context.fill(Path(CGRect(x: x2, y: y, width: w2, height: h)), with: .color(.yellow)) }
                            if (0.66 ... 1.00).contains(value) { context.fill(Path(CGRect(x: x3, y: y, width: w3, height: h)), with: .color(.red   )) }
                        }
                    }
                }.frame(height: Self.ITEM_HEIGHT * CGFloat(EqualizerState.MAX_ITEMS))
            }.onScrollGeometryChange(for: Bool.self) { geometry in
                self.canvasMinY = geometry.bounds.minY
                self.canvasMaxY = geometry.bounds.maxY
                return true
            } action: { _, _ in }
            .frame(width: 150)
            .background(.gray)
            .padding(.vertical, 12)
        }
    }

}

@Observable final class EqualizerState {

    static public let MAX_ITEMS: Int = 128

    @ObservationIgnored private var timer: Timer? = nil
    var values: [CGFloat] = []

    init() {
        self.values = Array(
            repeating: 0.0,
            count: Self.MAX_ITEMS
        )
        self.timer = Timer(
            timeInterval: 1 / 24,
            repeats: true,
            block: { _ in
                for index in 0 ..< Self.MAX_ITEMS {
                    self.values[index] = CGFloat.random(
                        in: 0...1
                    )
                }
            }
        )
        self.timer!.tolerance = 0.0
        RunLoop.current.add(
            self.timer!,
            forMode: .common
        )
    }

}

Although I know you'll again blame my naming conventions I decided to add a small example here: it does nothing except the peakmeter stuff and still causes a CPU load of 25% to 30% for 128 channels. This CPU load merely changes if you decrease the size of the window to only show two channels, while starting the example with only two channels (set s_NbPeakmeter = 2) shows a much lower cpu load. So invisible views also seem to cause CPU load?

import SwiftUI

class CPeakmeterManager: NSObject, ObservableObject
{
    static public let s_NbPeakmeter: Int = 128

    @Published var m_VecPeakmeterValues: [CGFloat] = []

    var m_Timer: Timer? = nil

    override init() 
    {
        super.init()
        
        m_VecPeakmeterValues = [CGFloat](repeating: 0.0, count: CPeakmeterManager.s_NbPeakmeter )
        
        m_Timer = Timer.scheduledTimer(timeInterval: 0.05, target: self, selector: #selector( OnTimer ), userInfo: nil, repeats: true)
    }
    
    @objc func OnTimer() 
    {
        for ChannelIndex in 0..<m_VecPeakmeterValues.count
        {
            m_VecPeakmeterValues[ ChannelIndex ] = CGFloat.random(in: 0...1)
        }
    }
}

struct PeakmeterView: View
{
    @Binding var b_PeakmeterValue: CGFloat

    var body: some View 
    {
        GeometryReader { geometry in
            ZStack(alignment: .trailing) {
                
                HStack(spacing: 0) {
                    Rectangle()
                        .frame( width: 0.4 * geometry.size.width, height: 10 )
                        .foregroundColor(.green)
                        
                    Rectangle()
                        .frame( width: 0.3 * geometry.size.width, height: 10 )
                        .foregroundColor(.yellow)
                            
                    Rectangle()
                        .frame( width: 0.3 * geometry.size.width, height: 10 )
                        .foregroundColor(.red)
                }

                Rectangle()
                    .frame(width: min( ( 1.0 - b_PeakmeterValue ) * geometry.size.width, geometry.size.width), height: 10)
                    .opacity(0.9)
                    .foregroundColor(.gray)
                }
        }
    }
}

@main
struct PeakmeterTestApp: App {

    @StateObject var m_PeakmeterManager = CPeakmeterManager()

    var body: some Scene {
        WindowGroup {
            ContentView().environmentObject(self.m_PeakmeterManager)
        }
    }
}

struct ContentView: View {

    @EnvironmentObject var m_PeakmeterManager: CPeakmeterManager

    var body: some View {
        ScrollViewReader { proxy in
            ScrollView {
                ForEach(0 ..< CPeakmeterManager.s_NbPeakmeter, id: \.self) { ChannelIndex in
                    PeakmeterView( b_PeakmeterValue: $m_PeakmeterManager.m_VecPeakmeterValues[ ChannelIndex ])
                        .frame(width: 150)
                }
            }
        }
        .padding([.top,.bottom],12)
    }
}

I am also creating a music app on SwiftUI and I also encountered a speed problem.

I have rewritten your code to a modern style and here it is:

import SwiftUI

@main struct app: App {

    var peakmeterState = PeakmeterState()
    static var ITEM_HEIGHT: CGFloat = 10.0

    var body: some Scene {
        WindowGroup {
            GeometryReader { geometry in
                ScrollView(.vertical) {
                    VStack(alignment: .leading, spacing: 1) {
                        let _ : Bool = {
                            print("FRAME REDRAW \(self.peakmeterState.frameNum)")
                            return true
                        }()
                        ForEach(0 ..< PeakmeterState.MAX_ITEMS, id: \.self) { index in
                            let width = geometry.size.width
                            let value = self.peakmeterState.peakValues[index]
                            Canvas { context, size in
                                context.fill(Path(CGRect(x: width / 3 * 0, y: 0, width: width / 3, height: Self.ITEM_HEIGHT)), with: .color(.green))
                                context.fill(Path(CGRect(x: width / 3 * 1, y: 0, width: width / 3, height: Self.ITEM_HEIGHT)), with: .color(.yellow))
                                context.fill(Path(CGRect(x: width / 3 * 2, y: 0, width: width / 3, height: Self.ITEM_HEIGHT)), with: .color(.red))
                            }
                            .frame(width: width * value, height: Self.ITEM_HEIGHT)
                            .animation(.spring(duration: 0.1), value: width * value)
                        }
                    }
                }
            }
            .frame(width: 150)
            .background(.gray)
            .padding(.vertical, 12)
        }
    }

}

@Observable final class PeakmeterState {

    static public let MAX_ITEMS: Int = 128

    @ObservationIgnored private var timer: Timer? = nil
    @ObservationIgnored var peakValues: [CGFloat] = []
    var frameNum: Int = 0

    init() {
        self.peakValues = Array(
            repeating: 0.0,
            count: Self.MAX_ITEMS
        )
        self.timer = Timer(
            timeInterval: 1 / 5,
            repeats: true,
            block: { _ in
                for index in 0 ..< self.peakValues.count {
                    self.peakValues[index] = CGFloat.random(
                        in: 0...1
                    )
                }
                self.frameNum += 1
            }
        )
        self.timer!.tolerance = 0.0
        RunLoop.current.add(
            self.timer!,
            forMode: .common
        )
    }

}

You can reduce the load by:

  1. Use final on classes to speed up method calls.
  2. Don't modify arrays in loops in @Observable classes because it causes unnecessary repaints (set them to @ObservationIgnored and change another variable in that class after array modification).
  3. Reducing the number of elements.
  4. Making the timer slower + removing animation.
  5. Rewriting the component to UIKit / AppKit.
  6. Maybe use SpriteKit (high-performance 2D content with smooth animations to your app, or create a game with a high-level set of 2D game-based tools).

In my project, even a simple cursor redraw eats up 15% of CPU power. Nothing helps - while the number of redraw frames was reduced to the minimum value.

I remade Canvas so that it is not in ForEach and now CPU load is no more than 10%:

import SwiftUI

@main struct app: App {

    var equalizerState = EqalizerState()
    static var ITEM_HEIGHT: CGFloat = 10.0

    var body: some Scene {
        WindowGroup {
            ScrollView(.vertical) {
                Canvas { context, size in
                    for index in 0 ..< EqalizerState.MAX_ITEMS {
                        let value = self.equalizerState.values[index]
                        let wPart = size.width / 3
                        let wFull = size.width * value
                        let x1 = wPart * 0
                        let x2 = wPart * 1
                        let x3 = wPart * 2
                        let w1 = wFull - (wPart * 0)
                        let w2 = wFull - (wPart * 1)
                        let w3 = wFull - (wPart * 2)
                        let y = Self.ITEM_HEIGHT * CGFloat(index)
                        let h = Self.ITEM_HEIGHT
                        if (0.00 ... 1.00).contains(value) { context.fill(Path(CGRect(x: x1, y: y, width: w1, height: h)), with: .color(.green )) }
                        if (0.33 ... 1.00).contains(value) { context.fill(Path(CGRect(x: x2, y: y, width: w2, height: h)), with: .color(.yellow)) }
                        if (0.66 ... 1.00).contains(value) { context.fill(Path(CGRect(x: x3, y: y, width: w3, height: h)), with: .color(.red   )) }
                    }
                }.frame(height: Self.ITEM_HEIGHT * CGFloat(EqalizerState.MAX_ITEMS))
            }
            .frame(width: 150)
            .background(.gray)
            .padding(.vertical, 12)
        }
    }

}

@Observable final class EqalizerState {

    static public let MAX_ITEMS: Int = 128

    @ObservationIgnored private var timer: Timer? = nil
    var values: [CGFloat] = []

    init() {
        self.values = Array(
            repeating: 0.0,
            count: Self.MAX_ITEMS
        )
        self.timer = Timer(
            timeInterval: 1 / 24,
            repeats: true,
            block: { _ in
                for index in 0 ..< self.values.count {
                    self.values[index] = CGFloat.random(
                        in: 0...1
                    )
                }
            }
        )
        self.timer!.tolerance = 0.0
        RunLoop.current.add(
            self.timer!,
            forMode: .common
        )
    }

}
  1. It is advisable not to use ScrollView if you can do without it.

  2. Environment variables are very slow.

Hi maxrys,

thanks for your proposal. I like the idea with the single canvas. But it seems like it still computes all areas of the canvas even if they are hidden due to the position of the ScrollView. So I think the biggest part of the CPU load would be gone if you could easily decide whether a peakmeter is really visible and stop all further processing of it if it is not. And I'd have hoped that this kind of optimization is already part of SwiftUI and that you don't have to implement this in your own code, which will never work as smooth as something already done in the framework.

Accepted Answer

This example draws only in the visible window. The load is 10% at 128 stripes. The gain will be at 2048 stripes, where the load will decrease from 45% to 35%. It seems to me that you can't squeeze more out of this - 10% is the ceiling and this is an acceptable result for SWiftUI.

  1. You can always reduce the number of stripes - in real life you don't need that much.
  2. You can slow down the timer from 1/24 to 1/12.
  3. You can remake it to UIKit/AppKit.
  4. You can make virtual scrolling - this is when nothing scrolls, and changes occur only on the image or in any separate area.

In any case, I helped both myself and you. I need exactly the same functionality and was interested in how much can be squeezed out of optimization.

import SwiftUI

@main struct app: App {

    static var ITEM_HEIGHT: CGFloat = 10.0

    var equalizerState = EqualizerState()
    @State var canvasMinY: CGFloat = 0
    @State var canvasMaxY: CGFloat = 0

    var body: some Scene {
        WindowGroup {
            ScrollView(.vertical) {
                Canvas { context, size in
                    for index in 0 ..< EqualizerState.MAX_ITEMS {
                        let h = Self.ITEM_HEIGHT
                        let y = h * CGFloat(index)
                        if (self.canvasMinY ... self.canvasMaxY).contains(y) {
                            let value = self.equalizerState.values[index]
                            let wPart = size.width / 3
                            let wFull = size.width * value
                            let x1 = wPart * 0
                            let x2 = wPart * 1
                            let x3 = wPart * 2
                            let w1 = wFull - (wPart * 0)
                            let w2 = wFull - (wPart * 1)
                            let w3 = wFull - (wPart * 2)
                            if (0.00 ... 1.00).contains(value) { context.fill(Path(CGRect(x: x1, y: y, width: w1, height: h)), with: .color(.green )) }
                            if (0.33 ... 1.00).contains(value) { context.fill(Path(CGRect(x: x2, y: y, width: w2, height: h)), with: .color(.yellow)) }
                            if (0.66 ... 1.00).contains(value) { context.fill(Path(CGRect(x: x3, y: y, width: w3, height: h)), with: .color(.red   )) }
                        }
                    }
                }.frame(height: Self.ITEM_HEIGHT * CGFloat(EqualizerState.MAX_ITEMS))
            }.onScrollGeometryChange(for: Bool.self) { geometry in
                self.canvasMinY = geometry.bounds.minY
                self.canvasMaxY = geometry.bounds.maxY
                return true
            } action: { _, _ in }
            .frame(width: 150)
            .background(.gray)
            .padding(.vertical, 12)
        }
    }

}

@Observable final class EqualizerState {

    static public let MAX_ITEMS: Int = 128

    @ObservationIgnored private var timer: Timer? = nil
    var values: [CGFloat] = []

    init() {
        self.values = Array(
            repeating: 0.0,
            count: Self.MAX_ITEMS
        )
        self.timer = Timer(
            timeInterval: 1 / 24,
            repeats: true,
            block: { _ in
                for index in 0 ..< Self.MAX_ITEMS {
                    self.values[index] = CGFloat.random(
                        in: 0...1
                    )
                }
            }
        )
        self.timer!.tolerance = 0.0
        RunLoop.current.add(
            self.timer!,
            forMode: .common
        )
    }

}

Hi maxrys, thanks for all your efforts and your latest version. Yes, I agree that probably that's the best you can squeeze out of SwiftUI :-)

Production version (horizontal version):

import SwiftUI

typealias Size = CGFloat

extension Numeric {

    func fixBounds(min: Self = 0, max: Self) -> Self where Self: Comparable {
        if self < min {return min}
        if self > max {return max}
        return self
    }

}

@Observable final class EqState {

    var canvasFrameMinX: Size = 0
    var canvasFrameMaxX: Size = 0
    var levels: [Double] = []

}

@main struct app: App {

    private var eqState = EqState()
    private let eqLevelsCount: Int = 128
    private let eqLevelWidth: Size = 10.0
    private let eqHeight: Size = 150
    private let timeInterval: Double = 1 / 24
    private var timer: Timer!

    var body: some Scene {
        WindowGroup {
            Equalizer(
                height    : self.eqHeight,
                levelWidth: self.eqLevelWidth,
                state     : self.eqState
            )
            .background(.gray)
            .padding(.horizontal, 12)
        }
    }

    init() {
        self.eqState.levels = Array(
            repeating: 0.0,
            count: self.eqLevelsCount
        )
        self.timer = Timer(
            timeInterval: self.timeInterval,
            repeats: true,
            block: self.onTimerTick
        )
        self.timer.tolerance = 0.0
        RunLoop.current.add(
            self.timer,
            forMode: .common
        )
    }

    func onTimerTick(_ : Timer) {
        for index in 0 ..< self.eqState.levels.count {
            self.eqState.levels[index] = Size.random(
                in: 0...1
            )
        }
    }

}

struct Equalizer: View {

    var height: Size
    var levelWidth: Size
    var state: EqState

    var body: some View {
        ScrollView(.horizontal) {
            Canvas { context, size in
                for index in 0 ..< self.state.levels.count {
                    let w = self.levelWidth
                    let x = self.levelWidth * Size(index)
                    if (self.state.canvasFrameMinX ... self.state.canvasFrameMaxX).contains(x) {
                        let level = self.state.levels[index]
                        let value = size.height * level
                        let sliceHeight = size.height / 3
                        let h3 = (value - (sliceHeight * 2)).fixBounds(min: 0, max: sliceHeight)
                        let h2 = (value - (sliceHeight * 1)).fixBounds(min: 0, max: sliceHeight)
                        let h1 = (value - (sliceHeight * 0)).fixBounds(min: 0, max: sliceHeight)
                        let y3 = sliceHeight * 1 - h3
                        let y2 = sliceHeight * 2 - h2
                        let y1 = sliceHeight * 3 - h1
                        if (h3 > 0) { context.fill(Path(CGRect(x: x, y: y3, width: w, height: h3)), with: .color(.red   )) }
                        if (h2 > 0) { context.fill(Path(CGRect(x: x, y: y2, width: w, height: h2)), with: .color(.yellow)) }
                        if (h1 > 0) { context.fill(Path(CGRect(x: x, y: y1, width: w, height: h1)), with: .color(.green )) }
                    }
                }
            }
            .frame(width: self.levelWidth * Size(self.state.levels.count))
            .frame(height: self.height)
        }
        .onScrollGeometryChange(for: Bool.self) { geometry in
            self.state.canvasFrameMinX = geometry.bounds.minX
            self.state.canvasFrameMaxX = geometry.bounds.maxX
            return true
        } action: { _, _ in }
    }

}
How can I optimize SwiftUI CPU load on frequent updates
 
 
Q