read

Most developers have experience using RxSwift. But since iOS 13, Apple has provided their 1st party framework, tightly integrated into other frameworks, including SwiftUI.

As such, you should consider picking up Combine.

In this post I will cover a few scenarios of using Combine in a MVVM architecture.

The basics

Eh.. I won’t cover the basics, because you can easily read in Apple and all major Swift websites.

If you’re coming from the RxSwift world, then you will be very familiar. Just note the difference in terminology:

  • Combine’s Publishers = RxSwift’s Observables
  • Combine’s Subscribers = RxSwift’s Observers

So instead of observing an Observable in RxSwift, you are subscribing to a Publisher in Combine.

A new communication method

Before jumping into the usage and see some code, it is important to understand the Unique Selling Point of reactive programming (both RxSwift and Combine).

There is nothing that you cannot accomplish without Combine.

You always have the existing tools to communicate – KVO, delegate, callback completion, NotificationCenter, …

What makes Combine better is that it is designed to be reactive, where streams of data can be emitted easily, while receivers can transform and use consistently. The data flow is supposedly clearer. But how you use it matters.

Using in View Model (MVVM)

Combine changes the way you provide inputs & outputs in a View Model.

Without Combine, a View Model can look like this:

class ViewModel {
  // WITHOUT Combine
  func fetchBooks(count: Int, completion: ([Book])-> Void) {...}
}

fetchBooks is both an input and output. The input is fetchBooks(count:), while the output is the completion closure. After the View Model has fetched the books (eg. over network), it will return the books via the completion closure.

With Combine, the input and output are separated.

class ViewModel: ObservableObject {
  // Input
  func fetchBooks(count: Int) {...}

  // Output
  @Published private(set) books = [Book]()
}

It is pretty neat in the View Model. Let’s take a look at the call site.

Using in View Controller (the call site)

With Combine, you will usually setup all the bindings in 1 place, and they will just react to all kinds of event – from user actions, system events, and View Model outputs.

class ViewController: UIViewController {
  func setupBindings() {
    // Bind UI events
    button.tapPublisher
      .sink { [weak self] in
        self?.viewModel.fetchBooks(count: 10) // Calling View Model input
      }.store(in: &cancellables)

    // Bind View Model outputs
    viewModel.books
      .sink { [weak self] books in
        // Handle the books
      }.store(in: &cancellables)
  }
}

In View Controller, things are handled separately in the 2 bindings. It can take some time getting used to, because without Combine, the code to fetchBooks and then handling in a completion closure is in 1 nice place.

It might not be apparent in this scenario, but when a feature is big, the flow can be complex and reactive programming will be clearer. We will see in a mroe compelx scenario later.

By using publishers, we also avoid the different communication methods that other frameworks use eg. UIButton tap using selector, UITextField using delegate, Alamofire using closure etc..

NOTE: The tapPublisher is provided by CombineCocoa, a community repository. Every framework can provide a publisher, so as to be Combine-compatible.

A more complex requirement

Let’s say we want to add a searchTextField and fetch books automatically, when the user stops entering text for 3 seconds.

We can pass searchTextField.textPublisher to the view model and let it handle. Together with operators, you can build such behaviour very easily.

class ViewController: UIViewController {
  func setupBindings() {
    // Pass textPublisher to View Model as an input
    viewModel.bind(searchText: searchTextField.textPublisher)
  }
}

class ViewModel {
  // Provides another input, subscrbing to the textPublisher
  func bind(searchText: AnyPublisher<String?, Never>) {
    searchText
      .debounce(for: .seconds(3), scheduler: RunLoop.main)
      .sink { [weak self] in
        self?.fetchBooks(search: $0)
      }.store(in: &cancellables)
  }
}

Using operators such as debounce make this requirement a breeze.

But what is worth highlighting is that the View Model can be calling fetchBooks(search:) or fetchBooks(count:), while mutating the same books output.

The View Model can mutate books from anywhere, anytime. All that View Controller has to do is simply subscribe to it, and update it’s UI.

That is what makes Combine shine.

PITFALLS

If you’re using CombineCocoa to observe eg. UITextField.text, do note that the implementation of a textPublisher relies on UIControl.Event ie. it will only publish if there is an event. For user triggering a change in the text, it is okay. But if you’re setting a text programmatically, then you need to emit the event:

textField.text = "new text changed programmatically"
textField.sendActions(for: .valueChanged)

Without sendActions, the text publisher will not receive any values.

Subject

Earlier, we use @Published to create a publisher for a certain type.

Another lower level alternative is using subject. There are 2 types: CurrentValueSubject and PassthroughSubject. The difference is that CurrentValueSubject will store a copy of the latest value, which is also the same behaviour of @Published.

This is an example of a passthrough subject that emits a void value.

private let lowDiskWarning = PassthroughSubject<Void, Never>()
// When detected the event (eg. low disk space), emit with lowDiskWarning.send()

// eraseToAnyPublisher to observe the event
lowDiskWarningSubject.eraseToAnyPublisher().sink { ... }

What’s next?

Writing lines of sink and store, along with weak self is unsightly. But don’t fall into this pitfall.

Hopefully the syntax can be improved.

Oh, Async Sequences (iOS 15) plays along nicely with publishers too.

for await newBooks in $books.values {
  ...
}

How to combineLatest with different error types?

Let’s say publisher 1 has error Never and publisher 2 has error BadBadError, you will face the error:

Instance method ‘combineLatest’ requires the types ‘BadBadError’ and ‘Never’ be equivalent

A simple way is to catch and return the Empty publisher – doesn’t emit anything.

publisher2
  .catch { _ in Empty<Pub2Type, Never>() }
  .combineLatest(publisher1)
  ...

If they are different error types, you can also use setFailureType(to:) if they are equatable.


Image

@samwize

¯\_(ツ)_/¯

Back to Home