Andrea
Andrea

Reputation: 26385

SwiftUI reference cycle, StateObject leaked

I've found this weird behavior and I cannot explain myself why is happening and how can I get rid of it.
Consider this code I tried to make a smaller MVP:

enum Sheet: Int, Identifiable {
    var id: Int {
        self.rawValue
    }
    
    case detail
    case tutorial
}

struct HostingView: View {
    
    @State var isPresenting: Bool = false
    
    var body: some View {
        Button("Press to present") {
            isPresenting.toggle()
        }
        .fullScreenCover(isPresented: $isPresenting) {
            MyModelView()
        }
    }
}

struct MyModelView: View {
    @StateObject var model = Model()
    @Environment(\.dismiss) private var dismiss
    
    @State private var presentedSheet: Sheet?
    @State private var currentIndex: Int = 0
    @State private var messageList = [Message]()


    var body: some View {
        VStack {
            ForEach(messageList) { message in
                MessageView(with:message)
            }
            Button("Dismiss") {
                dismiss()
            }
        }
        .task {
            await model.loadData()
            self.messageList = buildMessages(with: model.data)
            
        }.sheet(item: $presentedSheet) { sheet in
            switch sheet {
            case .detail:
                Text("Detail")
            case .tutorial:
                Text("Tutorial")
            }
        }
    }
    
    private func buildMessages(with data: [Int]) -> [Message] {
        [
            Message(text: "Interesting message \(data[0])"),
            Message(text: "Interesting message with action \(data[1])", buttonAction: {
                presentedSheet = .tutorial // HERE THE REF CYCLE
            })
        ]
    }
}


struct Message: Identifiable {
    var id = UUID()
    
    let text: String
    let buttonAction: (() -> Void)?
    
    init(text: String,
         buttonAction: (() -> Void)? = nil) {
        self.text = text
        self.buttonAction = buttonAction
    }
}

struct MessageView: View {
    let text: String
    let buttonAction: (() -> Void)?
    
    init(with message: Message) {
        self.text = message.text
        self.buttonAction = message.buttonAction
    }
    
    var body: some View {
        VStack(alignment: .leading) {
            Text(text)
            if let buttonAction {
                Button(action: {
                    buttonAction()
                }, label: {
                    Text("Press Here")
                })
            }
        }
        .frame(maxWidth: .infinity,
               alignment: .leading)
        .background(Color.red)

    }
}

class Model: ObservableObject {
    @Published var data: [Int] = []
    
    func loadData() async {
        try? await Task.sleep(nanoseconds: 1_000_000)
        await MainActor.run {
            self.data = [1,2,3,4]
        }
    }
}

I have a view that is presented modally fullscreen, this view instantiate a StateObject, using a .task modifier once it appears it starts to download data asking to the model to fetch them.
After the model has download those info it builds some Message model that have to be displayed in the view.
Some of those messages have a callback for a button buttonAction, that pressed have to display a specific sheet.
The callback is inject by directly accessing the @State variable presentedSheet to trigger the correct action. This message array is then looped in a ForEach to build MessageView.
Looking at the memory graph I saw that the Model instance is not deallocates after dismissing the view. A@StateObject should be deallocated when the view that creates it disappear, but is not happening. The Model instance is retained by the Message buttonAction closure with presentedSheet = .tutorial and never released.
if I comment that part of code I can see that the Model is dealloced correctly.
Since they are all structs I cannot weaken the reference, second why is happening should be self eventually passed by copy? How can I get rid of this ref cycle?

Upvotes: 0

Views: 58

Answers (1)

Currently you create a new model, @StateObject var model = Model() that has no relations to any other, every time you display the MyModelView().

Try this approach using a single source of truth for your data model, declared in the HostingView and passed to other views using .environmentObject(model) as shown in the example code:

 struct HostingView: View {
     @StateObject private var model = Model()  // <--- here
     @State private var isPresenting: Bool = false
     
     var body: some View {
         Button("Press to present") {
             isPresenting.toggle()
         }
         .fullScreenCover(isPresented: $isPresenting) {
             MyModelView()
                 .environmentObject(model) // <--- here
         }
     }
 }

 struct MyModelView: View {
     @EnvironmentObject var model: Model  // <--- here
 
     // .....
 

See also Monitoring data it gives you some good examples of how to manage data in your app.

Upvotes: 0

Related Questions