Apple’s Combine framework has two operators called .share()
and .makeConnectable()
. They are extremely useful but often misunderstood. Both help us avoid unnecessary processing. .share()
, as the name suggests, helps us share a subscription. .makeConnectable()
helps control when publishers emit events.
Working with Multiple Subscribers
Imagine you have a simple view with a button and two labels. When a user taps the button, the text on the two labels will be updated.
The UI might look like this.
Next, we make a Combine data stream that takes a tap on the button, creates a string and then updates both labels.
let didTap = didTapPublisher
.map { _ -> String? in "Updated Label" }
didTap
.assign(to: \.labelOne.text, on: self)
.store(in: &cancellables)
didTap
.assign(to: \.labelTwo.text, on: self)
.store(in: &cancellables)
Tapping on the button updates both labels.
In this example, the didTapPublisher
is actually a PassThroughSubject
linked to the UIButton
tap event. The PassThroughSubject
is a class and as such acts as a single publisher.
But when we assign the output of didTap
to each label, we actually create two subscription that we then store in our cancellables
.
That makes a lot of sense. The didTapPublisher
acts as the single source of truth for a tap event and pushes the mapped strings to the labels.
An API Call with Multiple Subscribers
Now we will update our code so the tap makes an API call. The API call will fetch a random user ID every time. When the fetch response is received we will update the UI with the received ID. In the following example, .flatMap { _ in API.getUserID() }
does the networking logic so we can focus on our subscriptions.
let didTap = didTapPublisher
.flatMap { _ in API.getUserID() }
didTap
.assign(to: \.labelOne.text, on: self)
.store(in: &cancellables)
didTap
.assign(to: \.labelTwo.text, on: self)
.store(in: &cancellables)
Now when we tap the button our updated UI might look like this.
Notice that a different ID is displayed for each label. This is because each subscription is causing a separate API call. It looks a bit like this.
Although one publisher is emitting the tap event, there are two subscriptions. The API call is part of the subscription. So we end up with two API calls.
It’s easy to imagine a more complex UI with multiple subscriptions causing multiple network calls. This is what we want to avoid. So we need .share()
.
Share the Upstream
In our next example, we want to share the events from the upstream publisher. We don’t want to cause multiple API calls as this is resource intensive and we also want to ensure the UI gets the same output.
This is where we need the .share()
operator.
Apple states that:
Share is a class instance that shares elements received from its upstream to multiple subscribers.
So .share()
specifically exists to share the events from the upstream publisher.
Note that .share()
returns a new class of type Publishers.Share
. If you are familiar with reference semantics then it makes sense as we only want to refer to one shared instance of the upstream publishers.
Let’s add it to our code.
let didTap = didTapPublisher
.flatMap { _ in API.getUserID() }
.share() // <-- added share
didTap
.assign(to: \.labelOne.text, on: self)
.store(in: &cancellables)
didTap
.assign(to: \.labelTwo.text, on: self)
.store(in: &cancellables)
Now the UI is what we would expect.
The data streams look more like this.
In these examples, the API Call is triggered by a tap on the button. The data stream is a push-style data stream, where events are pushed to subscribers from the upstream. But what if events are triggered by subscriptions? What if subscriptions pull data from an upstream publisher?
What if Downstream Subscriptions Pull the Events?
We can change our previous code so that instead of API calls being triggered by a tap, they are triggered by a subscription. Here’s how that looks.
let apiCall = API.getUserID()
.share()
apiCall
.assign(to: \.labelOne.text, on: self)
.store(in: &cancellables)
apiCall
.assign(to: \.labelTwo.text, on: self)
.store(in: &cancellables)
Now, the first subscription that assigns the output from the API call to labelOne.text
is actually what triggers the network call. This means that the second subscription must attach itself to the publisher before the API response is received, otherwise, it might miss the event.
In this very small example, this is virtually never going to happen as the API call will often take longer than the subsequent subscription. But if we delay the subscription we can see the result. Let’s try it.
let apiCall = API.getUserID()
.share()
apiCall
.assign(to: \.labelOne.text, on: self)
.store(in: &cancellables)
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [self] in
apiCall
.assign(to: \.labelTwo.text, on: self)
.store(in: &cancellables)
}
That breaks it.
How can we ensure that all subscribers are subscribed before they apply pressure to the upstream publisher and pull events?
ConnectablePublisher and makeConnectable
This is exactly what the operator makeConnectable()
is for. It is operator that returns a ConnectablePublisher.
This publisher doesn’t produce any elements until you call its connect() method.
The connect()
method becomes the trigger for pushing events. This gives us time to ensure all our subscribers are connected before we start sending events.
Using this we can update our code:
let apiCall = API.getUserID()
.share()
.makeConnectable()
apiCall
.assign(to: \.labelOne.text, on: self)
.store(in: &cancellables)
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [self] in
apiCall
.assign(to: \.labelTwo.text, on: self)
.store(in: &cancellables)
apiCall.connect().store(in: &cancellables)
}
Now both labels will be updated with the same response sometime after the second subscriber is subscribed.
Simple.
The API Code
If you wanted to replicate this post feel free to use this API code to get going.
struct User: Codable {
let id: Int
}
enum API {
static let users = URL(string: "https://random-data-api.com/api/v2/users")!
static func getUserID(url: URL = API.users) -> AnyPublisher<String?, Never> {
URLSession.shared
.dataTaskPublisher(for: url)
.map(\.data)
.decode(type: User.self, decoder: JSONDecoder())
.map { user in "Received User ID: \(user.id)"}
.replaceError(with: "error")
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}
}
Conclusion
The .share()
and .makeConnectable()
operators in the Combine framework are powerful tools for managing the flow of data in your Swift applications. .share()
allows multiple subscribers to receive updates from a single publisher, while .makeConnectable()
allows for manual control over when data is emitted from a publisher. Both operators can be useful in a variety of situations.
Bonne chance.