Reputation: 11812
I've encountered memory leak in SwiftUI view's models when using bindings.
final class Model: ObservableObject {
@Published var selectedValue: String?
}
struct ContentView: View {
@StateObject private var model = Model()
var body: some View {
SelectButton(selection: $model.selectedValue)
}
}
struct SelectButton: View {
@Binding var selection: String?
@State private var isPresented = false
let options: [String] = ["One", "Two"]
var body: some View {
Button { isPresented = true } label: {
Text(selection ?? "Select")
}
.sheet(isPresented: $isPresented) {
VStack {
Text("List with options")
ForEach(options) { option in Text(option) }
}
}
}
}
And now every time I push ContentView on screen and then try to select using SelectButton new selectedValue presenting sheet with list. Close this sheet by even simple pull to dismiss Then Model observable object leak. And returning from ContentView this Model isn't deallocated. If I just enter ContentView and doesn't present SelectButton sheet than there is no leak.
There also isn't leak when Sheet view doesn't use any property from SelectButton. But then this view will be useless.
I can prevent leaking by using weak binding, for instance
func weakBinding<Value: ExpressibleByNilLiteral, O: ObservableObject>(_ object: O, keyPath: ReferenceWritableKeyPath<O, Value>) -> Binding<Value> {
Binding(
get: { [weak object] in object?[keyPath: keyPath] ?? nil },
set: { [weak object] in object?[keyPath: keyPath] = $0 }
)
}
and
SelectButton(selection: weakBinding(model keyPath: \.selectedValue))
Can you have any idea how to better solve this memory leak. It occures using .sheet(), .fullScreenCover(), and I can circumvent (prevent) it using custom .model() modifier but it wraps UIKit modal presentation underneath. So it seems that there is something wrong in SwiftUI.
Maybe it is possible at least implement custom weak binding functionality in Swift that is analog to $viewModel.selectedValue
with other prefix like #
For instance I would like to have #viewModel.selectedValue
UPDATE
It seems that it is bug introduced by Apple in SwiftUI since new Xcode and iOS 17?
Memory leak on ObservableObject seems to happen each time you use sheet presentation in view refrencing this ObservableObject.
Minimal reproducible excample (you only need to push this TestView(viewModel: TestViewModel()) from root view. Then each time you open sheet and move back it causes memory leak.
final class TestViewModel: ObservableObject {
@Published var text = "Test View"
init() {
print("DEBUG: init TestViewModel")
}
deinit {
print("DEBUG: deinit TestViewModel")
}
}
struct TestView: View {
@StateObject var viewModel: TestViewModel
@State private var isPresented = false
var body: some View {
VStack {
Text(viewModel.text)
Button {
isPresented = true
} label: {
Text("Open sheet")
}
}
.sheet(isPresented: $isPresented, content: contentView)
}
private func contentView() -> some View {
VStack {
Text("Sheet content")
}
}
}
I am still testing it but it seems that memory leak happens every time you present sheet where sheet content is defined in some
var sheetContent: some View { }
or
func sheetContent() -> some View { }
as long as it seems that there is no memory leak if you directly specify sheet view inside closure like this
.sheet(isPresented; $isPresented) {
Text("This doesn't causes memory leak")
}
Unfortunatelly it work as long as you view doesn't have any reference to parent view or view model.
Update 2
Creating separate SheetView and presenting it directly doesn't leak parent view's view model.
struct SheetView: View {
var body: some View {
Text("Sheet content")
}
}
final class TestViewModel: ObservableObject {
@Published var text = "Test View"
init() {
print("DEBUG: init TestViewModel")
}
deinit {
print("DEBUG: deinit TestViewModel")
}
}
struct TestView: View {
@StateObject var viewModel: TestViewModel
@State private var isPresented = false
var body: some View {
VStack {
Text(viewModel.text)
Button {
isPresented = true
} label: {
Text("Open sheet")
}
}
.sheet(isPresented: $isPresented, content: {
SheetView()
})
}
private func contentView() -> some View {
VStack {
Text("Sheet content")
}
}
}
Update 3
Usage of environment object like suggested by malhal
also causes memory leak
Here is my tested code:
struct SheetView: View {
@EnvironmentObject var viewModel: TestViewModel
var body: some View {
Text("Sheet content")
}
}
final class TestViewModel: ObservableObject {
@Published var text = "Test View"
init() {
print("DEBUG: init TestViewModel")
}
deinit {
print("DEBUG: deinit TestViewModel")
}
}
struct TestView: View {
@StateObject var viewModel: TestViewModel
@State private var isPresented = false
@State private var flag = false
private let variable = "Test"
var body: some View {
VStack {
Text(viewModel.text)
Button {
isPresented = true
} label: {
Text("Open sheet")
}
}
.sheet(isPresented: $isPresented, content: {
SheetView()
.environmentObject(viewModel)
})
}
private func contentView() -> some View {
VStack {
Text("Sheet content")
}
}
}
Upvotes: 2
Views: 730
Reputation: 30736
I changed my answer so you can ignore comments from Oct 2023 and earlier.
Here is a simpler example of the leak. However the object is deinit
on the next update (e.g. incrementing the counter) so it's possible this is just how SwiftUI works?
struct ContentView: View {
@State var show = false
@State var counter = 0
var body: some View {
Button("Counter \(counter)") { // can deinit the object
counter += 1
}
Button(show ? "Hide without leak" : "Show") {
show.toggle()
}
if show {
ChildView(show: $show)
}
}
}
struct ChildView: View {
@StateObject var object = MyObject()
@Binding var show: Bool
var body: some View {
Button("Hide with leak") {
show.toggle()
}
}
}
class MyObject: ObservableObject {
init() { print("init") }
deinit { print("deinit") } // not called when there is a leak
}
Upvotes: -2