2

I'm trying to implement a Coordinator for managing a flow. The state is stored inside the CoordinatorStore. There are 2 @Published properties for managing the flow. The screen property controls which View is currently shown and path controls the navigation stack of the stack view. Details of the implementation can be found below.

With the current implementation and after the following actions: showA -> showB -> showInitial -> Go to Stack

I would expect that StoreA and StoreB would be deallocated from memory since path, which holds StoreA and StoreB via enum associated values, gets emptied.

But that doesn't happen, and if I repeat the actions again there would be 2 StoreA and 2 StoreB in memory and so on. Am I missing something?

I will also attach a screenshot of the memory debugger snapshot after doing the initial set of actions.

enum Path: Hashable {
    case a(StoreA)
    case b(StoreB)
}

enum Screen {
    case initial
    case stack
}

final class CoordinatorStore: ObservableObject {
    @Published var path: [Path] = []
    @Published var screen: Screen = .stack
    
    func showA() {
        let store = StoreA()
        path.append(.a(store))
    }
    
    func showB() {
        let store = StoreB()
        path.append(.b(store))
    }
    
    func showInitial() {
        path = []
        screen = .initial
    }
    
    func showStack() {
        screen = .stack
    }
}
struct Coordinator: View {
    @ObservedObject var store: CoordinatorStore
    
    var body: some View {
        switch store.screen {
        case .initial: initial
        case .stack: stack
        }
    }
    
    var stack: some View {
        NavigationStack(path: $store.path) {
            VStack {
                Text("Root")
            }
            .toolbar {
                Button(action: self.store.showA) {
                    Text("Push A")
                }
            }
            .navigationDestination(for: Path.self) { path in
                switch path {
                case .a(let store):
                    ViewA(store: store)
                        .toolbar {
                            Button(action: self.store.showB) {
                                Text("Push B")
                            }
                        }
                case .b(let store):
                    ViewB(store: store)
                        .toolbar {
                            Button(action: self.store.showInitial) {
                                Text("Show Initial")
                            }
                        }
                }
            }
        }
    }
    
    var initial: some View {
        VStack {
            Text("Initial")
            Button(action: store.showStack) {
                Text("Go to Stack")
            }
        }
    }
}
struct ViewA: View {
    @ObservedObject var store: StoreA
    
    var body: some View {
        Text("View A")
    }
}

final class StoreA: NSObject, ObservableObject {
    deinit {
        print("Deinit: \(String(describing: self))")
    }
}
struct ViewB: View {
    @ObservedObject var store: StoreB
    
    var body: some View {
        Text("View B")
    }
}

final class StoreB: NSObject, ObservableObject {
    deinit {
        print("Deinit: \(String(describing: self))")
    }
}

Memory Debugger Snapshot deb

hydro1337x
  • 95
  • 5

1 Answers1

2

I believe this is related but not identical to:

The Navigation api seems to be prioritizing efficiency (inits are expensive) and that SOMETHING must always be on screen. It doesn't seem to de-initialize views that have been disappeared until it has a replacement initialized and appeared.

That can lead to a memory leak (I believe) if you try to manage Navigation framework views with something outside of the Navigation framework, but it appears as long as the Navigation framework stays in charge things will be de-inted eventually, but not until the new view is init-ed.

NEWER VERSION

This version uses one coordinator, but preserves the separate enums and views for the initial vs main app pathways.


import Foundation
import SwiftUI

enum AppSceneTvTe:Hashable {
    case setup
    case app
}

enum PathTvTeOptions: Hashable {
    case optionA(OptionAVM)
    case optionB(OptionBVM)
}
struct SplashTVTEView: View {
    @StateObject var oneCoordinator = CoordinatorTvTe()
    
    var body: some View {
        NavigationStack(path: $oneCoordinator.path) {
            splash
                .navigationDestination(for: AppSceneTvTe.self) { scene in
                switch scene {
                case .app:
                    SplashTvTeAppRootView().environmentObject(oneCoordinator)
                    
                default:
                    splash
                }
                
            }
        }
    }
    
    var splash: some View {
        VStack {
            Text("Splash Page")
            Button(action:navigateToApp) {
                Text("Go App Root")
            }
        }.navigationBarBackButtonHidden(true)
    }
    
    func navigateToApp() {
        oneCoordinator.showStack()
    }
}
final class CoordinatorTvTe: ObservableObject {
    @Published var path = NavigationPath()

    
    func showA() {
        path.append(PathTvTeOptions.optionA(OptionAVM()))
    }
    
    func showB() {
        path.append(PathTvTeOptions.optionB(OptionBVM()))
    }
    
    func showInitial() {
        unwindAll()
        //path = NavigationPath()
    }
    
    func showStack() {
        path = NavigationPath()
        path.append(AppSceneTvTe.app)
    }
    
    func unwindAll() {
        while !path.isEmpty {
            path.removeLast()
        }
    }
}
struct SplashTvTeAppRootView: View {
    @EnvironmentObject var navigation: CoordinatorTvTe

    
    var body: some View {
      
            VStack {
                Text("Real Root")
            }
            .navigationBarBackButtonHidden(true)
            .toolbar {
                Button(action: self.navigation.showA) {
                    Text("Push A")
                }
            }
            .navigationDestination(for: PathTvTeOptions.self) { path in
                switch path {
                case .optionA(let vm):
                    OptionAView(vm: vm)
                        .toolbar {
                            Button(action: self.navigation.showB) {
                                Text("Push B")
                            }
                        }
                case .optionB(let vm):
                    OptionBView(vm: vm)
                        .toolbar {
                            Button(action: self.navigation.showInitial) {
                                Text("Show Initial")
                            }
                        }
                }
            }

    }
    
}


OLDER VERSION

Currently the way out of this is to keep it all in the Navigation Stack so no separate Scene vs. Path.

This code uses a boolean to control the Initial screen, but it could be one of the path options - which is the commented out code.

EDITED TO ADD: Tuns out the boolean solution gets weird when you try to make the initial state true. The Stack keeps winning, so I've taken it out.

enum Path: Hashable {
    case initial
    case a(StoreA)
    case b(StoreB)
}

final class CoordinatorStore: ObservableObject {
    @Published var path: [Path] = [.initial]
    
    func showA() {
        let store = StoreA()
        path.append(.a(store))
    }
    
    func showB() {
        let store = StoreB()
        path.append(.b(store))
    }
    
    func showInitial() {
        path = []
        path.append(.inital)

    }
    
    func showStack() { 
        path = []
    }
}
struct Coordinator: View {
    @ObservedObject var store: CoordinatorStore

    
    var body: some View {
        NavigationStack(path: $store.path) {
            VStack {
                Text("Real Root")
            }
            .toolbar {
                Button(action: self.store.showA) {
                    Text("Push A")
                }
            }
            .navigationDestination(for: Path.self) { path in
                switch path {
                case .a(let store):
                    ViewA(store: store)
                        .toolbar {
                            Button(action: self.store.showB) {
                                Text("Push B")
                            }
                        }
                case .b(let store):
                    ViewB(store: store)
                        .toolbar {
                            Button(action: self.store.showInitial) {
                                Text("Show Initial")
                            }
                        }
                case .initial:
                    initial
                }

            }
        }
    }
    
    var initial: some View {
        VStack {
            Text("Initial")
            Button(action: store.showStack) {
                Text("Go to Stack")
            }
        }.navigationBarBackButtonHidden(true)
    }
}
carlynorama
  • 166
  • 7
  • Hey thanks for your reply. I edited my question, it should have been showA -> showB -> showInitial -> Go to Stack. So to answer your last question, it will not deinit and repeating that would lead to more memory leaks. Repeating the pattern would increase the count in memory by 1 for each store which would be in the path . – hydro1337x Oct 06 '22 at 12:54
  • So I still get the ```Deinit: , Deinit: ```but I did not double check the memory. I'm about to update my answer with a "fix" for your purposes, but not a fix for the memory leak that I agree you've found! – carlynorama Oct 06 '22 at 23:19
  • Yeah, if you keep doing the flow that I described it will eventually deinit and reinit again, effectively having only 1 object in memory per store, but if I would introduce another coordinator which manages this coordinator and 1 more, it would lead to the same problem which you solved in your edit. For more complex flows we can not have everything fit into a single NavigationStack therefore your code is more of a workaround then a long term solution. We should probably file a bug to Apple for this issue. – hydro1337x Oct 07 '22 at 08:44
  • Agreed. It's really fragile. I did file a FeedBack, but I did it for an if-statement flow control. It can only help for you to do it for a case-statement! https://developer.apple.com/forums/thread/716804 Feedback FB11643551 – carlynorama Oct 07 '22 at 19:09
  • Referring to your first comment: "That can lead to a memory leak (I believe) if you try to manage Navigation framework views with something outside of the Navigation framework" . After a few days of experimenting I found a weird workaround for it. If you wrap the instance of a Coordinator which manages a NavigationStack inside a TabView or NavigationView where it is used, it will deinit itself as intended after switching from it. – hydro1337x Oct 22 '22 at 00:43
  • 1
    Yes! As long as things stay managed by a SwiftUI `Navigation` aware structure it seems to keep things managed I think the internal type is called something like `NavigationRegistrar` or something like it. – carlynorama Oct 29 '22 at 15:19