2

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?

jeh
  • 2,373
  • 4
  • 23
  • 38
  • When does deinit happen? My guess is that it happens after the data has been receive (or otherwise, `dataTaskPublisher` is finished. So, the sink release its resources (e.g. the closures), so there's no more references that keep an instance of `DataListViewModel` in memory – New Dev Oct 12 '20 at 15:39
  • Yes when data has been received. When you say releases resources it will store the closures weakly and set them to nil? Maybe this is outside of Combine and more general memory management. Would you be able to go into a bit more detail about that? Would it be that sink has reference to closure(s) which capture `DataListViewModel` - when the closure is set to nil/released it no longer points to `DataListViewModel` and that allows it to be deinitialised? I'm trying to picture each stage of the process. Thanks! – jeh Oct 12 '20 at 15:45
  • Yes, the `.sink` subscriber holds references to the closures, until it receives a cancel or a completion. Then it sets them to `nil`. By way of testing, use `[weak self]` in the sink closure, and what you should see that `deinit` would happen before the data is received – New Dev Oct 12 '20 at 15:51

1 Answers1

6

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
New Dev
  • 48,427
  • 12
  • 87
  • 129