Mastering Combine: tips & tricks
September 28, 2021Combine is a reactive programming framework allowing you to process asynchronous values over time. What would have been written in 30 lines with delegates can now be written in just few ones.
But while fantastic Combine, as any other reactive framework, come with one downside: complexity. With so many operators it's not always easy to use the right one or know its characteristic. As such what seemed simple to implement might end up with many mistakes.
Let's take this publisher fetching contacts from server everytime our query change:
$queryString
.removeDuplicates()
.flatMap { [weak self] in
self?.contactGateway
.searchContacts(query: $0)
.replaceError(with: [])
.eraseToAnyPublisher()
?? CurrentValueSubject([]).eraseToAnyPublisher()
}
.assign(to: \.searchResult, on: self)
.store(in: &cancellables)
When $queryString
is modified we call searchContacts
and assign the result to searchResult
. At first glance the code seem valid and doing the job. Easy peasy 🍋. Is it?
Retain cycle
First thing to be aware of and careful about when using Combine is retain cycles.
When calling sink
or assign
:
- We create a subscriber subscribing to our publisher
- Our subscriber keep a strong reference on the publisher
- We receive a
Cancellable
token which retain the subscriber.
This token is then often stored in self.cancellables
ending with self retaining the token, the subscriber and the publisher. Thus we need to be careful to not use self
in our operators closures to avoid a retaining cycle between self
and those items. Hence the use of [weak self]
.
So far so good. But if we have a closure closer look we can see we use self
when calling assign
. Not being a closure we might think it's okay. But reading the doc it clearly state the "operator maintains a strong reference to object (self)". Welcome you, hidden retain cycle! 👋
To avoid this pitfall we have two possibilities:
- Create a custom operator keeping a weak reference on the object.
- Use the alternate assign(to:) storing the token on the publisher itself and available in iOS14.
This article not being so much about this (well known) issue we'll just go with the later one:
$queryString
.removeDuplicates()
.flatMap { [weak self] in
self?.contactGateway
.searchContacts(query: $0)
.replaceError(with: [])
.eraseToAnyPublisher()
?? CurrentValueSubject([]).eraseToAnyPublisher()
}
.assign(to: &$searchResult)
No more retain cycle although we instead gained an awkward &$ symbol 🤷♂️. But we actually still have a bug.
FlatMap vs SwitchToLatest
It's probably one of the most common mistake made in Reactive programming whether being in RxSwift or Combine: the misusage of flatMap
operator.
Remember our objective? Every time our query ($queryString
) change we want to search our contacts. Is it our signal behaviour? Not exactly.
Every time user enter a character we create a new signal to search for our contacts. But we don't cancel previous ones. Therefore all will execute and respond but in undefined order. We might then get the result for "bruc" after "bruce" and not display the correct UI.
One solution would be to use debounce
:
$queryString
.removeDuplicates()
.debounce(0.5, RunLoop.main)
.flatMap { [weak self] in
self?.contactGateway
.searchContacts(query: $0)
.replaceError(with: [])
.eraseToAnyPublisher()
?? CurrentValueSubject([]).eraseToAnyPublisher()
}
.assign(to: &$searchResult)
By reducing the number of call to searchContacts
we mitigate the bug but if we have network latency it might rise again. What we truly need is to keep only latest search request running and cancel any previous one.
Thankfully Combine does provide an operator to achieve just that: switchToLatest
. As its name imply switchToLatest
switch on the latest signal made and cancel previous ones.
$queryString
.removeDuplicates()
.debounce(0.5, RunLoop.main) // we can keep our debounce
.map { [weak self] in
self?.contactGateway
.searchContacts(query: $0)
.replaceError(with: [])
.eraseToAnyPublisher()
?? CurrentValueSubject([]).eraseToAnyPublisher()
}
.switchToLatest()
.assign(to: &$searchResult)
We had to replace
flatMap
withmap
in order to useswitchToLatest
Now when our query change from "bruc" to "bruce" previous request will be canceled and only the one about "bruce" will run and return its result.
CurrentValueSubject vs Just
Inside our closure we use CurrentValueSubject
when self is weak. While working I think it is semantically wrong.
searchContacts
is a publisher that (probably) send only one value and complete. On the other hand CurrentValueSubject
send one value and... never complete. We can also consider when self
is weak that we won't do anything else so more reason to complete our inner signal.
Also while writing this article I discovered that as long as a inner publisher is not completed it stay alive no matter if the upstream was completed or canceled. In this regard I would suggest to not use CurrentValueSubject
to send only one value.
What would be a more appropriate answer then? Using Just
as it just send one value then automatically complete.
$queryString
.removeDuplicates()
.debounce(0.5, RunLoop.main)
.map { [weak self] in
self?.contactGateway
.searchContacts(query: $0)
.replaceError(with: [])
.eraseToAnyPublisher()
?? Just([]).setFailureType(to: Error.self).eraseToAnyPublisher()
}
.switchToLatest()
.assign(to: &$searchResult)
We had to change
Just
error type in order to make it compatible with oursearchContacts
error type
Weak self
Our closure is quite verbose because of the use of weak self
to avoid retain cycles. But if we look to our code we never really use self
itself. What we are interested in is contactGateway
.
In Swift we can define a closure capture list using []
syntax. By explicitly capturing contactGateway
we get a strong reference to it and avoid referencing self
. We can then drop our Just
publisher.
$queryString
.removeDuplicates()
.debounce(0.5, RunLoop.main)
.map { [contactGateway] in
contactGateway
.searchContacts(query: $0)
.replaceError(with: [])
}
.switchToLatest()
.assign(to: &$searchResult)
Because we now have only one signal inside map
we could even get rid of eraseToAnyPublisher
!
Conclusion
Following those small rules should hopefully help you simplify quite a bit your streams! So remember:
- Be careful about retain cycles
- Prefer
switchToLatest
overFlatMap
- Prefer
Just
overCurrentValueSubject
- Avoid referencing
self
and capture your attributes instead