Swift/UnwrapDiscover about Swift, iOS and architecture patterns

Mastering Combine: tips & tricks

September 28, 2021

Combine 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:

  1. We create a subscriber subscribing to our publisher
  2. Our subscriber keep a strong reference on the publisher
  3. 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:

  1. Create a custom operator keeping a weak reference on the object.
  2. 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 with map in order to use switchToLatest

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 our searchContacts 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:

  1. Be careful about retain cycles
  2. Prefer switchToLatest over FlatMap
  3. Prefer Just over CurrentValueSubject
  4. Avoid referencing self and capture your attributes instead