Michał Ziobro
Michał Ziobro

Reputation: 11812

Weak binding to model properties from subview

I've encountered memory leak in SwiftUI view's models when using bindings.

  1. I've created ObservableObject model like
final class Model: ObservableObject { 
   @Published var selectedValue: String?
}
  1. I've created ContentView using this Model
struct ContentView: View { 
  @StateObject private var model = Model() 

  var body: some View { 
     SelectButton(selection: $model.selectedValue) 
  } 
}
  1. I've implemented SelectButton this way
 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

Answers (1)

malhal
malhal

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

Related Questions