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
    }
}

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.