Getting started with Combine
Published on: January 6, 2020The Combine framework. Silently introduced, yet hugely important for iOS. It didn't get any attention during the big Keynote at WWDC 2019, but as soon as folks were in the sessions they knew that Combine was going to be huge. It implements a Functional Reactive Programming (FRP) paradigm that's similar to that of Rx which is implemented by RxSwift, except it's made by Apple and has native support on all Apple platforms as long as they are running iOS 13+, iPadOS 13+, macOS 10.15+, watchOS 6+ or tvOS 13+.
The fact that Apple created their own FRP framework is a big deal, and it gives off a signal to the developer community. SwiftUI makes heavy use of Combine and Apple has integrated Combine in several existing APIs as well. And since Combine is created and owned by Apple, it can be used without any third-party dependencies, and we can rest assured that Apple will continue to support Combine for the foreseeable future.
In today's post, I would like to help you get started with Combine and show you the basics of what it is, how it works, and what it can do. You will learn the following topics:
- Understanding what Functional Reactive Programming is
- Understanding publishers and subscribers
- Transforming publishers
There is a lot to cover in this post, so make sure you're comfortable, grab yourself something to drink and put on your learning hat.
Understanding what Functional Reactive Programming is
In the world of FRP, your code is written in a way where data flows from one place to the other automatically through subscriptions. It uses the building blocks of Functional Programming like, for example, the ability to map one dataflow into another. FRP is particularly useful in applications that have data that changes over time.
For example, if you have a label that displays the value of a slider, you can use FRP to push the value of your slider through a stream, or publisher which will then send the new value of the slider to all subscribers of the stream, which could be the label that shows the slider value, or anything else.
In addition to driving UI updates, FRP is also incredibly useful in asynchronous programming. Consider a network request. When you make the request, you expect to get a result back eventually. Usually, you would pass a completion closure to your request which is then executed when the request is finished. In FRP, the method you call to make a request would return a publisher that will publish a result once the request is finished. The benefit here is that if you want to transform the result of the network request, or maybe chain it together with another request, your code will typically be easier to reason about than a heavily nested tree of completion closures.
I won't cover networking or UI updates in today's post. Instead, we'll go over some more basic examples of publishers and transforming values to prepare them for display. I will cover networking and UI updates in future posts.
Understanding publishers and subscribers
A Combine publisher is an object that sends values to its subscribers over time. Sometimes this is a single value, and other times a publisher can transmit multiple values or no values at all. While a publisher can publish a variable number of values, it can only emit a single completion or error event. Since it's common to represent the flow of a publisher as a so-called marble diagram, let's examine one now.
The image above contains two marble diagrams. Each diagram is shown as an arrow. Each arrow represents a publisher. The circles, or marbles, on each line, represent the values that the publisher emits. The top arrow has a line at the end. This line represents a completion event. After this line, the publisher will no longer publish any new values.
The bottom diagram ends with a cross. The cross represents an error event. Errors end the stream of values, similar to a completion event. In other words, something went wrong and the publisher will now no longer publish any new events. Every publisher in the Combine framework uses these same rules, with no exceptions. Even publishers that publish only a single value must publish a completion event after publishing their single value.
Subscribing to a simple publisher
We can model the stream of values that are published by a publisher as an array. In fact, we can even use arrays to drive simple publishers. Let's create a simple publisher that publishes a list of integers:
[1, 2, 3]
.publisher
.sink(receiveCompletion: { completion in
switch completion {
case .failure(let error):
print("Something went wrong: \(error)")
case .finished:
print("Received Completion")
}
}, receiveValue: { value in
print("Received value \(value)")
})
There is a lot to unpack in the snippet above. The Combine framework adds a publisher
property to Array
. We can use this property to turn an array of values into a publisher that will publish all values in the array to the subscribers of the publisher.
The type of the created publisher is Publishers.Sequence<[Int], Never>
. Based on this type signature, we can derive that there is a Publishers
object in the Combine framework. You can look up the Publishers
object in the documentation and you'll find this page. We can find the following short description for Publishers
there:
A namespace for types that serve as publishers.
In other words, all the built-in publishers in the Combine framework are grouped under the Publishers
enum. Each of the publishers that exist in this namespace conforms to the Publisher
protocol and has a specific role. You will rarely have the need to directly create instances of the publishers contained in the Publishers
enum. Instead, you'll often create them by calling methods or properties on other objects. Similar to how we created a Publishers.Sequence<[Int], Never>
publisher by calling publisher
on our array.
The generic types in the definition of Publishers.Sequence<[Int], Never>
are [Int]
and Never
in the preceding example. This means that the publisher will use a sequence of type [Int]
to publish values and that its failure type is Never
. This tells us that the Sequence
publisher will always complete successfully. This means that in the example above, the .failure
case in the switch will never be hit and we can always assume success in the receiveCompletion
closure of a sink where the failure type is Never
. In fact, there is a special version of sink
available on publishers that have a failure type of Never
where you only supply a receiveValue
closure.
Every publisher in Combine has an Output
and a Failure
type. The Output
is the type of value that a publisher will push to its subscribers. In the case of our Sequence
, the Output
will be Int
. The Failure
type is Never
because the publisher cannot finish with an error. Publishers that can fail will often use an object that conforms to Error
as their Failure
type, but you're free to specify any type you want.
Tip:
If you want to learn more about generics, check out some of the posts I have written on that topic:
You can subscribe to a publisher using the sink(receiveCompletion:receiveValue:)
method. This method creates a subscriber that is subscribed to the publisher that the method was called on. It's important to note that publishers only publish values when they have subscribers. Calling sink(receiveCompletion:receiveValue:)
on a publisher creates a subscriber immediately and enables the publisher to begin streaming values.
For posterity, the output of the preceding code snippet is the following:
Received value 1
Received value 2
Received value 3
Received Completion
The receiveValue
closure is called whenever a new value is published by the publisher. This closure receives the latest value of the publisher as its single argument. In the completion handler, we can check whether a subscription failed with an error or if it completed normally. You can use a switch statement and pattern matching to extract the error and handle it as needed. Combine has more advanced error handling mechanisms that I won't go into in today's post. For now, it's important that you understand how publishers and subscriptions work at the surface.
Let's take a closer look at the subscription object that is created when you call sink(receiveCompletion:receiveValue:)
.
Keeping track of subscriptions
In the previous subsection, you learned a bit about subscribing to a publisher by calling sink(receiveCompletion:receiveValue:)
on the publisher itself. In the example code, we did not store the object that's returned by sink(receiveCompletion:receiveValue:)
, which is perfectly fine in a Playground. However, in your applications, you need to hold on to the subscriptions you create. If you don't do this, the subscription object will be discarded as soon as the scope where you create the subscription is exited. So if you were to call sink
in a function, the created subscription would cease to exist at the end of the function.
If you examine the return type of sink
, you will find that it's AnyCancellable
. An AnyCancellable
object is a type-erased wrapper around a Cancellable
subscription that you can hold on to in your view controller. You can safely hold on to your AnyCancellable
objects and be assured that any subscriptions are canceled when the object that's holding on to the AnyCancellable
is deallocated. You only need to call cancel
yourself if you explicitly want to discard a given subscription.
Note:
If you have used RxSwift in the past, you may have worked with an object calledDisposeBag
, and you would have addedDisposable
objects to theDisposeBag
. Combine does not have an equivalent ofDisposeBag
, but it does have an equivalent ofDisposable
which is theCancellable
protocol.
Consider the following example:
var subscription: AnyCancellable?
func subscribe() {
let notification = UIApplication.keyboardDidShowNotification
let publisher = NotificationCenter.default.publisher(for: notification)
subscription = publisher.sink(receiveCompletion: { _ in
print("Completion")
}, receiveValue: { notification in
print("Received notification: \(notification)")
})
}
subscribe()
NotificationCenter.default.post(Notification(name: UIApplication.keyboardDidShowNotification))
In the preceding code, we use the convenient publisher(for:)
method that was added to NotificationCenter
to subscribe to the UIApplication.keyboardDidShowNotification
notification. If you place this code in a Playground, you'll find that the print statement in the receiveValue
closure is executed, but the receiveCompletion
is never called. The reason for this is that NotificationCenter
publisher can send an infinite number of notifications to its subscribers.
If you remove the assignment of subscription
by removing subscription =
before publisher.sink
you will find that the receiveValue
closure is never called due to the subscription being discarded as soon as the subscribe()
function is done executing.
In addition to automatic cancellation of subscriptions when an AnyCancellable
is deallocated, you can also explicitly cancel a subscription by calling cancel()
on the AnyCancellable
that contains the subscription, or directly on any object that conforms to Cancellable
. To try this, you can add the following two lines after the code snippet I just showed you:
subscription?.cancel()
NotificationCenter.default.post(Notification(name: UIApplication.keyboardDidShowNotification))
If you put this in a playground, you'll find that the receiveValue
closure is only called once because the subscription is canceled after the first notification is posted.
If you examine the type of object that is returned by publisher(for:)
, you'll find that it's a NotificationCenter.Publisher
. This doesn't tell us much about the type of object and error it might publish. When you call sink(receiveCompletion:receiveValue:)
on the publisher, you'll notice that the receiveCompletion
closure has a single argument of type Subscribers.Completion<Never>
and the receiveValue
has an argument of type Notification
. In other words, the Output
of NotificationCenter.Publisher
is Notification
, and its Failure
is Never
. You can confirm this by looking up NotificationCenter.Publisher
in the documentation and examing the Output
and Failure
type aliases.
Tip:
Speaking of type aliases, if you want to learn more about how and when to use them, check out my five ways to improve code with type aliases.
At this point, you know that Combine revolves around publishers and subscribers. You know that publishers only publish values when they have active subscribers and that you can quickly subscribe to publishers using the sink(receiveCompletion:receiveValue:)
method. You saw two publishers in this section, a Publishers.Sequence<[Int], Never>
publisher and a NotificationCenter.Publisher
which publishes Notification
objects and has Never
as its error type. You also know that publishers will publish values until they emit either an error or a completion event. While this is really cool and useful already, let's look at another important key feature of Combine; transforming publishers.
Transforming publishers
When you subscribe to a publisher, you often don't want to use the values that it emits directly. Sometimes you'll want to format the output of a publisher so you can use it to update your UI, other times you need to extract some values from, for example, a notification for the published value to be useful.
Because Combine is a Functional Reactive Programming framework, it supports some of the foundational features that you might know from functional programming. Publishers support several transforming operators, like map
or flatMap
. We can use these transforming operators to transform every value that is emitted by a stream, into another value. Let's look at a marble diagram that describes how map
works in Combine:
The marble diagram above describes a publisher that emits values over time. By using the map
operator on the publisher, a new publisher is created. Its type is Publisher.Map<Upstream, Output>
. The Upstream
generic must be another publisher, and the Output
generic is the output of this new publisher. So when we use the earlier example where we subscribed to the UIApplication.keyboardDidShowNotification
, we can extract the keyboard height and push that to subscribers instead of the full Notification
object using the following code:
let publisher = NotificationCenter.default
.publisher(for: notification)
.map { (notification) -> CGFloat in
guard let endFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue else {
return 0.0
}
return endFrame.cgRectValue.height
}
When you subscribe to the created publisher
object you will receive CGFloat
values rather than Notification
objects. The type of publisher
in the above example is Publishers.Map<NotificationCenter.Publisher, CGFloat>
. In other words, its a map publisher that takes NotificationCenter.Publisher
as its Upstream
and CGFloat
as its Output
.
The general pattern in Combine is that every time you apply a transformation to a publisher, you create a new publisher that wraps the original publisher and has a new output. Sounds confusing? I know. Let me show you another example. This time we'll look at the collect
operator. Let's look at the marble diagram first:
The pictured marble diagram shows that the collect
operator takes values from a publisher, collects them into an array and sends them to its subscribers when a threshold is met. Let's look at this in a code example:
[1, 2, 3]
.publisher
.collect(2)
.sink(receiveValue: { value in
print("Received value \(value)")
})
Note that I have omitted the receiveCompletion
closure in the call to sink
above. This is perfectly fine if you're not interested in completion events from a publisher, or if you know that it will never emit an error, which the Publishers.Sequence
doesn't. After creating the sequence publisher, .collect(2)
is called on the publisher which transforms it into a Publishers.CollectByCount
publisher that wraps the original sequence publisher. This publisher uses the threshold that we supply and emits values whenever the threshold is met. The above code produces the following output in a playground:
Received value [1, 2]
Received value [3]
When a publisher completes before the threshold is met, the buffer is sent to the subscriber with the items that have been collected so far. If you don't specify a threshold at all, and call collect()
on a publisher, the publisher is transformed into a Result<Success, Failure>.Publisher
. This publisher uses an array of a publisher's output as the Success
value of Result
, and the Failure
is the publisher's Failure
. When the upstream publisher has completed, either with success or an error, the Result<Success, Failure>.Publisher
will emit a single value that contains all values that were published, or an error.
Note that the collect()
method with no threshold could cause your app to use an unbounded amount of memory if a publisher emits many events before completion. You'll usually want to specify a sensible threshold for your combine
operations.
At the beginning of this section, I mentioned the flatMap
operator. I'm not going to show how flatMap
works in today's post. The reason is simple. Using flatMap
is a fairly advanced concept where you can take a publisher that publishes other publishers, and you can use flatMap
to publish the values from all of these publishers on a single new publisher. I will demonstrate flatMap
in next week's post where we'll convert an existing networking layer to make use of Combine.
In summary
Today's post taught you the very basics of Combine. You learned what publishers are, and the basics of how they work. You learned that publishers push values to their subscribers until they have no more values to emit and are completed, or until they emit an error. You also learned that you can subscribe to a publisher using the sink(receiveCompletion:receiveValue:)
method, and that this method creates an AnyCancellable
object that contains the subscription and must be retained for as long as you need it to make sure your subscription stays alive.
After learning the basics of publishers and subscribers, I showed you the basics of how you can transform publishers of one type, into publishers of a different type, much like how map
works on an array. The ability to chain together publishers, and transform them using different functions is one of the most powerful features of FRP and you will find that these transformations truly are the heart of Combine once you start using it more often.
This post is part of an ongoing series I'm planning to do where I will gradually teach you more and more about Combine using practical examples and sometimes more theoretical overviews. You can find all the posts in this series right here. This post is the first post in this series so if you're reading this post early after I published it, there probably isn't much else to read yet. Make sure to subscribe to my newsletter below, and to follow me on Twitter to be notified when I publish new content.