Performance issues with SwiftUI on macOS

I’ve been trying to resolve some crippling performance issues in a SwiftUI app for macOS with a few thousand objects.

Unfortunately, the documentation of Realm’s Property Wrappers is not very detailed and much of SwiftUI’s behavior remains opaque, so I’d appreciate if someone could confirm my findings or suggest better ways to improve performance.

  • On macOS, SwiftUI’s List is not lazy-loading. When using ForEach on @ObservedResults and populating a SwiftUI.List, every single row’s view is initially created.
  • By default, ANY change to ANY object in @ObservedResults will invalidate ALL views in the corresponding ForEach.
  • ForEach is smart enough to only compute the bodies of views that are currently visible. Views that later come into view are computed lazily.
  • By specifying keyPaths in @ObservedResults, invalidation only occurs if the specified properties change (but still affect ALL items of the collection, even if only a single object has been modified).
  • When using NavigationLink, SwiftUI will initialize each destination view EVERY SINGLE TIME when the NavigationLink is computed. This will lead to a potentially huge number of initializations of destination views and therefore any contained @ObservedRealmObject property wrapper.
  • Specifying the id parameter in ForEach doesn’t have any impact, as Realm objects already need to be Identifiable to be used in @ObservedResults

Workarounds I’ve found:

  • Use the keyPaths parameter of @ObservedResults to limit refreshing to the relevant properties (e.g. the ones that are actually shown). Optionally, limit keyPath to a bogus property to have @ObservedResults only trigger for changes to the collection (add/remove).
  • Keep views as lean as possible (e.g. if a row only requires a String with a name, pass only that property and not the whole object).
  • Only use @ObservedRealmObject for situations where the view is visible (e.g. don’t use it in hundreds of row views in a list).
  • Conform to Equatable in views that rely only on a @ObservedRealmObject for content updates (to prevent them from being refreshed from outside). Implement Equatable by comparing the identity of the @ObservedRealmObject.
  • Debounce text fields that are bound to properties of a @ObservedRealmObject (not quite sure what’s the best way to do that yet).
  • When using NavigationLink with a destination view that has a @ObservedRealmObject, pass nil as destination if the item is not currently selected

It seems to me that there are quite a few design decisions in SwiftUI that currently make it extremely hard to integrate Realm in a way that is both simple and performant. I hope Apple will improve on this by implementing fine-grained invalidation and better-performing UI elements for macOS.

The following article helped me better understand what’s going on in SwiftUI: Understanding how and when SwiftUI decides to redraw views – Donny Wals

Simple example demonstration the performance issues

// SwiftUI app for macOS
import SwiftUI
import RealmSwift // 10.25.0

@main
struct TestApp: SwiftUI.App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

struct ContentView: View {
    @State var selection: UInt64? = nil
    @ObservedResults(Car.self) var cars
    
    var body: some View {
        NavigationView {
            List {
                ForEach(cars) { car in
                    let _ = print("Computing `NavigationLink` for `\(car.name)`")
                    NavigationLink(destination: CarDetailView(car: car), tag: car.id, selection: $selection) {
                        CarCell(car: car)
                    }
                }
            }
            Text("Choose a car")
                .foregroundColor(.secondary)
        }
        .toolbar {
            ToolbarItem(placement: .navigation) {
                Button(action: add10KCars) {
                    Label("Add 10 000 cars", systemImage: "plus")
                }
            }
        }
    }
    
    func add10KCars() {
        let numberOfCars = cars.count
        let realm = try! Realm()
        try! realm.write {
            for index in 0..<10_000 {
                realm.add(Car("Car \(numberOfCars+index+1)"))
            }
        }
    }
}

struct CarDetailView : View {
    @ObservedRealmObject var car:Car
    
    init(car:Car) {
        print("Initializing `CarDetailView` for `\(car.name)`")
        self.car = car
    }

    var body: some View {
        let _ = print("Computing `CarDetailView` for `\(car.name)`")
        TextField("Name", text: $car.name)
            .padding()
        TextField("Model", text: $car.model)
            .padding()
    }
}

struct CarCell : View {

    @ObservedRealmObject var car:Car
    
    init(car:Car)
    {
        print("Initializing `CarCell` for `\(car.name)`")
        self.car = car
    }

    var body: some View {
        let _ = print("Computing `CarCell` for `\(car.name)`")
        Text(car.name)
    }
}

class Car : Object, ObjectKeyIdentifiable {

    @Persisted var name = "Car \(Date.now.timeIntervalSince1970.description)"
    @Persisted var model = ""

    convenience init(_ name:String) {
        self.init()
        self.name = name
    }
}

With some performance enhancements (but still not great)

// SwiftUI app for macOS
import SwiftUI
import RealmSwift // 10.25.0

@main
struct TestApp: SwiftUI.App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

struct ContentView: View {

    @State var selection: UInt64? = nil
    @ObservedResults(Car.self, keyPaths: ["name"]) var cars
    
    var body: some View {
        NavigationView {
            List {
                ForEach(cars) { car in
                    let _ = print("Computing `NavigationLink` for `\(car.name)`")
                    NavigationLink(destination: selection == car.id ? CarDetailView(car: car) : nil, tag: car.id, selection: $selection) {
                        CarCell(name: car.name)
                    }
                }
            }
            Text("Choose a car")
                .foregroundColor(.secondary)
        }
        .toolbar {
            ToolbarItem(placement: .navigation) {
                Button(action: add10KCars) {
                    Label("Add 10 000 cars", systemImage: "plus")
                }
            }
        }
    }
    
    func add10KCars() {
        let numberOfCars = cars.count
        let realm = try! Realm()
        try! realm.write {
            for index in 0..<10_000 {
                realm.add(Car("Car \(numberOfCars+index+1)"))
            }
        }
    }
}

struct CarDetailView : View, Equatable {

    @ObservedRealmObject var car:Car
    
    init(car:Car) {
        print("Initializing `CarDetailView` for `\(car.name)`")
        self.car = car
    }
    
    var body: some View {
        let _ = print("Computing `CarDetailView` for `\(car.name)`")
        TextField("Name", text: $car.name)
            .padding()
        TextField("Model", text: $car.model)
            .padding()
    }

    static func == (lhs: CarDetailView, rhs: CarDetailView) -> Bool {
        return lhs.car.id == rhs.car.id
    }
}

struct CarCell : View {

    let name:String
    
    init(name:String) {
        print("Initializing `CarCell` for `\(name)`")
        self.name = name
    }
    
    var body: some View {
        let _ = print("Computing `CarCell` for `\(name)`")
        Text(name)
    }
}

class Car : Object, ObjectKeyIdentifiable {

    @Persisted var name = "Car \(Date.now.timeIntervalSince1970.description)"
    @Persisted var model = ""

    convenience init(_ name:String) {
        self.init()
        self.name = name
    }
}
1 Like

Is that a question or a statement?

According to WWDC List contents are always loaded lazily and I believe the lazy loading issue of NavigationLink was fixed in XCode 11.something

What versions of XCode and Swift are you using? Have you considered LazyVStack? Did you use the Instruments tool to profile the app?

A bit more info may lead to a clear explanation or perhaps even a solution.

A statement. While the documentation suggests that List should indeed be lazy-loading, it clearly is currently not in macOS.
The code I’ve posted makes it easy to verify this.

AFAIK, NavigationLink is supposed to not be lazy-loading. Running the code confirms that.

The most recent ones (Xcode 13.3 under macOS 12.3.1, Apple Swift version 5.6).

Yes, and that does indeed load lazily. However, it looks quite different and has other issues (like bad scrolling performance).

Yes. That helped track down the above mentioned issues.

How about with latest beta?

I currently don’t have a machine running macOS Ventura beta builds, so I can’t say.

macOS Ventura brought some improvements, among them the ability to create a NavigationLink with a lazy destination. While drawing performance in general seems to have been slightly improved, the biggest issue (List not being lazy) has not been resolved yet.

The recently released Realm v10.34.0 also contributed a speedup by reducing the number of times the view body has to be computed when a view with @ObservedResults is initialized for the first time. @ObservedResults still seems to trigger more view updates than strictly necessary though.

I’ll continue to look into this and will keep you posted (if you don’t mind). :slight_smile:

1 Like

I’ve had to stop using ObservedResults and replace with async loading into a view model. I’ve also implemented pagination into list.

That’s interesting as we really don’t have any issues using ObservedResults - they seem to behave pretty well albeit possibly refreshing the view slightly more than is needed.

We regularly have thousands of objects within a Results with very little memory impact - so we’ve not needed pagination. I am curious what the use case is for pagination with lazy-loaded objects?

It certainly doesn’t hurt but we’ve not seen a need for the additional code since a realm kind of paginates on it’s own, only loading data when it’s needed.

I’ve implemented pagination, too (although I’m using it with ObservedResults). In combination with SwiftUI’s onAppear, it’s fairly simple to load an additional batch of items before the scroll hits bottom.

The main issue from my point of view is still the non-lazy view loading on macOS and the opaque equality comparison done by SwiftUI to determine which objects have changed (especially with NSObject-based instances). Without the workarounds, my list views would still take several seconds to load (which is not surprising when several thousand views have to be created and rendered).

Looking forward to macOS Sonoma, although my hopes are not that high. Apple hasn’t responded to a single bug report I’ve submitted this year, and there were plenty of those…

I can’t document the issues I’ve had right now sorry

In terms of pagination, I’m dealing with lists of hundreds of thousands of objects