AnyCancellable.store(in:) with Combine

Viewed 3625

Let’s say you are using the built-in .store(in:) method on AnyCancellable like so:

private var subscriptions = Set<AnyCancellable>()

let newPhotos = photos.selectedPhotos
newPhotos
  .map { [unowned self] newImage in
    return self.images.value + [newImage]
  }
  .assign(to: \.value, on: images)
  .store(in: &subscriptions)

If you have an app that does this a lot - are these removed when the publishers complete?

Also, If i decide to go with this approach instead:

private var newPhotosSubscription: AnyCancellable?

self.newPhotosSubscription = newPhotos
  .map { [unowned self] newImage in
    self.images.value + [newImage]
  }
  .assign(to: \.value, on: images)

Everytime I call the method again, it override the AnyCancellable, what happens to the previous one? Does it still complete before being deallocated?

3 Answers

Dávid Pásztor's solution is close, but it has a race condition. Specifically, suppose the newPhotos publisher completes synchronously, before the sink method returns. Some publishers operate this way. Just and Result.Publisher both do, among others.

In that case, the completion block runs before sink returns. Then, sink returns an AnyCancellable which is stored in newPhotosSubscription. But the subscription has already completed, so newPhotosSubscription will never be set back to nil.

So for example if you use a URLSession.DataTaskPublisher in live code but substitute a Just publisher in some test cases, the tests could trigger the race condition.

Here's one way to fix this: keep track of whether the subscription has completed. Check it after sink returns, before setting newPhotosSubscription.

private var ticket: AnyCancellable? = nil

if ticket == nil {
    var didComplete = false
    let newTicket = newPhotos
        .sink(
            receiveValue: { [weak self] in
                self?.images.value.append($0)
            },
            receiveCompletion: { [weak self] _ in
                didComplete = true
                self?.ticket = nil
            }
        )
    if !didComplete {
        ticket = newTicket
    }
}

Everytime I call the method again, it override the AnyCancellable, what happens to the previous one? Does it still complete before being deallocated?

The previous one, if any, is cancelled, because the only reference to the old AnyCancellable is destroyed and so the AnyCancellable is destroyed. When an AnyCancellable is destroyed, it cancels itself (if not already cancelled).

A Set<T> doesn't know anything about T, and certainly doesn't know that it is a Combine AnyCancellable. The documentation for store(in:) does not say that it will monitor the subscription and remove it.

So, nothing automatic is going to happen when it completes, but you could remove it from the Set yourself.

If you let a AnyCancellable get dealloced (there is no GC in Swift) by dropping all references to it, you have cancelled the subscription. It will not wait around for a completion.

It is possible for a specific type of cancellable (for example, one you make) to decide that it always completes and to do so right before deallocation.

To achieve your goal of only creating a new subscription if the previous one has already finished, while also making sure that the previous subscription is thrown away as soon as it is finished, you're best off storing a reference to your AnyCancellable directly, not in a Set.

You should check if you already have a subscription and only start one if you don't, then instead of the assign, use a sink and in case you receive a completion, set the AnyCancellable reference to nil. This will ensure that once your current subscription finished, you can start a new one.

private var newPhotosSubscription: AnyCancellable?

if newPhotosSubscription == nil {
    newPhotosSubscription = newPhotos
        .map { [unowned self] newImage in
            self.images.value + [newImage]
        }
        .sink(receiveValue: { [weak self] value in
            self.images.value = value
        }, receiveCompletion: { [weak self] _ in
            newPhotosSubscription = nil
        })
    }
}
Related