Mastering Publisher Subscriptions in Combine: A Comprehensive Guide
It’s time to review a little:
Subscribers will receive values from Publisher, all subscribers must inherit the Subscriber protocol.
public protocol Subscriber {
associatedtype Input
associatedtype Failure: Error
func receive(subscription: Subscription)
func receive(_ input: Self.Input) -> Subscribers.Demand
func receive(completion: Subscribers.Completion<Self.Failure>)
}
Input
: Type of data provided, it has to be matched theOutput
ofPublisher
Failure
: Type of data for error
3 important methods:
receive(subscription:)
when receiving subscription from Publisherreceive(input:)
when receiving value from Publisher and we will adjust the request for further data through Demand.receive( completion:)
when receiving completion from the publisher.
You can subscribe to a Subscriber
by using subscribe
of Publisher
(if you haven’t read my article about Publisher in Combine yet, you can check this link)
publisher.subscribe(subscriber)
An important note that only when a subscriber connects does the publisher emit data
Subscriber
has the ability to self-destruction when subscription is disconnected. That cancellation helps memory automatically release unnecessary objects. We have 2 types of cancellation:
Auto cancel by
AnyCancellable
when we create subscribers bysink
orassign
Manually cancel by method
cancel()
of Subscriber
How to create Subscriber
Assign
Assigns each element from a publisher to a property on an object.
func assign<Root>(
to keyPath: ReferenceWritableKeyPath<Root, Self.Output>,
on object: Root
) -> AnyCancellable
Use the assign(to:on:)
subscriber when you want to set a given property each time a publisher produces a value.
import Combine
class MyModel {
@Published var value: Int = 0
}
let model = MyModel()
var cancellable = Set<AnyCancellable>()
Just(10)
.assign(to: \.value, on: model)
.store(in: &cancellable)
print(model.value) // Output: 10
You can use Subscribers.Assign
to get the same approach, but note that the instance created by this operator maintains a strong reference to object, and sets it tonil
when the upstream publisher completes (either normally or with an error), you can see the below example and try it yourself in Playground
import Combine
class ViewModel {
var name: String = ""
private var cancellable: Set<AnyCancellable> = Set()
deinit {
print("deinit")
}
init(publisher: CurrentValueSubject<String, Never>) {
publisher.assign(to: \.name, on: self).store(in: &cancellable)
}
}
let publisher = CurrentValueSubject<String, Never>("Test")
var viewModel: ViewModel? = ViewModel(publisher: publisher)
viewModel = nil // the ViewModel object can't be released because upstream publisher hasn't finished
publisher.send(completion: .finished) // finish the publisher, now the ViewModel object is completely released
I also find out an extension at forums.swift.org can replace the primary assign
method to properly prevent the memory leak, that’s awesome!
extension Publisher where Self.Failure == Never {
public func assignNoRetain<Root>(to keyPath: ReferenceWritableKeyPath<Root, Self.Output>, on object: Root) -> AnyCancellable where Root: AnyObject {
sink { [weak object] (value) in
object?[keyPath: keyPath] = value
}
}
}
Sink
Attaches a subscriber with closure-based behavior.
Use sink(receiveCompletion:receiveValue:)
to observe values received by the publisher and process them using a closure you specify.
let publisher = Just("Hello, Combine!")
let cancellable = publisher
.sink(receiveCompletion: { completion in
switch completion {
case .finished:
print("Publisher completed successfully.")
case .failure(let error):
print("Publisher completed with error: \(error)")
}
}, receiveValue: { value in
print("Received value: \(value)")
})
The
receiveValue:
closure is called with the value emitted by the publisher, and thereceiveCompletion:
closure is called when the publisher completes.The
receiveCompletion:
closure takes aSubscribers.Completion
parameter, which is an enum that can be either.finished
or.failure(let error)
. In this case, sinceJust
cannot fail, there’s no error to handle, but in a real-world scenario, you would handle potential errors in the.failure
case.
Please note that
sink(receiveCompletion:receiveValue:)
returns anAnyCancellable
instance, which is stored inSet<AnyCancellable>
to keep the subscription alive. If you don’t store this instance, the subscription is canceled immediately
Custom Subscriber
Implementing custom subscribers in Combine allows us to define our own logic for handling values received from publishers.
Step 1: Define Your Custom Subscriber
This class should conform to the Subscriber
protocol. The Subscriber
protocol requires you to specify types for Input
and Failure
.
class CustomSubscriber: Subscriber {
typealias Input = Int
typealias Failure = Never
Step 2: Implement thereceive(subscription:)
Method
This method is called once when the publisher is connected to the subscriber. Here, you can request a certain number of values from the publisher.
func receive(subscription: Subscription) {
subscription.request(.max(1))
}
Step 3: Implement thereceive(_:)
Method
This method is called each time a new value is delivered by the publisher. Here, you can handle the received value.
func receive(_ input: Int) -> Subscribers.Demand {
print("Value:", input)
return .none
}
This mode returns Demand
means that every time receives the value, Subscriber will adjust its request through Demand
. With returning:
none
: do not get any more elementsunlimited
: take allmax(n)
: get the next n elements
Step 4: Implement thereceive(completion:)
Method
This method is called when the publisher completes, either successfully or with an error. Here, you can handle the completion event.
func receive(completion: Subscribers.Completion<Never>) {
print("Completion: \(completion)")
}
}
Step 5: Use Your Custom Subscriber
Finally, you can use your custom subscriber with a publisher. The subscribe(_:)
method is called on the publisher with the subscriber as an argument, which connects the publisher to the subscriber.
let publisher = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].publisher
let subscriber = CustomSubscriber()
publisher.subscribe(subscriber)
You can also get the full code of CustomSubscriber here
AnyCancellable and memory management
AnyCancellable
is a type-erasing cancellable object that executes a provided closure when canceled. It’s used to manage the lifetime of a subscription
Here are some key points about AnyCancellable
:
- Cancellation token: Subscriber implementations can use
AnyCancellable
to provide a “cancellation token” that makes it possible for a caller to cancel a publisher. This means you can stop the subscription whenever you want by calling the cancel() method.
let publisher = Just("Hello, Combine!")
let cancellable: AnyCancellable = publisher
.sink { value in
print(value)
}
// When you want to cancel the subscription
cancellable.cancel()
- Automatic cancellation: An
AnyCancellable
instance automatically callscancel()
when de-initialized. This means if theAnyCancellable
instance is deallocated, the subscription will be canceled automatically.
class MyClass {
var cancellable: AnyCancellable? = nil
init() {
cancellable = Just("Hello, Combine!")
.sink { value in
print(value)
}
}
}
var myClass: MyClass? = MyClass() // prints "Hello, Combine!"
myClass = nil // `cancellable` is deallocated, so the subscription is cancelled
Storing AnyCancellable instances: You can store
AnyCancellable
instances in a collection or a set using thestore(in:)
method. This is useful when you have multiple subscriptions and want to manage them together.Memory Management: When you store
AnyCancellable
instances in a set (or any other collection), you’re essentially creating a strong reference to those instances. As long as there’s a strong reference to anAnyCancellable
, the subscription it represents stays alive. However, if you remove all references to anAnyCancellable
, then thoseAnyCancellable
instances get deallocated, and the subscriptions they represent are automatically canceled.
class MyClass {
var cancellables = Set<AnyCancellable>()
init() {
Just("Hello, Combine!")
.sink { value in
print(value)
}
.store(in: &cancellables)
}
}
var myClass: MyClass? = MyClass() // prints "Hello, Combine!"
myClass = nil // MyClass instance is deinitialized, so all subscriptions are cancelled
In this code, when myClass
is set to nil, the MyClass
instance is deinitialized. As a result, the cancel()
method of each AnyCancellable
instance will be called automatically, effectively canceling all subscriptions. This is a key feature of AnyCancellable
and a reason why it’s commonly used for managing subscriptions in Combine
Conclusion
This article covers subscribing to publishers with assign
, sink
and custom subscribers. Lastly, take advantage of AnyCancellable for efficient subscription and memory management in Combine.
References
https://developer.apple.com/documentation/combine/publisher/assign(to:on:)
AnyCancellable | Apple Developer Documentation
Thanks for Reading! ✌️
If you have any questions or corrections, please leave a comment below or contact me via my LinkedIn accountPham Trung Huy.
Happy coding 🍻
Subscribe to my newsletter
Read articles from Phạm Trung Huy directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Phạm Trung Huy
Phạm Trung Huy
👋 I am a Mobile Developer based in Vietnam. My passion lies in continuously pushing the boundaries of my skills in this dynamic field.