swiftuimemory-leaks

SwiftUI (Mac) @Observable memory leak, related to an array, triggers App lifecycle


Using @Observable view model with an array collection, if addressed in init, will trigger a second App lifecycle when mutated.

Breaking it up:
Root:

@main
struct MyApp: App {
    
    var body: some Scene {
        WindowGroup {
            MainContentView()
        }
    }
}

ViewModel

@Observable
class ContentViewModel {
    var items = [String]()
    
    init() {
        print("Init")
        if items.isEmpty {}
    }

    deinit {
        print("deinit")
    }

    
    func update() {
        items.append("New")
    }
}

View:

struct ContentView: View {
    
    @State var viewModel: ContentViewModel = .init()
    
    var body: some View {
        Button("Update", action: viewModel.update)
    }
}

When running this, it will print "Init" (expected of course), but clicking "Update" triggers a complete lifecycle.
It will print another "Init". Setting a breakpoint on the (single) WindowGroup will stop there. Meaning (I think) that the whole var body: some Scene is getting triggered.


Solution

  • The key difference between @StateObject and @State is that the property initialiser of @State is called every time the view's initialiser is called.

    So after viewModel is first initialised, you end up creating a temporary instance of ContentViewModel every subsequent time that ContentView.init is called.

    Normally, the temporary instance is immediately deinitialised, but it can still affect performance if you are doing something heavy in the @Observable class' initialiser. This is pointed out in the documentation for State.

    A State property always instantiates its default value when SwiftUI instantiates the view. For this reason, avoid side effects and performance-intensive work when initializing the default value. For example, if a view updates frequently, allocating a new default object each time the view initializes can become expensive. Instead, you can defer the creation of the object using the task(priority:_:) modifier, which is called only once when the view first appears:

    struct ContentView: View {
        @State private var library: Library?
    
    
        var body: some View {
            LibraryView(library: library)
                .task {
                    library = Library()
                }
        }
    }
    

    Sometimes, one extra instance is retained because of some quirks in SwiftUI's algorithm of creating a dependency graph. From my experience, I have only seen at most one extra instance per @State.

    In your case, the line if items.isEmpty {} access the observation-tracked property items, and this access is registered in the observationRegistrar. This affects SwiftUI's dependency graph one way or another, and the instance is not released.

    You can solve this by doing what the documentation recommends - making it optional and initialising it in task or onAppear.

    Alternatively, you can use this property wrapper by malhal.

    @MainActor
    @propertyWrapper struct StateObservable<T>: DynamicProperty where T: Observable & AnyObject {
        @StateObject var storage: Storage
        
        class Storage: ObservableObject {
            @Published var object: T // @Published allows the object to be replaced and body will be called
            init(object: T) {
                self.object = object
            }
        }
        
        var wrappedValue: T {
            get {
                return storage.object
            }
            nonmutating set {
                storage.object = newValue
            }
        }
        
        // this allows $ syntax to be used in the same View where the wrapper is declared.
        var projectedValue: Bindable<T> {
            get {
                Bindable(storage.object)
            }
        }
        
        @inlinable public init(wrappedValue thunk: @autoclosure @escaping () -> T) {
            _storage = StateObject(wrappedValue: Storage(object: thunk()))
        }
    }
    

    This property wrapper wraps the property initialiser expression into an @autoclosure, so that it acts similarly to @StateObject.