Why is viewModel not deiniting with a NavigationView?
Asked Answered
E

4

6

I've got a problem with an object not being destroyed when put into the environment and used with a NavigationView. If a view creates an object, creates a NavigationView and inserts the object into the environment, then when that view disappears the object should be deallocated, but it's not.

The example app below should demonstrate this issue!

Look at the LoggedInView where the ViewModel is created: When the user logs out, the LoggedInView disappears, but the deinit of ViewModel is not called.

What needs to change so that the ViewModel is deinitialized when the LoggedInView disappears?

Here is an example I've created to demo the issue:

struct ContentView: View {
    @State private var loggedIn = false
    
    var body: some View {
        if loggedIn {
            LoggedInView(loggedIn: $loggedIn)
        } else {
            Button("Log In") {
                loggedIn = true
            }
        }
    }
}

struct LoggedInView: View {
    @Binding var loggedIn: Bool
    @StateObject private var viewModel = ViewModel()
    
    var body: some View {
        NavigationView {
            Button("Log Off") {
                loggedIn = false
            }
        }
        .environmentObject(viewModel)
    }
}

class ViewModel: ObservableObject {
    @Published var name = "Steve"
    private let uuid = UUID().uuidString
    
    init() {
        print("initing " + uuid)
    }
    
    deinit {
        print("deiniting " + uuid)
    }
}

If I comment out either the NavigationView { or the .environmentObject(viewModel) then the ViewModel is deallocated as expected.

If I log in and out a few times the console looks like this:

initing 5DA78F23-6EC7-4F06-82F8-6066BB82272E
deiniting 5DA78F23-6EC7-4F06-82F8-6066BB82272E
initing 5BEABAF1-31D0-465E-A35E-94E5E141C453
deiniting 5BEABAF1-31D0-465E-A35E-94E5E141C453
initing 7A4F54FA-7C32-403B-9F47-1ED6289F68B5
deiniting 7A4F54FA-7C32-403B-9F47-1ED6289F68B5

However, if I run the code as above then the console looks like this:

initing D3C8388D-A0A0-42C9-B80A-21254A868060
initing 8DFFF7F7-00C4-4B9F-B592-85422B3A01C0
initing F56D4869-A724-4E10-8C1A-CCA9D99EE1D3
initing ED985C33-C107-4D8C-B51D-4A9D1320F784
deiniting 8DFFF7F7-00C4-4B9F-B592-85422B3A01C0
initing CCD18C41-0196-44B0-B414-697B06E0DE2F
deiniting ED985C33-C107-4D8C-B51D-4A9D1320F784
initing EAA0255A-5F2E-406D-805A-DE2D675F7964
initing 49908FAA-8573-4EBE-8E8B-B0120A504DDA

What's interesting here is that the User object does sometimes get deallocated but it seems only after it's created multiple instances and it never seems to deallocated the first few that were created.

Epithelium answered 18/12, 2022 at 10:15 Comment(3)
In Swift and SwiftUI your model types should be structs not classes. – Pierpont
Thanks, and agree, but this is just a simple example to demonstrate the issue! Trying to understand why the ObservedObject is not deallocating as expected – Epithelium
So deinit is called immediately before the class is deallocated (can be thought of as special instructions for that operation). In the example code it does not appear to be happening determinitistically because the actual deallocation is managed by the swift runtime and may be deferred by it until it is more convenient or efficient for the runtime to do so. (fwiw I would guess this is the reason why multiple instances might trigger the process because the system is detecting memory pressure, lull in processing ...). – Beore
K
5

This is an insane Apple issue which I'm dealing last several days. As I correctly understood such behaviour is a kinda of specific Apple logic for NavigationView/NavigationStackView added to the root of an app hierarchy.

Here is my investigations on it, maybe you can find a workaround which works for you:

  • NavigationView retains in memory all ObservableObjects shared via .environmentObject() method applied on it if and only if NavigationView is placed as a root view of an app.
  • So if you try to replace some view with NavigationView in a root of an hierarchy (login/logout like in your example), then all shared environment objects of navigation would retain in memory.
  • To prove this statement I tried to present NavigationView modally (using .sheet() or .fullScreenCover()) and share ObservableObject via .environmentObject() on it. When presented NavigationView dismisses, SwiftUI successfully deallocates all shared environments objects from memory.

Conclusion: I assume that this is an Apple "feature" we didn't ask for... The thing is that there are a lot of optimisations in SwiftUI and many of them are related to the memory management - inits/deinits of reference type state objectes stored in SwiftUI Views are optimised as well. So Apple may assume that if NavigationView is a root view, then it always should be there (kinda logical... no? :D) and if you even replace it with other view, you always would return to NavigationView... So for the sake of optimizations Apple doesn't remove state objects attached to it πŸ€”

Of course this is just an assumption. But you may try to present authorized flow view with navigation modally from your authorization view... this is the only solution I've found so far. If you find something better, ping me pleas :)

Also, you may change your architecture and make your ViewModel a shared one for the whole app and clean its state on logout.

But the most correct way, I suppose, is that we have to file a bug for Apple. Cause it's definitely a bug, not a feature :)

Knossos answered 23/12, 2022 at 16:52 Comment(2)
Thanks Artem, think you've concluded the same as I have! – Epithelium
I want to add to this, in that SwiftUI does some really stupid stuff. I recently am trying to figure out why an ObservedObject is deinit for one view but the view shown it's using the ObservedObject for a view which never called onAppear. It's an absolute mess. Shame on Apple. – Chloride
K
2

It seems that NavigationView/NavigationStack issue has been fixed in Xcode 14.3. I've tested my use-case described above and right now NavigationStack placed in root view is no longer retains @State Observable Objects propagated via Environment Object! Cheers!

Knossos answered 17/4, 2023 at 12:13 Comment(3)
This does not provide an answer to the question. Once you have sufficient reputation you will be able to comment on any post; instead, provide answers that don't require clarification from the asker. - From Review – Rubato
I'm still seeing the same behavior with NavigationStack on 14.3. – Retraction
Seems it’s back, folks. I can reproduce it on ios 17, Xcode 15 🫠 – Knossos
M
1

This is happening, IMO, because you are creating two strong pointers to the class User, one by making it an @StateObject and other by putting it on "environmentObject".

  1. Try to decouple your User model as a struct and store the UUID there
  2. Create a class to serve as a Storage Only View Model it will only Publish the User struct
  3. Instantiate this View Model as @StateObject into your view and use your model as you wish.
  4. Get rid of your environmentObject, pass along your view model as needed.

PS: Only use environmentObject after a deep understanding about what they are. For now I'll not lecture on environmentObjects

Minard answered 20/12, 2022 at 17:24 Comment(6)
Thanks Allan, on point 1 and 2, malhal made a similar comment about User being a struct instead of a class. Maybe my example above isn't great and I should have just called it a viewmodel. On point 3 and 4, my issue is that I've got an object that needs to be passed around a big app (so don't want to be using ObservedObject as every view in between would then need a reference), and that object not deiniting with the use of a NavigationView – Epithelium
I think your initial point is interesting, but it seems that creating it with @StateObject property and later injecting it into the environment is the recommended way to do this avanderlee.com/swiftui/environmentobject. It also does deinit without the NavigationView but still using the StateObject/environment – Epithelium
I could be totally wrong on this, but if you modify an upper view, like the one that instantiate ContentView and inject in the whole app the ViewModel, you wouldn't need to make it \@StateObject. You could use this "environmentObject" as a \@ObservedObject private var viewModel: ViewModel without the "=" (equals). Because this is an environmentObject you don't need a new instance of it. – Minard
In my previous comment where I say "ObservedObject" I mean "EnvironmentObject". Sorry. – Minard
It needs to be instantiated somewhere and then injected into the view hierarchy, I believe – Epithelium
Yes. You should instantiate in a struct or a class that will keep alive during the whole application. The best one is the struct MyApp: App {} ;-) – Minard
I
1

The most straight-forward solution for your simplified example would be to move the .environmentObject(viewModel) modifier: instead of applying it to the NavigationView, apply it to the Button.

This works if the ViewModel is only needed in children of LoggedInView which aren't wrapped in a NavigationLink.

However, when you apply this approach to a scenario that actually expects the ViewModel to be accessible in any destination views wrapped in NavigationLinks, you would run into the problem of NavigationLink shielding its destination from any .environmentObjects that aren't either a) applied to the enclosing NavigationView or b) applied to NavigationLink's destinations directly.

I've tried a couple of approaches to work around this in your specific setup, but SwiftUI's "optimizations" around if/else statements, as well as @StateObject initialization, cause even solutions using the .id() modifier, AnyView(), and EquatableView() to fail.

All of these should provide custom control over a View's identity, as described in this WWDC talk: Demystify SwiftUI but I believe the specific structure you're looking for touches on too many idiosyncratic components to provide a clean outcome, leaving me to recommend the following as the only solution that maintains your described hierarchy but also deinits the ViewModel as desired:

  1. Remove .environmentObject() from the NavigationView
  2. Apply .environmentObject(viewModel) to all immediate child views (or a Group/Container) inside LoggedInView that need access to the ViewModel
  3. Do the same for any destination views inside of NavigationLinks, but apply .environmentObject(viewModel) directly to the destination view, not to the enclosing NavigationLink.

So a sample implementation looks like this:

struct LoggedInView: View {
    @Binding var loggedIn: Bool
    @StateObject private var viewModel = ViewModel()

    var body: some View {
        NavigationView {
            List {
                Button("Log Off") {
                    loggedIn = false
                }

                DeeperViewDependingOnViewModel()
                    .environmentObject(viewModel)

                NavigationLink {
                    DeeperViewDependingOnViewModel()
                        .environmentObject(viewModel)
                } label: {
                    Text("Go Deeper")
                }
            }
            // This alone would suffice if no NavigationLink destinations need to access ViewModel:
            //.environmentObject(viewModel)
        }
    }
}
Iapetus answered 23/12, 2022 at 9:37 Comment(1)
Thanks Alex, your solution definitely works moving the .environmentObject inside NavigationView, but as I think you recognised it's not ideal especially in an app with a richer ui the NavigationView may only feature in a small corner of the app. Nevertheless I've upvoted your answer! – Epithelium

© 2022 - 2025 β€” McMap. All rights reserved.