swiftuicombineretain-cycle

Why Is There No Retain Cycle In ReceiveValue Block Combine Subscription


I'm determined to fully understand why this isn't causing a reference cycle. And in general what is happening at each stage of memory management here.

I have the following setup:

struct PresenterView: View {
    @State private var isPresented = false
    var body: some View {
        Text("Show")
            .sheet(isPresented: $isPresented) {
                DataList()
            }
            .onTapGesture {
                isPresented = true
            }
    }
}

struct DataList: View {

    @StateObject private var viewModel = DataListViewModel()
    
    var body: some View {
        NavigationView {
            List(viewModel.itemViewModels, id: \.self) { itemViewModel in
                Text(itemViewModel.displayText)
            }.onAppear {
                viewModel.fetchData()
            }.navigationBarTitle("Items")
        }
    }
}

class DataListViewModel: ObservableObject {
    
    private let webService = WebService()

    @Published var itemViewModels = [ItemViewModel]()
    
    private var cancellable: AnyCancellable?
    
    func fetchData() {
        cancellable = webService.fetchData().sink(receiveCompletion: { _ in
            //...
        }, receiveValue: { dataContainer in
            self.itemViewModels = dataContainer.data.items.map { ItemViewModel($0) }
        })
    }
    
    deinit {
        print("deinit")
    }
    
}

final class WebService {
    
    var components: URLComponents {
        //...
        return components
    }

    func fetchData() -> AnyPublisher<DataContainer, Error> {
        return URLSession.shared.dataTaskPublisher(for: components.url!)
            .map { $0.data }
            .decode(type: DataContainer.self, decoder: JSONDecoder())
            .receive(on: DispatchQueue.main)
            .eraseToAnyPublisher()
    }
}

So when I create a PresenterView and then dismiss it I get a successful deinit print.

However I don't understand why they is no reference cycle here. DataListViewModel has cancellables which has a subscription that captures self. So DataListViewModel -> subscription and subscription -> DataListViewModel. How can deinit be triggered? In general is there a good approach to understanding whether there is a retain cycle in these kinds of situation?


Solution

  • The closure, as you expected, does retain a strong reference to self. The closure itself is maintained by the Sink subscriber.

    If nothing else happens, this is a memory leak because the subscriber is never cancelled, because AnyCancellable is never released, because self never de-inits, and self never de-inits because the subscriber is holding a reference it.

    However, in your case, the publisher completes, and that's another way for the subscriber to release its closures. So, self is only released after the pipeline completes.

    To illustrate, we can use a PassthroughSubject to explicitly send a completion:

    class Foo {
       var c: AnyCancellable? = nil
    
       func fetch() {
          let subject = PassthroughSubject<String, Never>()
    
          c = subject.sink {
             self.c // capture self
             print($0)
          }
    
          subject.send("sync")
    
          DispatchQueue.main.async { subject.send("async") }
    
          DispatchQueue.main.asyncAfter(deadline: .now() + 2) { 
             subject.send("async 2 sec")
             subject.send(completion: .finished)
          }
       }
    
       deinit { print("deinit") }
    }
    
    
    do {
       Foo().fetch()
    }
    

    Because self is captured, it's not released until after a completion is sent 2 seconds later:

    sync
    async
    async 2 sec
    deinit 
    

    If you comment out the line subject.send(completion: .finished), there will not be a deinit:

    sync
    async
    async 2 sec
    

    If you use [weak self] in the closure, the pipeline would cancel:

    sync
    deinit