An Introduction to Combine - Part 3
And here we are for the third part of our introduction to Combine. If you lost the first two parts I highly recommend that you read them before because we've been learning the concepts and types from Combine by refactoring the same application step-by-step, so here are part 1 and part 2.
In this article we're going to update our component to make use of 2 other subscription mechanisms: Subscribe and Assign.
Subscribe
One thing that I don't like in our code at the moment is that the ViewController
is not exactly sending events do the ViewModel
, but giving it commands instead. In other words, when the button is tapped ViewController
doesn't say "hey ViewModel, the refresh button was tapped, you may want to do something about it now...". No, it tells the ViewModel exatcly what to do: "Refresh the balance!". The same applies to viewDidAppear
.
This difference is subtle, but it matters and we're gonna fix this now. The first thing we're going to do is creating an enum
to contain all events that flow from BalanceViewController
to BalanceViewModel
:
enum BalanceViewEvent {
case viewDidAppear
case refreshButtonWasTapped
}
The next step is creating a subject in BalanceViewModel
that will receive all the external events, and make a private subscription to it using sink
:
final class BalanceViewModel {
let eventSubject = PassthroughSubject<BalanceViewEvent, Never>()
(...)
init(service: BalanceService) {
(...)
eventSubject
.sink { [weak self] in self?.handleEvent($0) }
.store(in: &cancellables)
}
private func handleEvent(_ event: BalanceViewEvent) {
switch event {
case .refreshButtonWasTapped, .viewDidAppear:
refreshBalance()
}
}
private func refreshBalance() {
(...)
}
(...)
}
With this change we could make refreshBalance()
private because, again, we don't want anybody telling our ViewModel what to do. The only thing an external caller can do with our ViewModel
now is sendind it a BalanceViewEvent
through the eventSubject
and reading or subscribing to the ViewModel
's state.
Now we need to update the ViewControler
. In viewDidAppear
we just change viewModel.refreshBalance()
for viewModel.eventSubject.send(.viewDidAppear)
. In viewDidLoad
is where we're gonna do it differently: we'll replace that sink
on rootView.refreshButton.touchUpInsidePublisher
with the following:
rootView.refreshButton.touchUpInsidePublisher
.map { _ in BalanceViewEvent.refreshButtonWasTapped }
.subscribe(viewModel.eventSubject)
.store(in: &cancellables)
In the listing above we first append a map
operator so that every time the button sends us that Void
touch event we transform it into a proper BalanceViewEvent
, which is the type of event that eventSubject
accepts. Then we call subscribe(viewModel.eventSubject)
on the resulting publisher, and retain the cancellable (as we were doing before).
From now on, every button tap will be transformed into a BalanceViewEvent.refreshButtonWasTapped
and this event flow directly into eventSubject
, and our ViewModel
will, of course, be listening to all the events that pass through eventSubject
.
Time to run our tests again. They pass. Here's the commit with the change.
Is replacing functions with subjects worth it?
Before we move on, I wanted to analyze what we did on the ViewModel
. Besides the mental shift from commands to events, we replaced function calls (we had only one in our simple example, but we usually have more than that) with a single subject, where the ViewModel
can receive all the events it knows how to handle. This change looks a bit overkill for our tiny app, but they bring interesting capabilities to our code.
First, now that our ViewModel has a single point for receiveing events and a single var to store and publish the current state, we're very close to having a generic definition that we could use for any ViewModel
, and this could even be used to decouple the ViewController
from the ViewModel
as long as they work with the same type for the events and for the state. But that's subject for another article.
Second, the fact that we're receiving events through a subject allows the viewModel to apply operators to different events. Imagine that we should only refresh the balance on the first time the view appears. We could do this without any additional var to control the number of times the "view did appear", the only thing we need to do is use the first(where:)
operator:
eventSubject
.first(where: { $0 == .viewDidAppear })
.sink { [weak self] _ in self?.refreshBalance() }
.store(in: &cancellables)
You can argue that I could also change the viewDidAppear
event to a viewDidLoad
event, and you're right, but if you switch to SwiftUI, for instance, you only have the onAppear
event and it can be called multiple times, so if you have a requirement like this (refreshing automatically only in the first time the view appears) with a SwiftUI View you'll end up having to do something like we did above.
Another example: imagine that our BalanceViewController is a child of a larger ViewController that will show a lot of other things like last transactions the user made; as the user is new to the app, this parent ViewController
is showing a text overlay explaining the balance widget and inviting the user to tap into the refresh balance button. When the user taps on it, in addition to refreshing the balance, the overlay should be dismissed. Well, anyone that knows our ViewModel can use eventSubject
to send events to it, right? But they can also subscribe to this subject and also be notified when the ViewModel receives events from any source. This way our outer ViewController
can know when the refresh button was tapped without the need for any additional NSNotification
, callback or anything.
So, again, I'm not doing these changes just to push it with Combine. I really think modeling the inteface of my ViewModel in this way has several benefits.
Assign
The last subscription mechanism that I want to show today is the .assign(to:on:)
function.
So far, we're binding the state updates to the view updates with the following piece of code in our BalanceViewController
:
override func viewDidLoad() {
super.viewDidLoad()
viewModel.$state
.sink { [weak self] in self?.updateView(state: $0) }
.store(in: &cancellables)
(...)
}
private func updateView(state: BalanceViewState) {
rootView.refreshButton.isHidden = state.isRefreshing
if state.isRefreshing {
rootView.activityIndicator.startAnimating()
} else {
rootView.activityIndicator.stopAnimating()
}
rootView.valueLabel.text = state.formattedBalance
rootView.valueLabel.alpha = state.isRedacted
? BalanceView.alphaForRedactedValueLabel
: 1
rootView.infoLabel.text = state.infoText(formatDate: formatDate)
rootView.infoLabel.textColor = state.infoColor
rootView.redactedOverlay.isHidden = !state.isRedacted
}
By doing that we always update everything regardless of what changed from the past value of viewModel.state
. To give you and example, if only state.isRefreshing
changed the only views that should really be updated are rootView.refreshButton
and rootView.activityIndicator
, but in our case we're also changing texts, alphas, colors, etc., anyway.
So let's extract the updates to rootView.refreshButton
and rootView.activityIndicator
from this generic flow to more specific subscriptions. I'll start with rootView.refreshButton
by removing the first line in the updateView(state:)
function and addding the following to viewDidLoad()
:
let isRefreshingPublisher = viewModel.$state
.map(\.isRefreshing)
.removeDuplicates()
isRefreshingPublisher
.assign(to: \.isHidden, on: rootView.refreshButton)
.store(in: &cancellables)
On the first three lines I take the $state
publisher from the viewModel
and use the map()
operator with a key path to derive another publisher that extracts just the Bool
value of isRefreshing
from the whole BalanceViewState
struct. If you don't understand what key paths are is I suggest that you read this article from John Sundell.
Continuing, if I stop here and create a subscription to viewModel.$state.map(\.isRefreshing)
I'll still be receiving repeated values. For instance, if the current value of the state has isRefreshing
equal to false
and the user answers a phone call, this will make the app inactive and state.isRedacted
will be set to true
. This change, which has nothing to do with isRefreshing
, will generate another value for the whole state
struct and viewModel.$state.map(\.isRefreshing)
will ping me back with another false
value, which is the value for isRefreshing
in this new state
. To prevent the reception of duplicated values, we can append the .removeDuplicates()
operator. This operator will wrap the publisher resulting from the map
call and only propagate values to the subscribers when they really changed.
Let's stop and take a look at the type of the isRefreshingPublisher
:
Publishers.RemoveDuplicates<Publishers.MapKeyPath<Published<BalanceViewState>.Publisher, Bool>>
This is a pure application of the decorator pattern in a heavily generic API, but it reads so complicated (and here we only applied 2 operators) that this is why we always prefer erasing to AnyPublisher
when we need to declare the type of the publisher explicitly. The really important part of this type is that Bool
at the end. We derived this publisher from a key path of a Bool
property and that's the type of value that we'll get back when we subscribe to this publisher.
Talking about subscription, let's analyze that assign(to:on:)
call, which effectively creates the subscription. The first parameter is a writable key path and the second parameter is the class that contains this key path. The type of the variable pointed by this key path must match the type of the publisher's value (that Bool
we emphasized before). Just like sink
, assign
returns a AnyCancellable
that must be stored while we want to keep the subscription alive.
The effect of this subscription is that every time isRefreshingPublisher
publishes a new value it's instanteneously assigned to isHidden
on rootView.refreshButton
, which is exactly what we want.
A second implication is that the subscription creates a strong refefrence to the object passed as the second parameter, which is fine as rootView.refreshButton
doesn't hold any strong reference back to our ViewController, the owner of the cancellable. We could also write the same subscription targeting the rootView
instead:
isRefreshingPublisher
.assign(to: \.refreshButton.isHidden, on: rootView)
.store(in: &cancellables)
This would also work fine. However, we would be creating a retain cycle with the following code:
isRefreshingPublisher
.assign(to: \.rootView.refreshButton.isHidden, on: self)
.store(in: &cancellables)
Whenever you need to assign to key paths on self
prefer using sink { [weak self] value in }
instead or call .assign(to: (...), onWeak: self)
using the following extension:
extension Publisher where Failure == Never {
func assign<Root: AnyObject>(
to keyPath: ReferenceWritableKeyPath<Root, Output>,
onWeak object: Root
) -> AnyCancellable {
sink { [weak object] value in
object?[keyPath: keyPath] = value
}
}
}
Moving on, this is how our updateView(state:)
function looks like at the moment:
private func updateView(state: BalanceViewState) {
if state.isRefreshing {
rootView.activityIndicator.startAnimating()
} else {
rootView.activityIndicator.stopAnimating()
}
rootView.valueLabel.text = state.formattedBalance
rootView.valueLabel.alpha = state.isRedacted
? BalanceView.alphaForRedactedValueLabel
: 1
rootView.infoLabel.text = state.infoText(formatDate: formatDate)
rootView.infoLabel.textColor = state.infoColor
rootView.redactedOverlay.isHidden = !state.isRedacted
}
As we mentioned before, state.isRefreshing
also controls the animation of the rootView.activityIndicator
, so let's replace this if-else on updateView(state:)
with another subscription to isRefreshingPublisher
in viewDidLoad()
:
isRefreshingPublisher
.assign(to: \.isAnimating, on: rootView.activityIndicator)
.store(in: &cancellables)
But the compiler yells at us with an error that reads very complicated because of the deeply nested generics from isRefreshingPublisher
:
Key path value type 'ReferenceWritableKeyPath<UIActivityIndicatorView, Publishers.RemoveDuplicates<Publishers.MapKeyPath<Published<BalanceViewState>.Publisher, Bool>>.Output>' (aka 'ReferenceWritableKeyPath<UIActivityIndicatorView, Bool>') cannot be converted to contextual type 'KeyPath<UIActivityIndicatorView, Publishers.RemoveDuplicates<Publishers.MapKeyPath<Published<BalanceViewState>.Publisher, Bool>>.Output>' (aka 'KeyPath<UIActivityIndicatorView, Bool>')
Don't be intimidated. Those "aka"s actually help a lot. The problem is that isAnimating
is a readonly property on UIActivityIndicatorView
and we obviously can't have a writable keypath from a readonly property. So we either need to go back to using a sink
or find another way to use assign
. I'll go with the second option, by creating the following extension:
extension UIActivityIndicatorView {
var writableIsAnimating: Bool {
get { isAnimating }
set {
if newValue {
startAnimating()
} else {
stopAnimating()
}
}
}
}
Now all we need to do is replacing that \.isAnimating
key path with \.writableIsAnimating
and run the tests again. Here is the commit diff.
You might be thinking that creating these fine-grained subscriptions for each subview that we need to update is overkill for our component. And you are completely right. I went ahead and replaced every view update that we had in BalanceViewController
with calls to assign
after applying a couple of operators, check it out.
Besides being unnecessarily precise (UIKit is optimized enough to know when some property update will really require a new render phase), this is much harder to read than our old updateView(state:)
function, even after cleaning up all those .store(in:)
calls.
I'm doing this for explanatory purposes, of course, but we usually work on more complex screens right? You'll probably spot better opportunities to use assign
at a higher level as you start using Combine in your apps, specially with UIKit.
Conclusion
Although sink
is the most common way to create subscriptions in Combine, we can use (but not abuse, as I did) assign
and subscribe
to connect publishers and subscribers in a very elegant and precise way.
Keep these mechanisms in mind and don't always chose sink
without reflecting before if assign
or subscribe
could be applicable AND make your code better.
In the next (and probably the last) part, we'll apply Combine to our service and the network layer, so follow me on Twitter and LinkedIn to stay tuned.
If you have suggestions, corrections, etc., feel free to open a Pull Request or send me a message.
See you next time!