How to update SwiftUI many times a second while being performant?

Hey guys, I'm at a loss on this one. I have an async event loop that is parsing messages from an IRC chat. I have SwiftUI listening for updates via ObservableObject, but my CPU is spiking to 100%+. The data comes in many times a second, so SwiftUI is needing to update a List pretty often with new items. How do I ensure the CPU usage doesn't get out of control? Is there a best practice for updating the UI with high frequency data?

This will depend a lot on the specifics, and I cannot give you SwiftUI best-practices (because I haven't used it in ages) but in general a few things come to mind:

  • use some form of throttling/debouncing (eg: no more than once per second) to perform the more expensive update (both combine and async-algorithms have pre-made solutions for this)

  • connected to the throttling idea, you might want to batch or "chunk" your stream of updates into some lightweight data-structure, then apply the chunked updates less frequently

  • in general, make sure you use a data structure that performs well for your use case (eg: check out prepending/appending performance characteristics of Deque vs List)

I've made stopwatch with UI updated at 60fps rate and at about 90fps in promotion displays. CPU utilisation was quite low on iOS 16 / 17. Frequent UI updates is not always the bottleneck.
I should recommend to profile your App at first. Poor performance can be caused by another reasons, e.g. large amount of work in main thread, such as Decoding of large JSONs. Try to minimise UI updates impact e.g. by replacing all of your custom views with complex hierarchy by simple Text(""). If CPU is still highly utilised, use Profiler to see what functions use CPU most of the time.
There are also several WWDC videos about SwiftUI performance, structural and explicit identity and other topics. Particulary, if explicit identity is made incorrectly, the rendering become more expensive. Nuances with structural identity can also lead to poor performance.

1 Like

This made a significant improvement. I throttled the updates to just 0.3 and that helped a ton. The subscriber model is hard to figure out though. It took a while to even research how to set that up, but I did it with 2 observable objects, where one is the raw updates and the other is the debounced version that the UI subscribes to. Hopefully that sounds like a sensible design.

I also noticed I was conforming to identifiable with var id { return UUID() } which was very expensive. I changed that to simply just be a member variable and that reduced CPU consumption by about 20% lol.

1 Like

In this case, I am doing all of my heavy lifting in c++ on a background thread, so the UI isn't having to do anything except display the data. Even when I was just displaying raw text instead of my actual view it was still showing upwards of 120% CPU. the backend that processes and translates the data only uses 0.1% CPU, so the jump to 120% is very extreme.

however, you were correct about the identity. I was conforming to Identifiable via a getter which was consuming a ton of CPU each update. changing that to a simple member variable made a big difference.

Oof, var id { UUID() }, in addition to being a bit more expensive than normal, would have changed the identity of all of your models every time it was accessed, and so was causing huge amounts of recomputation within SwiftUI. Stable identity of both models and views is key to SwiftUI performance.

5 Likes

Yes, excellent point. I would also add that it is quite easy have poor performance because of structural identity pitfalls. Like this pattern from stackoverflow:

extension View {
  //  "Don't use it, it breaks animations and leads to poor performance
  @ViewBuilder public func applyIf(condition: Bool, transform: (Self) -> some View) -> some View {
    if condition {
      transform(self)
    } else {
      self
    }
  }
}

From SwiftUI point of view and structural identity particularly, the result of this function differs from call to call. Because of that, SwiftUI destroy current view and create new instance.

// instead of this
.applyIf(condition: isAuthorised) { $0.opacity(0.5) }

// it is better to write
$0.opacity(isAuthorised ? 1 : 0.5)

Just a hint what we’ve done:

We prep the complete data set on background tasks, provide it with an async stream of size 1 which just provides the latest update and drops previous ones, the on the main actor task we just assign this complete data update (with care of identifiers as mentioned).

Then we do a “tail debounce” by sleeping the task that is reading from the stream for a randomized amount of time - then check if we overshot that time significantly (which means a saturated main thread typically) - we then add that to the sleep the next time so we back off the FPS practically. If we hit the sleep target, we will back of the sleep with hysteresis to avoid oscillations. This gives us the desired behavior to update as frequently as the main thread can handle without choking with dynamic back off if overloaded, while performing all processing on non-main thread tasks.

(We have many windows with high data rates in parallel)

2 Likes

You can also try to use EquatableView s in SwiftUI. I haven't yet profiled them for performance, but the following trick works well with diffable algorithms in UIKit.
Imagine you have a thousand of ViewMdels (immutable structs), each of them consists of some complex data that is expensive to check for equality in main thread.
The idea is to make equality check in some other Queue / Thread. Simplified solution is something like:

struct ViewModel: Equatable {
  var content: RenewableContent

  private var diffToken: Int64

  func updateWith(args: ..) {
    let newContent = content.updatedWith(args: ..)

    if newContent != content {
      // expensive direct equality via `!=` and update is done in background
      // or something more clever can be done instead of `!=`
      diffToken = Int64.random()
    }

    content = newContent
  }

  private struct RenewableContent {
    var foo: ...
    var bar: ...
  }
  
  // When viewModels will be compared in UI on main thread 
  // the equality check will be speed of light fast as Int value or something similar is compared
  public static func == (lhs: Self, rhs: Self) -> Bool { 
    lhs.diffToken == rhs.diffToken 
  }
} 

It is very convenient to have a compiler synthesised == function. But sometimes, there is a need to:

  • keep a data that is not Equatable
  • keep a helper data needed for internal logic but we want to prevent it being used for Equality comparison
    To exclude such data a property wrapper can be used:
@propertyWrapper
struct EquatableExcluded<T>: Equatable {
  var wrappedValue: T
  
  init...
  
  static func == (lhs: Self, rhs: Self) -> Bool { true }
  func hash(into hasher: inout Hasher) {}
}

I suppose similar concept can be implemented in SwiftUI

The other piece of advice I can give here is to avoid having many other unrelated views observing the same ObservableObject, because ObservableObject tracking happens when any of the @Published properties change.

The easiest solution to this is to switch to @Observable, which does change-tracking on a per-property basis and therefore only ever updates the views that actually intend to read the high-frequency-updating value.

2 Likes

For me personally understanding two things help building SwiftUI views:

  1. view is a function of state;
  2. SwiftUI will update view when state updates (see 1) with diffing algorithm under hood based on some identity.

Then you just care about state update in the end and how it being handled in the view. Also as @harlanhaskins pointed already @Observable does some additional magic and updates only related views. If you need support for older devices—there is a swift-perception library from Pointfreeco which backports behaviour up to iOS 13.

1 Like