This is Understanding Combine, written by Matt Neuburg. Corrections and suggestions are greatly appreciated (you can comment here). So are donations; please consider keeping me going by funding this work at http://www.paypal.me/mattneub. Or buy my books: the current (and final) editions are iOS 15 Programming Fundamentals with Swift and Programming iOS 14. Thank you!


AnyCancellable

The .sink command makes a Subscribers.Sink object, and the .assign command makes a Subscribers.Assign object. However, both .sink and .assign wrap that object up in an AnyCancellable instance, returning that instance.

I talked earlier about what AnyCancellable is. It’s a class that acts as a type eraser so that you don’t have to deal with the nitty-gritty of what a Sink object or an Assign object really is; in effect, it provides a common type that could be a Sink or an Assign. Both Sink and Assign conform to the Cancellable protocol, meaning that they have a cancel method. AnyCancellable, too, has a cancel method, because it, too, conforms to the Cancellable protocol; AnyCancellable’s cancel calls cancel on the wrapped Cancellable subscriber, giving us the ability to cancel the wrapped subscriber (and thus the entire pipeline) by way of the AnyCancellable wrapper.

Moreover, AnyCancellable has the remarkable property that it automatically calls cancel() on its wrapped subscriber when it itself goes out of existence. This means that the whole pipeline right back up to the publisher is cancelled when the wrapper is released. In effect, AnyCancellable gives us memory management for the entire pipeline, along with coherent messaging to the publisher that it no longer needs to produce any values.

Conversely, you need to retain the AnyCancellable object produced by .sink or .assign if you don’t want to risk having the pipeline cancel itself prematurely. Premature cancellation is just what will happen if we write a pipeline like this, and stop:

let url = URL(string:"https://www.apeth.com/pep/manny.jpg")!
URLSession.shared.dataTaskPublisher(for: url)
    .map {$0.data}
    .replaceError(with: Data())
    .compactMap { UIImage(data:$0) }
    .receive(on: DispatchQueue.main)
    .assign(to: \.image, on: self.iv)

When the app runs, no image is assigned to our image view, and we get an error message in the console:

Error Domain=NSURLErrorDomain Code=-999 "cancelled"

That’s because our AnyCancellable (which we didn’t even bother to capture) went out of existence and sent a cancel() call to the data task publisher before it had a chance to do any networking.

That is why, as I’ve been at pains to demonstrate already, you should always follow a .sink or .assign call with a call to .store(in:), so as to retain the AnyCancellable. .store(in:) is an AnyCancellable method (because AnyCancellable conforms to the Cancellable protocol). The parameter can be the address of a RangeReplaceableCollection, such as an Array, or of a Set. Typically, you will have prepared this as an instance property beforehand, thus guaranteeing that the subscriber will live as long as the owner of the instance property (typically a view controller).

So, real code would look like this:

let url = URL(string:"https://www.apeth.com/pep/manny.jpg")!
URLSession.shared.dataTaskPublisher(for: url)
    .map {$0.data}
    .replaceError(with: Data())
    .compactMap { UIImage(data:$0) }
    .receive(on: DispatchQueue.main)
    .assign(to: \.image, on: self.iv)
    .store(in:&self.storage)

In that code, self.storage could be declared like this (this is what I usually do):

var storage = Set<AnyCancellable>()

But alternatively it could be declared, for instance, like this:

var storage = [AnyCancellable]()

Just as an exercise, let’s demonstrate to ourselves how the .assign operator wraps the Assign object in an AnyCancellable by doing the wrapping ourselves. (The .sink operator is obviously completely parallel.) First, I’ll create the publisher end of the pipeline.

let url = URL(string:"https://www.apeth.com/pep/manny.jpg")!
let pub : AnyPublisher<UIImage?,Never> =
    URLSession.shared.dataTaskPublisher(for: url)
        .map {$0.data}
        .replaceError(with: Data())
        .compactMap { UIImage(data:$0) }
        .receive(on: DispatchQueue.main)
        .eraseToAnyPublisher()

(I’ll talk about eraseToAnyPublisher later. It has the advantage of type-erasing the pipeline so that we don’t have to deal with its real type, which is often quite complicated and ugly.)

Now I’ll manually create an Assign object, subscribe it to my publisher, and wrap it in an AnyCancellable. You may imagine that that is just the sort of thing the .assign operator really does behind the scenes! Finally, I’ll store the AnyCancellable in an instance property as usual, and the entire pipeline is now built and operates correctly:

let assign = Subscribers.Assign(object: self.iv, keyPath: \UIImageView.image)
pub.subscribe(assign)
let any = AnyCancellable(assign)
any.store(in:&self.storage)

Having said all that, there are situations where this entire architecture may seem too heavyweight. Indeed, the situation I’ve just described is such a situation! We know for a fact that a data task publisher will publish just once and then stop. It doesn’t need to stick around in perpetuity, or even as long as the surrounding object; it just needs to stick around long enough for it to publish, that one time. So next I’ll describe an alternative architecture for what we may call a one-shot subscriber.


Table of Contents