SwiftUI NavigationLink memory leak
Asked Answered
C

3

15

I have a question on how memory management works in SwiftUI's NavigationView stack. I have a view, in which I have declared NavigationView and NavigationLink, inside destination parameter of NavigationLink is my TestView. Navigation works good, but when I pop view from stack (f. e. up back button) deinit is not printed in console and TestViewModel can still be found in memory graph. How do I deinitialize my TestViewModel, when it's not needed anymore ?

    /// First view in application
    struct ContentView: View {

        var body: some View {
            NavigationView {
                VStack {
                    Text("Hello, leak!")
                    NavigationLink(
                        destination: TestView(viewModel: TestViewModel()),
                        label: { Text("Create leak πŸ€·β€β™‚οΈ") }
                    )
                }
            }
        }
    }

    /// Just simple class for init and deinit print
    class TestViewModel: ObservableObject {

        @Published var text = "Test"

        init() {
            print("TestViewModel init")
        }

        deinit {
            print("TestViewModel deinit")
        }
    }

    /// Second view, which is poped from stack
    private struct TestView: View {

        @ObservedObject var viewModel: TestViewModel

        var body: some View {
            Text(viewModel.text)
        }
    }

UPDATE Added memory graph screenshot, which I preveiosly forget.

Memory graph screenshot bottom part

Memory graph screenshot top part

UPDATE

Tested on real device, where navigation works. It looks like, view model is not deinitializad, when poping view, but initialized again, when pushing another time. But question still remains, is there a way to deinit view model, when popping view in navigation stack ?

TestViewModel init
TestViewModel deinit
TestViewModel init

Also, when I add another view to stack, behaviour changes a bit. Now second's view view model will cause leak, but first will be deinitialized as expected.

First view push
TestViewModel init
Second view push
TestViewModel2 init
Second view pop
First view pop
TestViewModel deinit
Camber answered 8/2, 2020 at 17:57 Comment(4)
You don't need to manage deinit, but I think that the framework does what it wants & no real control of that. – Batrachian
Unfortunatelly, I haven't tested it on real device and I can't use NavigationLink twice, because there is a bug on iOS simulator. I will update my answer once I tested it on real device. Nevertheless, I found strange, that I ARC will not release this object. – Maquis
I think, after playing a bit I probably figured out, why memory is not released. When working with SwiftUI, we have entire application wrapped inside single hosting controller, which probably means, we have only one scope and entire application is one single state (I could't wrap my head about it for a while). Every class reference will be held by this controller, until it will be changed by another (case above). I think, Apple should provide some kind of way to manage those scopes manualy or automatically, when performing some kind of navigation. – Maquis
Make sure your viewModel is @StateObject which will create one manageable instance. – Bastinado
C
6

As I am watching Data Essentials in SwiftUI I think, I found the answer to my question. It's new StateObject property wrapper (I could not find the documentation, but here is post which describes it). Now, I can use @StateObject, when I want my data to only exists inside a view scope, whithout doing any hacks.

Camber answered 27/6, 2020 at 12:58 Comment(3)
I have the same question but I don't quite understand your explanation. Can you please provide some code. How does it solve your problem? Is TestViewModel now deallocated from memory, every time you pop view from stack? – Lissome
If one's view model has parameters that need to come in from a View init method, I think I can't use @StateObject. – Wear
really helpful! – Pascasia
W
25

I had the same problem and spent a lot of time to figure it out. Finally, I got it! Use .navigationViewStyle(StackNavigationViewStyle()). Add is as a function to NavigationView:

NavigationView {
   ...
}
.navigationViewStyle(StackNavigationViewStyle())
Wolpert answered 22/6, 2020 at 9:19 Comment(7)
Any ideas of why this solves the problem? – Wear
wow.. I spend A LONG time on this problem! And finally this solved it. Thanks man! – Bcd
Turns out this solution breaks the animated transition when pushing the new view on the navigation stack :( – Bcd
Every time I start thinking to myself "wow, SwiftUI really does make things easy" I immediately hit some stupid, obscure issue that takes 2 days to track down. But thanks for bringing an end to my 2 day search. – Manciple
Didn't work for me. – Chengteh
@Chris, The default navigation style is ColumnNavigationViewStyle, which would show the list AND the selected detail item any time the size class is regular. If on a iPhone Pro Max you can select an item in portrait, go back, but then rotate the device and still see the selected item in the second column. So it turns out that ColumnNavigationViewStyle remembers the selected item until a new item item is selected. – Tova
@MichaelLong thanks! Given the default style is ColumnNavigationViewStyle it now all makes sense! – Barela
C
6

As I am watching Data Essentials in SwiftUI I think, I found the answer to my question. It's new StateObject property wrapper (I could not find the documentation, but here is post which describes it). Now, I can use @StateObject, when I want my data to only exists inside a view scope, whithout doing any hacks.

Camber answered 27/6, 2020 at 12:58 Comment(3)
I have the same question but I don't quite understand your explanation. Can you please provide some code. How does it solve your problem? Is TestViewModel now deallocated from memory, every time you pop view from stack? – Lissome
If one's view model has parameters that need to come in from a View init method, I think I can't use @StateObject. – Wear
really helpful! – Pascasia
T
2

This is happening because the default navigation style is ColumnNavigationViewStyle, which would show the navigation list AND the selected detail item any time the horizontal size class is regular.

To see this in action run the app on a iPhone Pro Max. Then select an item in portrait, go back, and then rotate the device. Bingo. You'll see the selected item in the second column.

So it turns out that in order to make this magic happen ColumnNavigationViewStyle will remember the selected item until a new item item is selected.

Which in turn gives you the mysterious "retain cycle". It's not a leak, it's just how it works. (Even on devices like the iPhone mini that will never allow the second column to appear.)

The .navigationViewStyle(StackNavigationViewStyle()) fix mentioned elsewhere changes this behavior.

Tova answered 11/4, 2022 at 14:0 Comment(0)

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