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.