Combine From RxSwift: Highlights for Smooth Adaption
[This article was originally published on my Github page]
RxSwift has been around since 2015 and widely known among Apple developer community as an efficient open-sourced FRP library. In WWDC 2019 Apple introduced their very own FRP framework, making it possible to utilize the paradigm natively with declarative Swift API. The two frameworks share quite a few similarities in terms of principles and usage, so it can be not too daunting to get your feet wet with Combine if you have already been using RxSwift in your projects.
If you search around the topic, it’s easy to find a cheatsheet for migrating from RxSwift to Combine summed up by Shai Mishali which is helpful to have a good overview comparision between the two frameworks. In this article I would like to highlight some interesting points about Combine from the viewpoint of a developer who has been working with RxSwift for more than two years.
Publishers & Subscribers
These two are the equivalents of Observables and Observers in RxSwift. Together they make up the core components of Combine. As Apple put it:
Combine declares publishers to expose values that can change over time, and subscribers to receive those values from the publishers.
The great thing about using the native FRP framework is that it has been integrated into other Apple’s frameworks, making the migration process easier. Foundation framework has supported the use of Combine in some of its APIs, like
URLSession in the following example:
In this example,
Publisher as a wrapper to emit signals to its subscribers on receipt of response from the URL request.
.sink is a method to create a subscription by connecting a subscriber of type
Sink to a publisher. This method accepts one closure for execution when receiving new elements and another for handling completion. This is similar to RxSwift Observable's
subscribe method which lets us handle when a stream emits new event, completes or errors out.
Subscriptions in Combine can also be handled by assigning values to class properties that are marked with
@Published - which is quite similar to
bind(to:) method in RxSwift Observable:
The preceding example uses the property wrapper
@Published to create a publisher that outputs values of type
Repo? and has failure type
Never (meaning the stream never fails - more on that later). The
repoDetail therefore can be observed for changes by another subscriber:
As can be noticed from the subscription of
repoDetail, another method
assign(to:on:) is used to assign the mapped
name value to
titleLabel's displayed text using KVO. This method keeps strong reference to the object passed to
on:, so it should be used with caution to avoid retain cycle if you happen to send
self. Although a quick workaround would be to send
weak self instead, if you find yourself in such situation, it's time to use an
@Published property instead (unfortunately
assign(to:) is only available since iOS 14).
Similar to RxSwift, Combine has the same concept of Subjects — the types of publishers that allow injection of values to be published. Subjects are useful for quickly creating custom publishers and migrating from imperative programming to FRP. There are two built-in subjects:
PassthroughSubject: equivalent to PublishSubject in RxSwift. This subject broadcasts elements to downstream subscribers, suitable for stateless streams of events. This is usually used for observing user interactions (button taps, toggle switches etc.) or sending notifications.
CurrentValueSubject: equivalent to BehaviorSubject in RxSwift. This subject wraps a stream of output of value type and exposes the most recently published element via
valuevariable. New values can be injected to the stream by updating the
valuevariable or using
If you look closely at the code examples in the first section, you’ll notice the retaining of
AnyCancellable after creating subscriptions. Methods
.assign(to:on:) both return
AnyCancellable, indicating that the subscriptions can be cancelled from outside. This retaining is important to keep the created data streams alive; and also to dispose them when they are no longer relevant - much like how
DisposeBag is used in RxSwift.
Usually you would want to create more than one subscriptions in a class, so it is more practical to have a set of
AnyCancellable objects to keep all the subscriptions in one place:
Here the lifecycle of
cancellablles is tied with
RepoDetailViewController, so when the view controller is released, the subscriptions stored in this property will all be cancelled and disposed as well.
But how about the method
assign(to:)? Why didn't I retain the subscription made with it like I did with the other two methods?
As you may have guessed, this method links the created subscription with the
@Published property sent to it, so it does not require the same memory managment machanism as the two methods discussed earlier.
Type matching and error handling
In RxSwift, Observable is a generic type with an associated type Element defining the type of the data stream’s output. In Combine, a Publisher has two concerns: Output and Failure types. Failure type of a publisher can be either an
Error-conforming type, or
Never if it does not publish any error. So when creating a subscription, you have to make sure that the Input type of the subscriber match with the Output type of the publisher. More interestingly, their Failure types are also required to be the same, otherwise you'll encounter a mismatching types error.
Publisher extension has several operators to handle errors. In the
fetchRepoDetail() example above I used
replaceError(with:) to silent any decoding or response errors with
nil since I wanted the failure type of the upstream publisher to match with that of the
@Published property, which is
Never. In cases when you are certain that no error should be thrown,
assertNoFailure() can be used to transform failure type to
Never or call assert otherwise. You can also use
retry() to attempt recreating failed subscriptions to a specified number of times; or map the error to another publisher using
In real life situations, however, error handling is more often necessary — here’s how the code can be refactored for that purpose:
In the above solution, I’ve changed the
@Published property to a
CurrentValueSubject to change the error type from
RepoError. Then I used
mapError function to transform the errors from upstream to my custom error type
RepoError. The result publisher was finally subscribed using a
Sink to send values and completion event to the subject. It's a pity that we cannot use
assign(to:) with a subject like how we can bind observables to subjects or observers in RxSwift - but we can further improve this piece of code in the next section.
A quick look at the Apple documentation of Publisher can show that most operators used on publishers return their respective types extending the
Publishers enum. Take the code snippet from the previous section as an example, if you paste it in a Playground and open Quick Help panel to investigate the returned types of each operator, you'll be surprised how the types can get more and more complicated after each operator:
URLSession.shared.dataTaskPublisherreturns publisher of type
tryMap, we get the result of type
- moving on with
decode, we get
Publishers.Decode<Publishers.TryMap<URLSession.DataTaskPublisher, Data>, Item, Coder> where Item : Decodable, Coder : TopLevelDecoder, Self.Output == Coder.Input
- finally, with
mapErrorwe end up with
Publishers.MapError<Publishers.Decode<Publishers.TryMap<URLSession.DataTaskPublisher, Data>, Repo?, JSONDecoder>, E>
If we want to expose the publisher to external subscribers, we’ll need a more generic type -
AnyPublisher - as the subscribers only concern about the output and failure types of the publisher they subscribe to. This is where type erasing comes to play, and Combine has a method for this:
eraseToAnyPublisher(). Let's see how we can use this to improve the implementation in the previous section:
This time, a publisher is returned immediately from
fetchDetail() function, and the
RepoDetailViewController can subscribe directly to it and handle any received events. I can then safely remove redundant use and subscription of
CurrentValueSubject, and the code looks much neater. The returned publisher is of type
AnyPublisher<Repo?, RepoError>, which is informative enough for any external subscribers.
Future and Deferred
In Combine, many publishers invoke their closures and emit values only after they get a subscription. For instance, a
DataTaskPublisher starts a url request and emit subsequent result whenever it receives demand request from a subscriber. This also means that several subscriptions made to the same publisher can trigger multiple url requests and receive different response - which is not desirable if we just want to observe the same stream. Applying
share() operator can solve that problem, making sure that the same output of the publisher is shared among its subscribers.
An alternative would be to wrap the
URLSessionDataTask creation in a
Future. It's a type of publisher that completes after emitting a single output value or failure. This result is republished to any subscriber listens to the same
Future object. It's suitable for wrapping any asynchronous work that expects only one result. Since a url requests always returns response once and then completes - this is a perfect case for the use of
Future requires extra caution though, especially if you apply
eraseToAnyPublisher() as developers on the receiving end may not be aware of its distinctive features:
Futureimmediately invokes the asynchronous request in its closure at the time of creation.
Futureemits result only once so even though it's still possible to call
retry()on it, the operator can't create new subscription after the publisher completes.
A solution to delay the start of a
Future's work while still taking advantage of its one-time result is to wrap it inside a
Deferred. This is another special kind of publisher whose job is to wait for a subscription before triggering the supplied closure to create a new publisher. The implementation is simple:
With the use of
Deferred, a new
Future object is created every time there's a new subscription, making the
Future lazy - which is more similar to
Single in RxSwift.
I cannot end this article without mentioning backpressure. It defines the core principle of Combine: In a subscription, the subscriber controls the handling of events from the upstream publisher, including number of values to be received. As per the documentation on Publisher protocol, the following methods of the subscriber will be triggerred when it listens to a publisher:
receive(subscription:): Notifies the subscriber of a succesful subscribe request and returns a
Subscriptioninstance. The subscriber uses this subscription object to request a
Subscriber.Demandsspecifying the number items to receive, if any.
receive(_:): Delivers one element from the publisher to the subscriber. This methods returns another
Subscriber.Demandsto let the publisher know if any more demand for is needed.
receive(completion:): Informs the subscriber that publishing has ended, either normally or with an error.
The specification of demands makes sure that the subscription sends just enough items as requested by the subscriber and no more. Below is an illustration for a
Sink subscriber to visualize the relationship and clarify the explanations a little further (apologies for my poor drawing skills 🙂):
Backpressure is an advanced topic and as Apple keeps their implementations of publishers and subscribers private, so the complexity of this management is hidden from developers. Understanding the principle of backpressure makes customization for publishers and subscribers possible. One particular use case for this is CombineCocoa by CombineCommunity, which makes integration of Combine when using UIControls much more convenient. However, Apple strongly encourages that developers use the built-in convenience publishers and subjects instead of implementing custom ones, so it is important to look for simple solutions before tempting to overengineer your solutions to avoid bringing complexity and potential bugs to your projects.
Since Combine only supports iOS 13 onwards, it may be a bit early for migration for most of current projects. Nevertheless, it’s never too soon to learn new tech and start experimenting with it to get yourself comfortable and “relevant”. The framework itself is still under development and improvement, so don’t forget to check back in future WWDCs for any cool new updates. I hope this article inspires you to dive deeper and learn more about Combine. I would like to give my thanks to Donny Wals for his book Practical Combine, as well as all the resources listed in the References section for making this article possible. In the meantime, you can also check out RxCombine to discover the possiblities of bridging between the two frameworks. Have fun!