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 :(
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.