Search code examples
iosswiftuimvvmswiftui-listdeinit

SwiftUI list row view/view model not deinitialized when row is deleted


I'm having trouble to understand how SwiftUI manages List row views (and their associated view model) when a row is added and deleted from a list.

In the little example below, the view model deinit() is not executed when the row is manually deleted from the list.

struct ContentView: View {

    @State var list: [String] = [
        "A", "B", "C"
    ]

    var body: some View {
        List {
            ForEach(list, id: \.self) { item in
                RowView(item: item)
            }
            .onDelete(perform: { indexSet in
                indexSet.forEach { list.remove(at: $0) }
            })
        }
    }
}

struct RowView: View {
    @State var vm: RowViewModel

    init(item: String) {
        vm = RowViewModel(item: item)
    }

    var body: some View {
        Text(vm.item)
    }
}

@Observable
class RowViewModel {
    public let item: String

    init(item: String) {
        print("RowVM:Init \(item)")
        self.item = item
    }

    deinit {
        print("RowVM:Deinit \(item)")
    }
}

When I launch the app, I see:

RowVM:Init A
RowVM:Init A
RowVM:Deinit A
RowVM:Init B
RowVM:Init B
RowVM:Deinit B
RowVM:Init C
RowVM:Init C
RowVM:Deinit C

Seems like SwiftUI creates 2 new row views and their view models and then remove one. I would have expected 1 only.

Then when I slide row C to delete it, I observe:

RowVM:Init A
RowVM:Init B

New row views A and B and their VMs are created again without deinit'ing the previous ones, but worse, I don't see:

RowVM:Deinit C

I have to stop the app to see the full deinit() series:

RowVM:Deinit C
RowVM:Deinit B
RowVM:Deinit B
RowVM:Deinit A
RowVM:Deinit A

This is weird and annoying :(


Solution

  • Your confusion is justified and can be attributed to several causes.

    1. ForEach may generate views unnecessarily multiple times

    Seems like SwiftUI creates 2 new row views and their view models and then remove one. I would have expected 1 only.

    When using ForEach, it may happen that each of the views is created twice initially with the unnecessary view then being immediately discarded.

    You are not the first person to notice this behavior! See here or here, for example.

    As far as I know, there is no direct solution for this yet and you have to accept this behavior until Apple changes something about it.

    In general, this should not be a problem, as the initialization of SwiftUI views must always be lightweight. If problems do occur, this is usually a sign that you are doing something in an init method that you should not be doing like creating a heavy view model instance with each call. More on this below.

    2. Using an @Observable model as a @State property

    One big difference between State and StateObject is that StateObject offers an init method whose wrapped value is only accessed when it needs to be created, that's why it's an autoclosure.

    If RowViewModel were an ObservableObject, RowView would look like this:

    struct RowView: View {
        @StateObject private var vm: RowViewModel
    
        var body: some View {
            Text(vm.item)
        }
    
        init(item: String) {
            _vm = .init(wrappedValue: RowViewModel(item: item))
        }
    }
    

    With this variant, the problem does not occur that a RowViewModel is created unnecessarily with every call of RowView.init. SwiftUI will only create RowViewModel when a new state needs to be created by calling the wrappedValue closure.

    Considering the problem of ForEach described in 1., this still leads to unnecessary views being created, but no unnecessary RowViewModel instances are created anymore.

    However, if you use the Observable macro, the situation changes because the State.init method does not use an autoclosure and thus creates a new instance of the view model each time it is called.

    Apple suggests in the developer documentation to make the corresponding State property optional and to initialize it via a task call

    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

    Applied to your case, RowView could look like this:

    struct RowView: View {
        @State private var vm: RowViewModel?
        private let item: () -> String
    
        var body: some View {
            VStack {
                if let item = vm?.item {
                    Text(item)
                }
            }
            .task {
                vm = RowViewModel(item: item())
            }
        }
    
        init(item: @autoclosure @escaping () -> String) {
            self.item = item
        }
    }
    

    This is not a real-world example, of course, because RowViewModel doesn't really do anything heavyweight in this case.

    But it shows you which pattern you can use for your specific use case.

    3. Release of SwiftUI State resources

    I have to stop the app to see the full deinit() series

    This is due to the internal management of State resources. The exact point at which SwiftUI releases them is an internal implementation detail that should not be relied upon.

    Generally, the deinit of State resources happens at the latest when the parent view disappears completely.

    Also note that there have been memory leaks in iOS in the past when using the @Observable macro for a view model as a State. But as far as I know, these should be fixed by now with the latest iOS versions.