Getting Started with Combine in Swift
The Combine Framework is huge and as such, impossible to go over in a single article. For the sake of brevity, I’ll split this into a series of posts. Consider this first one to be just an introduction to Combine — here, you’ll learn how Publishers & Subscribers work in Combine, create subscriptions for arrays, Notification Center, and finally how to create a custom publisher.
Before we dive in, make sure you can follow along! You’ll need access to:
- macOS 10.15+
- iOS 13.0+
- Swift 5
- Xcode 11
What is Combine?
Combine was introduced as a new framework by Apple at WWDC-2019. It provides a declarative Swift API for processing values over time. The framework can be compared to frameworks like RxSwift and ReactiveSwift (formally known as ReactiveCocoa).
It allows you to write functional reactive code by providing a declarative Swift API. Functional Reactive Programming (FRP) languages allow you to process values over time. You can consider examples of these kinds of values — network responses, user interface events, other types of asynchronous data.
Here’s an example of an FRP sequence:
- A network response is received
- Its data is mapped to a JSON model
- It is then assigned to the View
What is FRP (Functional Reactive Programming)?
In the FRP context, data flows from one place to another automatically through subscriptions. It uses the building blocks of Functional Programming, like the ability to map one data flow into another. FRP is very useful when data changes over time.
As an example, if you have a String variable and you want to update the text of a UILabel
, this is how you’d do it with FRP:
- Create a Subscription for the UILabel for new text values
- Push the value of the variable through the Stream (Subscription). Eventually, all the Subscribers will be notified of the new values. In our case, UILabel (one of our Subscribers) will update the received text on the UI.
FRP is also very useful in asynchronous programming and hence in UI rendering, which is based on the asynchronous response data. You generally get a response back and pass it in a completion closure. In FRP, the method you call to make a request would return a publisher that will publish a result once the request is finished. What’s the benefit here? You get rid of a heavily nested tree of completion closures.
Publishers and Subscribers
A Publisher exposes values (that can change) on which a subscriber subscribes to receive all those updates. If you relate them to RxSwift
:
- Publishers are like Observables
- Subscribers are like Observers
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.
In the below diagram:
- Each row represents a publisher
- The Circles on each line represent the values that the publisher emits
Let’s examine both of them.
- The first arrow has a line at the end. This represents a completion event. After this line, the publisher will no longer publish any new values.
- The bottom arrow ends with a cross. This represents an error event. That means, something went wrong and the publisher will now no longer publish any new events.
Let’s summarise:
- 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
The Combine Framework adds a publisher property to an 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.
[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)")
})
Foundation Framework and Combine
The Foundation Framework has extensions to work with Combine. You are already familiar with some publishers.
- A URLSessionTask: it publishes the data response or request error. Operators for JSON decoding. Notification: a publisher for a specific
- Notification.Name which publishes the notification.
- Let’s take a look at an example. Launch Swift Playground in your Xcode, and let’s get started.
NotificationCenter.Publisher
Let’s create a new publisher for a new-event notification.
extension Notification.Name {
static let newEvent = Notification.Name("new_event")
}
struct Event {
let title: String
let scheduledOn: Date
}
let eventPublisher = NotificationCenter.Publisher(center: .default, name: .newEvent, object: nil)
This publisher will listen for incoming notifications for the newEvent notification name. Now we need a subscriber.
We can create a variable called theEventTitleLabel
that subscribes to the publisher.
let theEventTitleLabel = UILabel()
let newEventLabelSubscriber = Subscribers.Assign(object: theEventTitleLabel, keyPath: \.text)
eventPublisher.subscribe(newEventLabelSubscriber)
The above code still has some errors (when you build), like:
- Swift compilation error:
No exact matches in call to instance method subscribe
The text property of the label requires receiving a String?
value while the stream publishes a Notification. Therefore, we need to use an operator:map
. Using this operator, we can change the output value from a Notification to the required String?
type.
let eventPublisher = NotificationCenter.Publisher(center: .default, name: .newEvent, object: nil)
.map { (notification) -> String? in
return (notification.object as? Event)?.title ?? ""
}
Now, the compilation error should go away.
At this point, the code looks like this:
import UIKit
import Combine
extension Notification.Name {
static let newEvent = Notification.Name("new_event")
}
struct Event {
let title: String
let scheduledOn: Date
}
let eventPublisher = NotificationCenter.Publisher(center: .default, name: .newEvent, object: nil)
.map { (notification) -> String? in
return (notification.object as? Event)?.title ?? ""
}
let theEventTitleLabel = UILabel()
let newEventLabelSubscriber = Subscribers.Assign(object: theEventTitleLabel, keyPath: \.text)
eventPublisher.subscribe(newEventLabelSubscriber)
let event = Event(title: "Introduction to Combine Framework", scheduledOn: Date())
NotificationCenter.default.post(name: .newEvent, object: event)
print("Recent event notified is: \(theEventTitleLabel.text!)")
Whenever a new event notification is received, the label “Subscriber” will update its text value. It is great to see a working example (of Publisher-Subscriber)!
Before we go to the next example(?), it is important to note that Combine comes with a lot of convenient APIs that allow us to subscribe to the publisher with fewer lines of code.
let theEventTitleLabel = UILabel()
/*
let newEventLabelSubscriber = Subscribers.Assign(object: theEventTitleLabel, keyPath: \.text)
eventPublisher.subscribe(newEventLabelSubscriber)
*/
eventPublisher.assign(to: \.text, on: theEventTitleLabel) // <-- [new code line]
The assign(to:on)
operator subscribes to the notification publisher and links to the lifetime of the label. Once the label gets released, its subscription gets released too.
Timer Subscription
Creating a Subscription
import Combine
import Foundation
import PlaygroundSupport
let subscription = Timer.publish(every: 1, on: .main, in: .common)
.autoconnect()
.sink { output in
print("finished stream with : \(output)")
} receiveValue: { value in
print("receive value: \(value)")
}
When you run the above code in Swift Playground, the output will be:
receive value: 2021–12–16 07:59:23 +0000
receive value: 2021–12–16 07:59:24 +0000
receive value: 2021–12–16 07:59:25 +0000
receive value: 2021–12–16 07:59:26 +0000
receive value: 2021–12–16 07:59:27 +0000
…
Explanation
Timer.publish()
: Returns a publisher that repeatedly emits the current date on the given interval..autoconnect
: Starts the timer when the Timer object is created.sink {}
:-
output
block: called when the subscription is finished. receiveValue
block: called when any value is published.
-
But, we want this to be stopped later. I.e. we should cancel the subscription when the task is done.
Cancelling the Subscription
1. Call cancel()
import Combine
import Foundation
import PlaygroundSupport
let subscription = Timer.publish(every: 1, on: .main, in: .common)
.autoconnect()
.print("data stream")
.sink { output in
print("finished stream with : \(output)")
} receiveValue: { value in
print("receive value: \(value)")
}
RunLoop.main.schedule(after: .init(Date(timeIntervalSinceNow: 5))) {
print(" - cancel subscription")
subscription.cancel()
}
When you run the above code, the output is:
data stream: request unlimited
data stream: receive value: (2021–12–16 08:23:28 +0000)
receive value: 2021–12–16 08:23:28 +0000
data stream: receive value: (2021–12–16 08:23:29 +0000)
receive value: 2021–12–16 08:23:29 +0000
data stream: receive value: (2021–12–16 08:23:30 +0000)
receive value: 2021–12–16 08:23:30 +0000
data stream: receive value: (2021–12–16 08:23:31 +0000)
receive value: 2021–12–16 08:23:31 +0000
data stream: receive value: (2021–12–16 08:23:32 +0000)
receive value: 2021–12–16 08:23:32 +0000
— cancel subscription
data stream: receive cancel
Explanation subscription.cancel()
: Cancel the timer to stop emitting the value.
2. Set subscription nil
import Combine
import Foundation
import PlaygroundSupport
var subscription: Cancellable? = Timer.publish(every: 1, on: .main, in: .common)
.autoconnect()
.print("data stream")
.sink { output in
print("finished stream with : \(output)")
} receiveValue: { value in
print("receive value: \(value)")
}
RunLoop.main.schedule(after: .init(Date(timeIntervalSinceNow: 5))) {
print(" - cancel subscription")
// subscription.cancel()
subscription = nil
}
This will also cancel the subscription. It is generally a use case of cancelling subscriptions when a View is going to be deallocated and a subscription is no longer needed.
Calling cancel()
frees up any allocated resources. It also stops side effects such as timers, network access, or disk I/O.
Summarising the Rules of Subscriptions
Let’s look at the rules of subscriptions.
- A subscriber can only have one subscription.
- Zero or more values can be published.
- At most, one completion will be called.
What? A Subscription with NO Completion?Yes, subscriptions can come with completion, but this is not always the case. Our Notification example is one such Publisher — it will never be complete. You can receive zero or more notifications, but there’s no real end to it.
So, what are completing publishers?The URLSessionTask
is a Publisher that completes a data response or a request error. The fact is that whenever an error is thrown from a stream, the subscription is dismissed even if the stream allows multiple values to pass through.
These rules are important to remember in order to understand the lifetime of a subscription.
Using @Published to bind values for the changes over time
@Published
is a property wrapper and the keyword adds a Publisher to any property.
Let’s take a look at a simple example of a boolean which is assigned to the UIButton
state (enabled or disabled).
class AgreementFormVC: UIViewController {
@Published var isNextEnabled: Bool = false
@IBOutlet private weak var acceptAgreementSwitch: UISwitch!
@IBOutlet private weak var nextButton: UIButton!
override func viewDidLoad() {
super.viewDidLoad()
$isNextEnabled
.receive(on: DispatchQueue.main)
.assign(to: \.isEnabled, on: nextButton)
}
@IBAction func didSwitch(_ sender: UISwitch) {
isNextEnabled = sender.isOn
}
}
To break this down:
To break this down:
The UISwitch will trigger the didSwitch(_ sender: UISwitch) method and change the isNextEnabled value to either true or false. The value of the nextButton.isEnabled is bound to the isNextEnabled property. Any changes to isNextEnabled are assigned to this isEnabled property on the main queue as we’re working with UI.
property on the main queue as we’re working with UI.
You might notice the dollar sign in front of isNextEnabled
. This allows you to access the wrapped Publisher value. From that, you can access all the operators or, like we did in the previous example, subscribe to it. Note that you can only use this @Published
property wrapper on a class instance.
Combine & Memory Management
Subscribers can retain a subscription as far as they want to receive and process values. The subscription references should be released when it is no longer needed.
In RxSwift
, a DisposeBag
is used for memory management. → In Combine, AnyCancellable
is used. The AnyCancellable
class calls cancel()
and makes sure subscriptions are terminated.“Cancelling subscriptions help avoid retain-cycles.”
Let’s update the last example to make sure the nextButton
subscription is released correctly.
class AgreementFormVC: UIViewController {
@Published var isNextEnabled: Bool = false
private var switchSubscriber: AnyCancellable?
@IBOutlet private weak var acceptAgreementSwitch: UISwitch!
@IBOutlet private weak var nextButton: UIButton!
override func viewDidLoad() {
super.viewDidLoad()
// Save the Cancellable Subscription
switchSubscriber = $isNextEnabled
.receive(on: DispatchQueue.main)
.assign(to: \.isEnabled, on: nextButton)
}
@IBAction func didSwitch(_ sender: UISwitch) {
isNextEnabled = sender.isOn
}
}
The Lifecycle of the switchSubsriber
is linked to the lifecycle of the AgreementFormVC
.
I.e. Whenever the view controller is released, the property is released as well and the cancel()
method of the subscription is called.
Storing Multiple Subscriptions
There is a good facility to handle multiple subscriptions in a class, which can be stored in a Set
.
class AgreementFormVC: UIViewController {
@Published var isNextEnabled: Bool = false
// private var switchSubscriber: AnyCancellable?
private var subscribers = Set<AnyCancellable>() // ← set of all subscriptions
@IBOutlet private weak var acceptAgreementSwitch: UISwitch!
@IBOutlet private weak var nextButton: UIButton!
override func viewDidLoad() {
super.viewDidLoad()
// Save the Cancellable Subscription
$isNextEnabled
.receive(on: DispatchQueue.main)
.assign(to: \.isEnabled, on: nextButton)
.store(in: &subscribers) // ← storing the subscription
}
@IBAction func didSwitch(_ sender: UISwitch) {
isNextEnabled = sender.isOn
}
}
In the above code, the isNextEnabled
subscription is stored in a collection of subscribers. Once theAgreementFormVC
is released, the collection is released and its subscribers get cancelled.
That wraps up our introduction to the Combine Framework. You can find all the code from this article here on GitHub. Next, we’ll take a
look at PassthroughSubject
, CurrentValueSubject
& Operators
with some examples. Stay tuned!
References
- Combine in Practice — WWDC19 — Videos — Apple Developer
- Getting Started with Combine Framework in Swift — Introduction to Functional Reactive Programming
- Getting started with Combine + UIKit in Swift
- Getting started with Combine — Donny Wals
- Getting started with the Combine framework in Swift — SwiftLee