Swift Combine Framework Demystified: A Beginner's Guide to Reactive Programming
Binoy Vijayan
Posted on January 23, 2024
Reactive programming is a programming paradigm that deals with asynchronous data streams and the propagation of changes. It provides a way to handle events and manage state in a more efficient and responsive manner.
Here are some fundamental concepts and principles of reactive programming:
1. Observer Pattern
Reactive programming often relies on the observer pattern, where an object (the subject) maintains a list of its dependents (observers) that are notified of any state changes. When the state of the subject changes, all its observers are automatically notified.
Here's a basic example of how the Observer Pattern works in Swift Combine:
import Combine
// Define a simple class to act as the subject
class Subject: ObservableObject {
// The @Published property wrapper is a part of Combine
@Published var value: String = "Initial Value"
}
// Create an instance of the subject
let subject = Subject()
// Use Combine to observe changes to the subject's value
let cancellable = subject.$value
.sink { newValue in
print("Received new value: \(newValue)")
}
// Modify the subject's value
subject.value = "Updated Value"
In this example:
Subject is an ObservableObject, and it has a published property called value.
The $value syntax is a shorthand to access the publisher for the value property.
The sink operator is used to subscribe to changes in the value property. It prints the new value whenever a change occurs.
When subject.value is modified, the subscribers (in this case, the sink closure) are notified, and the new value is printed.
This simple example demonstrates the basic concepts of the Observer Pattern in Swift Combine. As you modify the observed property (value in this case), all the subscribers are automatically notified of the changes.
Here are some common scenarios where Combine's Observer Pattern is often applied
UI Binding in SwiftUI
SwiftUI relies heavily on Combine for handling data flow and state changes. Often use the Observer Pattern to bind UI elements directly to the state of the data models. For example, updating a text label whenever a property in an ObservableObject changes.
struct ContentView: View {
@ObservedObject var viewModel = MyViewModel()
var body: some View {
Text(viewModel.text)
}
}
Network Requests
When making network requests using Combine's URLSession.dataTaskPublisher, you can observe and react to changes in the network request's state, such as receiving data or encountering an error.
URLSession.shared.dataTaskPublisher(for: url)
.map(\.data)
.decode(type: MyModel.self, decoder: JSONDecoder())
.sink(receiveCompletion: { completion in
// Handle completion (success or failure)
}, receiveValue: { model in
// Handle received data
})
.store(in: &cancellables)
User Authentication
When implementing user authentication, Combine can be used to observe the authentication state changes. For instance, updating the UI when a user logs in or out.
@Published var isAuthenticated: Bool = false
func loginUser() {
// Perform login logic
isAuthenticated = true
}
Form Validation
In forms or input screens, Combine can be employed to observe changes in the input fields and perform real-time validation.
@Published var username: String = ""
@Published var isUsernameValid: Bool = false
private var cancellables: Set<AnyCancellable> = []
init() {
$username
.map { $0.count >= 6 }
.assign(to: \.isUsernameValid, on: self)
.store(in: &cancellables)
}
Notification Handling
You can use Combine to observe and react to notifications, making it easier to handle events in a reactive manner.
These are just a few examples, and Combine's Observer Pattern is versatile enough to be applied in various scenarios where you need to react to changes or events in a reactive and declarative manner.
NotificationCenter.default.publisher(for: Notification.Name.someNotification)
.sink { notification in
// React to the received notification
}
.store(in: &cancellables)
2. Observable
In reactive programming, an Observable is a representation of a stream of data or events that can be observed. Observables emit data, and observers subscribe to these observables to react to the emitted data.
In Swift Combine, the concept of observables is represented by the Publisher protocol and its various implementations. A Publisher is a type that emits a sequence of values over time, and it can be observed by subscribers. The Combine framework provides several types that conform to the Publisher protocol, and these are often referred to as observables.
Here's a brief explanation of observables in Swift Combine:
Publisher Protocol
The Publisher protocol is at the core of the Combine framework. It has associated types for the emitted value and possible failure, and it declares methods to attach subscribers.
protocol Publisher {
associatedtype Output
associatedtype Failure: Error
func receive<S>(subscriber: S) where S : Subscriber, Self.Failure == S.Failure, Self.Output == S.Input
}
Common Observable Types
Just - Emits a single value and then finishes.
let justPublisher = Just("Hello, Combine!")
Future - Represents a single value or an error that will be available in the future.
let futurePublisher = Future<String, Never> { promise in
promise(.success("Hello, Future!"))
}
Publishers - Represents a sequence of values.
let sequencePublisher = Publishers.Sequence<[String], Never>(sequence: ["One", "Two", "Three"])
CombineLatest - Combines the latest values from multiple publishers.
let publisher1 = somePublisher()
let publisher2 = anotherPublisher()
let combinedPublisher = Publishers.CombineLatest(publisher1, publisher2)
Subject - A mutable object that conforms to the Publisher protocol, allowing both publishing and receiving values.
let subject = PassthroughSubject<String, Never>()
Subscribing to Observables
Subscribe to a publisher using the sink operator, which establishes a connection between the publisher and a subscriber.
let cancellable = justPublisher
.sink { value in
print("Received value: \(value)")
}
Transforming and Combining Observables
Combine provides a rich set of operators for transforming and combining publishers, allowing you to create complex data processing pipelines.
let transformedPublisher = justPublisher
.map { $0.uppercased() }
let combinedPublisher = Publishers.CombineLatest(justPublisher, futurePublisher)
.map { ($0, $1) }
Cancellation
The sink operator returns a Cancellable object. Keeping a reference to this object is important to manually cancel the subscription if needed.
var cancellables: Set<AnyCancellable> = []
let cancellable = justPublisher
.sink { value in
print("Received value: \(value)")
}
.store(in: &cancellables)
These are the fundamental concepts of observables in Swift Combine. The framework provides a powerful and declarative way to handle asynchronous and event-driven programming, and understanding these concepts is crucial for working with Combine effectively.
3. Observer
An Observer is an entity that subscribes to an Observable to receive notifications when the observable's state changes. The observer defines the actions to be taken when it receives new data, errors, or a completion signal from the observable.
In Swift Combine, the term "Observer" is often used in the context of subscribers or subscribers to a publisher. A subscriber is an object that subscribes to a publisher to receive values and completion events. The Observer Pattern is inherent in the way Combine handles asynchronous and reactive programming.
Here's how you typically work with observers in Swift Combine
Subscribing to a Publisher
You create a subscriber to a publisher using the sink operator, and the closure you provide will be called with each value emitted by the publisher.
import Combine
let publisher = Just("Hello, Combine!")
let cancellable = publisher
.sink { value in
print("Received value: \(value)")
}
In this example, the closure inside sink acts as the observer. It gets executed every time the publisher emits a new value.
Cancellation
The sink operator returns a Cancellable object, which allows you to cancel the subscription manually when it's no longer needed. If you don't store this Cancellable, the subscription is automatically cancelled when the reference to the subscriber is deallocated.
var cancellables: Set<AnyCancellable> = []
let cancellable = publisher
.sink { value in
print("Received value: \(value)")
}
.store(in: &cancellables)
Multiple Observers:
Combine allows multiple subscribers to a single publisher. Each subscriber gets its copy of the emitted values.
let cancellable1 = publisher
.sink { value in
print("Observer 1 received value: \(value)")
}
let cancellable2 = publisher
.sink { value in
print("Observer 2 received value: \(value)")
}
In this example, both Observer 1 and Observer 2 are subscribers to the same publisher.
EraseToAnyPublisher
When dealing with generic publishers, you might want to use eraseToAnyPublisher() to erase the type information and make it easier to work with in a non-generic context.
let genericPublisher: AnyPublisher<String, Never> = someGenericPublisher()
let cancellable = genericPublisher
.sink { value in
print("Received value: \(value)")
}
NotificationCenter as an Observable
Combine provides a convenient way to observe NotificationCenter events using NotificationCenter.Publisher.
import Combine
let cancellable = NotificationCenter.default.publisher(for: .someNotification)
.sink { notification in
print("Received notification: \(notification)")
}
This code sets up an observer for a specific notification using Combine.
These examples illustrate the Observer Pattern in Combine, where subscribers (observers) receive and react to values emitted by publishers. The framework leverages this pattern to provide a declarative and reactive approach to handle asynchronous and event-driven programming.
4. Stream/Event Stream
An event stream is a sequence of ongoing events over time. It can represent anything from mouse clicks and keystrokes to changes in data over a network. Observables are often used to model these event streams.
In Swift Combine, a stream or event stream refers to a sequence of values over time emitted by a publisher. Publishers in Combine emit a stream of events, and these events can include values, completion events, and errors.
Here's a breakdown of how streams and event streams are represented in Combine:
Values in a Stream:
A stream of values is emitted by a publisher over time. The values represent the changes in the state of the data.
let publisher = Just("Hello, Combine!")
In this example, the publisher emits a single value, "Hello, Combine!", forming a stream of one value.
Completion Events:
A completion event indicates the end of the stream and whether it completed successfully or encountered an error.
let publisher = Just("Hello, Combine!")
let cancellable = publisher
.sink(
receiveCompletion: { completion in
switch completion {
case .finished:
print("Stream completed successfully.")
case .failure(let error):
print("Stream failed with error: \(error)")
}
},
receiveValue: { value in
print("Received value: \(value)")
}
)
In this example, the receiveCompletion closure is called when the stream completes, providing information about whether it finished successfully or encountered an error.
Errors in a Stream
Errors indicate that something went wrong in the stream. The receiveCompletion closure is called with a .failure case to handle errors.
let errorPublisher = Fail<String, Error>(error: MyError.someError)
let cancellable = errorPublisher
.sink(
receiveCompletion: { completion in
switch completion {
case .finished:
print("Stream completed successfully.")
case .failure(let error):
print("Stream failed with error: \(error)")
}
},
receiveValue: { value in
// This closure won't be called in case of an error
}
)
In this example, the publisher is explicitly set to fail with a specific error, demonstrating how errors are handled in Combine.
Multiple Values in a Stream
A stream can emit multiple values over time.
In this example, the publisher emits a stream of values [1, 2, 3, 4, 5].
let sequencePublisher = Publishers.Sequence<[Int], Never>(sequence: [1, 2, 3, 4, 5])
let cancellable = sequencePublisher
.sink { value in
print("Received value: \(value)")
}
CombineLatest and Zip
Combine provides operators like combineLatest and zip to combine multiple streams into a single stream.
let publisher1 = somePublisher()
let publisher2 = anotherPublisher()
Publishers.CombineLatest(publisher1, publisher2)
.sink { value1, value2 in
// React to changes in both publishers
}
In this example, combineLatest combines the latest values from two publishers into a single stream.
These examples illustrate the concept of streams and event streams in Swift Combine. Publishers emit values over time, and subscribers can react to these values, completion events, and errors in a reactive and declarative manner. The combination of streams and operators in Combine provides a powerful way to handle asynchronous and event-driven programming.
5. Operators
Operators are functions that can be applied to observables to transform, filter, or combine the emitted data. Examples include map, filter, merge, zip, etc. These operators allow developers to manipulate the data stream in a declarative and composable manner.
Swift Combine provides a rich set of operators that you can use to transform, combine, and process data emitted by publishers. These operators allow you to create complex data processing pipelines in a declarative and concise manner.
Here are some commonly used operators in Combine:
i. ‘Map’ - Transforms each element emitted by a publisher.
let publisher = Just(5)
let cancellable = publisher
.map { $0 * 2 }
.sink { value in
print("Mapped value: \(value)")
}
ii. 'compactMap' - Transforms and unwraps optionals, ignoring nil values.
let publisher = ["1", "2", "three", "4"]
let cancellable = publisher
.compactMap { Int($0) }
.sink { value in
print("Parsed value: \(value)")
}
iii. ‘flatMap’ - Transforms each element into a publisher, then flattens the resulting sequence of publishers into a single sequence.
let publisher = ["apple", "banana", "orange"]
let cancellable = publisher
.flatMap { fruit in
Just(fruit.count)
}
.sink { value in
print("Length of each fruit: \(value)")
}
iv. ‘Filter’ - Filters the elements emitted by a publisher based on a provided closure.
let publisher = [1, 2, 3, 4, 5]
let cancellable = publisher
.filter { $0 % 2 == 0 }
.sink { value in
print("Even number: \(value)")
}
v. removeDuplicates - Removes consecutive duplicate elements emitted by a publisher.
let publisher = [1, 2, 2, 3, 3, 4]
let cancellable = publisher
.removeDuplicates()
.sink { value in
print("Unique values: \(value)")
}
vi. combineLatest - Combines the latest values from multiple publishers into a single stream.
let publisher1 = somePublisher()
let publisher2 = anotherPublisher()
Publishers.CombineLatest(publisher1, publisher2)
.sink { value1, value2 in
print("Combined values: \(value1), \(value2)")
}
vii. ‘Catch’ - Handles errors from a publisher by replacing them with another publisher.
let publisher = somePublisher()
let fallbackPublisher = Just("Fallback value")
let cancellable = publisher
.catch { _ in fallbackPublisher }
.sink { value in
print("Value or fallback: \(value)")
}
viii. ‘Retry’ - Retries a publisher's upstream elements on failure.
let publisher = somePublisher()
let cancellable = publisher
.retry(3)
.sink { value in
print("Value after retry: \(value)")
}
These are just a few examples of the many operators available in Swift Combine. Combine provides a comprehensive set of operators for various use cases, making it a powerful tool for handling asynchronous and event-driven programming in a reactive and declarative manner.
6. Subscription
Subscription is the act of an observer attaching itself to an observable. It establishes a connection between the observer and the observable, allowing the observer to receive notifications when the observable emits new data.
In Swift Combine, a subscription represents a connection between a publisher and a subscriber. It defines how values are produced and delivered from a publisher to a subscriber. The interaction between publishers and subscribers is established through the sink operator or other subscription-related methods.
Here's a basic overview of subscriptions in Combine:
Subscribing with sink
The most common way to establish a subscription is by using the sink operator. It creates a subscriber and connects it to the publisher, defining closures to handle emitted values and completion events.
import Combine
let publisher = Just("Hello, Combine!")
let cancellable = publisher
.sink { value in
print("Received value: \(value)")
}
In this example, the sink operator establishes a subscription by creating a subscriber that prints the received value. The cancellable variable holds a reference to the subscription, and when it's deallocated, the subscription is automatically canceled.
Cancellation
A subscription in Combine is cancellable, meaning you can manually cancel it to stop receiving updates from the publisher. The sink operator returns a Cancellable object, and you can use this object to cancel the subscription.
var cancellables: Set<AnyCancellable> = []
let cancellable = publisher
.sink { value in
print("Received value: \(value)")
}
.store(in: &cancellables)
Here, the store(in:) method is used to store the cancellable in a set. When the set or the owner of the set is deallocated, all contained cancellable are automatically canceled.
Subscribers
A subscriber is an object that receives values and completion events from a publisher. It conforms to the Subscriber protocol, which includes methods for handling these events.
import Combine
class MySubscriber: Subscriber {
typealias Input = String
typealias Failure = Never
func receive(subscription: Subscription) {
// Handle the subscription, e.g., request values
subscription.request(.unlimited)
}
func receive(_ input: String) -> Subscribers.Demand {
// Handle each received value
print("Received value: \(input)")
return .none
}
func receive(completion: Subscribers.Completion<Never>) {
// Handle the completion event
print("Received completion: \(completion)")
}
}
Custom Subscriptions
You can also create custom subscribers by conforming to the Subscriber protocol. This allows you to define how your subscriber handles values and completion events.
import Combine
class MyCustomSubscriber: Subscriber {
typealias Input = String
typealias Failure = MyError
func receive(subscription: Subscription) {
// Handle the subscription, e.g., request values
subscription.request(.max(5))
}
func receive(_ input: String) -> Subscribers.Demand {
// Handle each received value
print("Received value: \(input)")
return .none
}
func receive(completion: Subscribers.Completion<MyError>) {
// Handle the completion event or error
switch completion {
case .finished:
print("Received completion: finished")
case .failure(let error):
print("Received completion: \(error)")
}
}
}
These examples demonstrate the basics of subscriptions in Combine. Subscriptions allow you to establish connections between publishers and subscribers, enabling the flow of values and completion events in a reactive and declarative programming style.
7. Hot and Cold Observables
Cold observables start emitting data when someone subscribes, and each subscriber gets its own independent sequence of data. Hot observables, on the other hand, emit data regardless of whether there are subscribers, and all subscribers share the same sequence of data.
In the context of Swift Combine, the terms "hot" and "cold" observables are not explicitly used, but the concepts they represent in the realm of reactive programming are relevant. Let's discuss the concepts of hot and cold observables and how they might be related to Combine.
Cold Observables:
A cold observable produces values only when there is a subscriber. Each subscriber gets its own independent sequence of values.
Example:
A sequence of numbers generated by a publisher, such as an array or a Combine Publishers.Sequence:
import Combine
let coldObservable = Publishers.Sequence<[Int], Never>(sequence: [1, 2, 3, 4, 5])
When you subscribe to coldObservable, each subscriber gets its own sequence starting from the beginnin
coldObservable.sink { value in
print("Subscriber 1 received: \(value)")
}
coldObservable.sink { value in
print("Subscriber 2 received: \(value)")
}
Subscribers receive the values independently, and the sequence is replayed for each new subscriber.
Hot Observables (Connectable Publishers):
A hot observable emits values regardless of whether there are subscribers. Subscribers join the stream at whatever point it is currently.
Example:
Using a PassthroughSubject as a connectable publisher:
import Combine
let hotObservable = PassthroughSubject<Int, Never>()
You can emit values to all subscribers independently of their subscription time.
hotObservable.send(1)
let cancellable = hotObservable.sink { value in
print("Subscriber received: \(value)")
}
hotObservable.send(2)
In this case, the subscriber receives values emitted after its subscription, but it doesn't replay previous values.
Making a Cold Observable Hot (Connectable):
You can convert a cold observable into a hot observable by using the makeConnectable operator. This operator turns a publisher into a connectable publisher.
import Combine
let coldObservable = Publishers.Sequence<[Int], Never>(sequence: [1, 2, 3, 4, 5])
let connectableObservable = coldObservable.makeConnectable()
let cancellable = connectableObservable
.sink { value in
print("Subscriber 1 received: \(value)")
}
connectableObservable.connect() // This triggers the emission of values
In summary, while Combine does not explicitly use the terms "hot" and "cold" observables, the concepts are relevant. Publishers in Combine can be either cold (replay their sequence for each subscriber) or hot (emit values regardless of subscribers). The choice often depends on the use case and whether you need each subscriber to receive an independent sequence of values or whether they can join an existing stream of values.
8. Backpressure:
In reactive programming, backpressure refers to a mechanism for handling situations where a downstream subscriber is unable to keep up with the rate at which values are being emitted by an upstream publisher. Backpressure mechanisms help prevent issues like excessive memory usage or degradation of performance in scenarios where there is a significant difference in the processing speed of the producer and the consumer.
In Swift Combine, backpressure is managed automatically by the system in certain scenarios, but it's important to understand how this works.
Auto Backpressure in Combine:
Combine handles backpressure automatically in certain situations, primarily when dealing with standard publishers. The sink operator, for instance, automatically applies backpressure. When you use sink, Combine takes care of managing the demand for values, and it uses backpressure mechanisms behind the scenes.
Here's an example:
import Combine
let publisher = (1...10).publisher
let cancellable = publisher
.sink { value in
print("Received value: \(value)")
}
In this example, the sink operator automatically manages the demand for values. If the subscriber cannot keep up, the upstream publisher will adjust its rate of emitting values accordingly.
Manual Backpressure:
If you are working with custom subscribers and need more fine-grained control over backpressure, you can use the request(_:) method of the Subscription protocol. This method allows a subscriber to request a specific number of values from the publisher.
Here's a simple example:
import Combine
class MySubscriber: Subscriber {
typealias Input = Int
typealias Failure = Never
func receive(subscription: Subscription) {
subscription.request(.max(3)) // Request only 3 values initially
}
func receive(_ input: Int) -> Subscribers.Demand {
print("Received value: \(input)")
return .none
}
func receive(completion: Subscribers.Completion<Never>) {
print("Received completion")
}
}
let subscriber = MySubscriber()
let publisher = (1...10).publisher
publisher.subscribe(subscriber)
In this example, the receive(subscription:) method requests only three values initially. This is a manual way of implementing backpressure.
Keep in mind that in most cases, Combine's automatic backpressure handling is sufficient, and manual management may not be necessary. The system dynamically adjusts the demand based on the subscriber's ability to process values.
Understanding backpressure is important when dealing with reactive programming, but in many scenarios, you can rely on Combine's default mechanisms for handling it.
9. Schedulers
Schedulers are used in reactive programming to control the execution context of observables. They help manage concurrency and determine on which thread or event loop the observable should emit notifications and where the observers should receive them.
Schedulers in Swift Combine are a fundamental part of managing the execution context for Combine publishers and subscribers. They control when and on which thread or queue the various parts of a Combine pipeline operate. Schedulers help ensure thread safety, concurrency, and proper execution of asynchronous operations.
Combine provides several built-in schedulers that you can use to control the context in which publishers emit values and subscribers receive and process those values.
Here are some commonly used schedulers in Swift Combine:
DispatchQueue:
The DispatchQueue scheduler allows you to execute code on a specific dispatch queue. This is useful for handling concurrency and ensuring that certain operations run on a designated queue.
import Combine
import Foundation
let publisher = Just("Hello, DispatchQueue!")
let cancellable = publisher
.receive(on: DispatchQueue.main) // Execute on the main queue
.sink { value in
print("Received value: \(value)")
}
RunLoop
The RunLoop scheduler is used to execute code on a specific run loop. This can be useful in certain scenarios, especially in UI-related code.
import Combine
import Foundation
let publisher = Just("Hello, RunLoop!")
let cancellable = publisher
.receive(on: RunLoop.main) // Execute on the main run loop
.sink { value in
print("Received value: \(value)")
}
OperationQueue
The OperationQueue scheduler is used to execute code on a specific operation queue. This is helpful for managing operations in a queue-based manner.
import Combine
import Foundation
let publisher = Just("Hello, OperationQueue!")
let cancellable = publisher
.receive(on: OperationQueue.main) // Execute on the main operation queue
.sink { value in
print("Received value: \(value)")
}
ImmediateScheduler
The ImmediateScheduler executes code immediately on the current thread, without any delay. This is often used for testing or scenarios where you want to ensure that operations are executed immediately.
import Combine
let publisher = Just("Hello, ImmediateScheduler!")
let cancellable = publisher
.receive(on: ImmediateScheduler.shared)
.sink { value in
print("Received value: \(value)")
}
DispatchQueue vs RunLoop vs OperationQueue:
The choice between DispatchQueue, RunLoop, and OperationQueue often depends on the specific requirements of your application. For UI-related operations, you might use DispatchQueue.main or RunLoop.main. For more complex asynchronous tasks, you might use OperationQueue. The choice impacts the concurrency and execution context of your Combine pipeline.
Custom Schedulers:
You can also create custom schedulers by conforming to the Scheduler protocol. This allows you to define your own rules for scheduling operations.
import Combine
import Foundation
struct MyCustomScheduler: Scheduler {
typealias SchedulerTimeType = DispatchQueue.SchedulerTimeType
typealias SchedulerOptions = DispatchQueue.SchedulerOptions
var now: SchedulerTimeType {
return DispatchQueue.main.now
}
func schedule(options: SchedulerOptions?, _ action: @escaping () -> Void) {
DispatchQueue.main.schedule(options: options, action)
}
func schedule(after date: SchedulerTimeType, interval: SchedulerTimeType.Stride, tolerance: SchedulerTimeType.Stride, options: SchedulerOptions?, _ action: @escaping () -> Void) -> Cancellable {
return DispatchQueue.main.schedule(after: date, interval: interval, tolerance: tolerance, options: options, action)
}
}
let publisher = Just("Hello, CustomScheduler!")
let cancellable = publisher
.receive(on: MyCustomScheduler())
.sink { value in
print("Received value: \(value)")
}
In this example, a custom scheduler MyCustomScheduler is created conforming to the Scheduler protocol.
Understanding and using schedulers effectively is crucial for managing the concurrency and execution context in Combine. The appropriate choice of scheduler depends on the specific requirements of your application and the context in which your Combine pipeline is executing.
Sealing the Deal
Reactive programming is particularly useful in scenarios where there are frequent asynchronous events or changes in data, such as user interfaces, real-time applications, and distributed systems.
Posted on January 23, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
January 23, 2024