swiftswiftuiobservation-framework

Why is deinit not called after removing an instance from an array using SwiftUI Observation framework?


Title: Why is deinit not called after removing an instance from an array using SwiftUI Observation framework?

I'm new to SwiftUI and currently experimenting with the new Observation framework. I have a class called Streamer marked with @Observable, and I'm storing instances of it in an array inside a modelManager class (also marked as @Observable).

I'm updating the UI with SwiftUI, and when I remove a Streamer instance from the array, I expect its deinit to be called — but it never is. The object appears to be removed from the array, yet it’s clearly still retained somewhere.

struct MainUI : View {
    
    @State private var streamer = ""
    @State private var model = modelManager();
                           
    
    var body: some View {
        let _ = MainUI._printChanges()
        
        TextField("remove name", text: $streamer)
        Button("refresh") {
            if (!model.allStreamers.isEmpty) {
                model.removeStreamer(named: streamer)
            }
            
            print("Remaining: \(model.allStreamers.count)")
            
            for streamer in model.allStreamers {
                print("\(streamer.name) \(streamer.state) \(streamer.title)")
            }
        }
        
        Button("add streamer") {
            model.addStreamer(UUID().uuidString.prefix(5).description)
        }
        
        List(model.allStreamers, id: \.name) { streamer in
            ////
            HStack(spacing:10) {
                
                Circle()
                    .fill( (streamer.state == .online ) ? Color.green : Color.red)
                    .frame(width: 12, height: 12)
                
                Text(streamer.name)
                    .bold(true)
                
                Text(streamer.title)
                
                Spacer()
                
                Text(streamer.state.rawValue)
                    .foregroundStyle((streamer.state == .online ) ? Color.green : Color.red)
                    .animation(.bouncy(duration: 1.5), value: streamer.state)
                
                
                
                
            }.padding(10)
            
            Button("change status") {
                streamer.state = streamerState.allCases.randomElement()!
                streamer.title = "title\(Int.random(in: 1...10))"
                print(streamer.state.rawValue)
                
            }
            ///
        }
        Spacer()
        
            
    }
        
}


@Observable
class Streamer {

    let name: String
    var state: streamerState
    var title: String
    
    
    init(name: String, state: streamerState, title: String) {
        self.name = name
        self.state = state
        self.title = title
    }
    
    
    deinit {
        print("\(self.name) has been deinit")
    }
    
}

@Observable
class modelManager {
    
    var allStreamers = [Streamer]()
    
    func addStreamer(_ newStreamer: String) {
        
        allStreamers.append(Streamer(name: newStreamer, state: .offline, title: "my title is \(Int.random(in: 1...100))"))
        
    }
    
    func removeStreamer(named nameToRemove: String) {
                
        if let index = allStreamers.firstIndex(where: { $0.name == nameToRemove }) {
            allStreamers.remove(at: index)
        }
        
    }
    
}

Despite removing the instance from the array, deinit is never called. I'm only using the Observation framework.

Why isn't the Streamer instance being deallocated after removal? What am I missing when using the Observation framework?

Thank you in advance!


Solution

  • This is due to the lazy container (List) caching off-screen views and deferring the teardown/deinit until the respective view is unmounted (you scrolls far enough off-screen, you navigate away, or you unmount the whole list).

    You can verify this by switching from a List to a non-lazy container, like a VStack, and noticing the deinit will be triggered right away. On the other hand, a LazyVStack will act just like a List.

    Another approach is to setup a flow where you navigate to the List and then navigate away from it, moment at which the deinit will be triggered. The example below shows this scenario.

    And yes, forcing a weak capture like @Sweeper showed will also do the trick.

    To see the behavior in the below code:

    1. Tap to go to StreamerList

    2. Add some Streamers.

    3. Delete at will (deinit will not be called)

    4. Tap back to return to the main screen (deinit will be called as soon as List disappears).

    import SwiftUI
    
    struct StreamerMainUI: View {
        
        //Body
        var body: some View {
            NavigationStack {
                NavigationLink(destination: StreamerList()){
                    Text("Go to Streamer List")
                }
            }
            
        }
    }
    
    struct StreamerList : View {
        
        //State values
        @State private var streamer = ""
        @State private var model = StreamerManager()
        
        //Body
        var body: some View {
            let _ = Self._printChanges()
            
            Button("Add Streamer") {
                model.addStreamer(UUID().uuidString.prefix(5).description)
            }
            // 
            Button("Refresh") {
                
                print("Remaining: \(model.allStreamers.count)")
                
                for streamer in model.allStreamers {
                    print("\(streamer.name) \(streamer.state) \(streamer.title)")
                }
            }
            
            List {
                ForEach(model.allStreamers, id: \.name) { streamer in
                        
                        VStack {
                            HStack(spacing:10) {
                                
                                Circle()
                                    .fill( (streamer.state == .online ) ? Color.green : Color.red)
                                    .frame(width: 12, height: 12)
                                
                                Text(streamer.name)
                                    .bold(true)
                                
                                Text(streamer.title)
                                
                                Spacer()
                                
                                Text(streamer.state.rawValue)
                                    .foregroundStyle((streamer.state == .online ) ? Color.green : Color.red)
                                // .animation(.bouncy(duration: 1.5), value: streamer.state)
                            }
                            .padding(10)
                            
                            HStack {
                                Button("change status") {
                                    streamer.state = streamer.state == .online ? .offline : .online
                                    streamer.title = "title\(Int.random(in: 1...10))"
                                    print(streamer.state.rawValue)
                                    
                                }
                                
                                Spacer()
                                
                                Button(role: .destructive) {
                                    let nameToRemove = streamer.name
                                    Task {
                                        await MainActor.run {
                                            model.allStreamers.removeAll { $0.name == nameToRemove }
                                        }
                                        
                                    }
                                } label: {
                                    Image(systemName: "trash")
                                }
                                .buttonStyle(.borderless)
                            }
                        }
                }
            }
            Spacer()
        }
    }
    
    enum StreamerState: String, CaseIterable {
        case online, offline
    }
    
    @Observable
    class Streamer {
    
        let name: String
        var state: StreamerState
        var title: String
        
        init(name: String, state: StreamerState, title: String) {
            self.name = name
            self.state = state
            self.title = title
        }
        
        deinit {
            print("\(self.name) has been deinit")
        }
        
    }
    
    @Observable
    class StreamerManager {
        
        var allStreamers = [Streamer]()
        
        func addStreamer(_ newStreamer: String) {
            
            allStreamers.append(Streamer(name: newStreamer, state: .offline, title: "my title is \(Int.random(in: 1...100))"))
            
        }
        
        func removeStreamer(named nameToRemove: String) {
            if let index = allStreamers.firstIndex(where: { $0.name == nameToRemove }) {
                allStreamers.remove(at: index)
            }
            
        }
        
    }
    
    
    #Preview {
        StreamerMainUI()
    }