Combine in swift (publisher/subscriber/operator/subject)
Combine is Apple’s declarative Swift framework designed for reactive programming. It helps manage asynchronous events by allowing developers to work with streams of values over time using concepts like publishers and subscribers. It simplifies tasks like binding data between models and views while supporting reactive programming, eliminating the need for third-party libraries like RxSwift
Example: (YouTube subscription example) Only the person who subscribes to the channel, will get the latest video content. The person who is not a subscriber, will not get video content.
Combine basics
The main component of the combines are publishers, operators, subscribers, and Subjects.
Publishers
Responsibility to publish the data to the subscribers.
A Publisher can have so many subscribers.
Types of Publishers:
In Combine, there are many built-in publishers, but you can also create custom publishers. Below are some common types of publishers:
Just Publisher: Emits exactly one value and then completes.
let publisher = Just("Hello, Combine!")
Empty Publisher: Does not emit any values but completes immediately.
let publisher = Empty<String, Never>()
Future Publisher: Emits a single value or an error at some point in the future.
let future = Future<Int, Error> { promise in
promise(.success(42))
}
Fail Publisher: Emits an error and does not emit any values.
let failurePublisher = Fail(outputType: String.self, failure: MyError.someError)
Timer Publisher: Emits a sequence of values based on a timer interval.
let timer = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()
NotificationCenter Publisher: Publishes values when a Notification
is posted.
let publisher = NotificationCenter.default.publisher(for: .myNotification)
URLSession Publisher: Publishes the result of a network request.
let publisher = URLSession.shared.dataTaskPublisher(for: myURL)
Subscribers
Subscribers are methods with closures, where we access the output values.
Example: sink, assign
Operator
In the Combine framework, operators are functions that allow you to manipulate, transform, and combine data streams emitted by publishers.
Key Concepts of Operators:
- Chaining: You can chain multiple operators together to create complex data flows. Each operator takes the output from the previous publisher and produces a modified result for the next.
- Non-destructive: Operators do not modify the original publisher but instead return a new publisher with the applied transformations.
Common Categories of Combine Operators
- Transformation Operators: These operators change the values emitted by the publisher.
- Filtering Operators: These allow values to pass through based on certain conditions.
- Combining Operators: These operators combine multiple publishers into one.
- Timing Operators: These introduce delays, throttle the rate of emissions, or work with timed data.
- Error Handling Operators: These manage errors emitted by publishers.
1. Transformation Operators
These operators modify or transform the emitted values.
a) map(_:)
Transforms the values emitted by the publisher by applying a function.
let publisher = Just(2)
.map { $0 * 2 } // Multiply each value by 2
let cancellable = publisher.sink { value in
print(value) // Output: 4
}
b) flatMap(_:)
Transforms the emitted value into a new publisher and flattens the result into a single stream.
let publisher = Just("Hello")
.flatMap { _ in Just("World") }
let cancellable = publisher.sink { value in
print(value) // Output: "World"
}
c) scan(_:_:_)
Accumulates a value across multiple emissions, similar to reduce
, but emits intermediate results.
let numbers = [1, 2, 3, 4]
let publisher = numbers.publisher
.scan(0) { $0 + $1 } // Running sum
let cancellable = publisher.sink { value in
print(value)
}
// Output: 1, 3, 6, 10
2. Filtering Operators
These operators allow values to pass based on specific conditions.
a) filter(_:)
Passes only the values that satisfy a predicate.
let publisher = [1, 2, 3, 4, 5].publisher
.filter { $0 % 2 == 0 } // Pass even numbers only
let cancellable = publisher.sink { value in
print(value) // Output: 2, 4
}
b) removeDuplicates()
Suppresses consecutive duplicate values.
let publisher = [1, 1, 2, 2, 3].publisher
.removeDuplicates()
let cancellable = publisher.sink { value in
print(value) // Output: 1, 2, 3
}
c) compactMap(_:)
Transforms the values, but ignores nil
values.
let publisher = ["1", "a", "3"].publisher
.compactMap { Int($0) } // Converts strings to Int, ignoring invalid ones
let cancellable = publisher.sink { value in
print(value) // Output: 1, 3
}
3. Combining Operators
These operators combine multiple publishers into a single one.
a) combineLatest(_:)
Combines the latest values from two or more publishers. When any publisher emits a new value, the combined publisher emits a tuple containing the latest values from each.
let publisher1 = PassthroughSubject<String, Never>()
let publisher2 = PassthroughSubject<Int, Never>()
let cancellable = publisher1.combineLatest(publisher2)
.sink { (string, number) in
print("\(string) - \(number)")
}
publisher1.send("A") // No output yet, waiting for both publishers
publisher2.send(1) // Output: A - 1
publisher1.send("B") // Output: B - 1
b) merge(with:)
Merges multiple publishers into a single stream, emitting values as they arrive from any publisher.
let publisher1 = PassthroughSubject<String, Never>()
let publisher2 = PassthroughSubject<String, Never>()
let cancellable = publisher1
.merge(with: publisher2)
.sink { value in
print(value)
}
publisher1.send("Hello") // Output: "Hello"
publisher2.send("World") // Output: "World"
c) zip(_:)
Combines values from two publishers into pairs, but only when both publishers have emitted a value.
let publisher1 = PassthroughSubject<String, Never>()
let publisher2 = PassthroughSubject<Int, Never>()
let cancellable = publisher1.zip(publisher2)
.sink { (string, number) in
print("\(string) - \(number)")
}
publisher1.send("A")
publisher2.send(1) // Output: A - 1
publisher1.send("B")
publisher2.send(2) // Output: B - 2
4. Timing Operators
These operators manage the timing of emissions.
a) delay(for:tolerance:scheduler:)
Delays the delivery of values by a specified time interval.
let publisher = Just("Delayed message")
.delay(for: .seconds(2), scheduler: RunLoop.main)
let cancellable = publisher.sink { value in
print(value) // Output after 2 seconds: "Delayed message"
}
b) debounce(for:scheduler:)
Waits for a pause in the emissions of values and then delivers the last value emitted during that period.
let publisher = PassthroughSubject<String, Never>()
let cancellable = publisher
.debounce(for: .seconds(1), scheduler: RunLoop.main)
.sink { value in
print(value)
}
publisher.send("A") // No output yet, waiting for pause
publisher.send("B") // Still no output
// Output "B" after 1 second pause
c) throttle(for:scheduler:latest:)
Emits a value from the upstream publisher at most once within the specified time interval.
let publisher = PassthroughSubject<String, Never>()
let cancellable = publisher
.throttle(for: .seconds(1), scheduler: RunLoop.main, latest: true)
.sink { value in
print(value)
}
publisher.send("A") // Output: A
publisher.send("B") // No output yet
// Output "B" after 1 second
5. Error Handling Operators
These operators help handle or recover from errors emitted by publishers.
a) catch(_:)
Replaces an error with another publisher.
let failingPublisher = Fail<String, Error>(error: NSError(domain: "", code: -1))
let cancellable = failingPublisher
.catch { _ in Just("Recovered") }
.sink { value in
print(value) // Output: "Recovered"
}
b) retry(_:)
Attempts to re-subscribe to a publisher if it fails, retrying a specified number of times.
let publisher = Fail<String, Error>(error: NSError(domain: "", code: -1))
.retry(2)
let cancellable = publisher
.sink(receiveCompletion: { print($0) }, receiveValue: { print($0) })
// Output: Prints completion after 2 retries
There is so many operators are available: https://developer.apple.com/documentation/combine/publisher
Publisher Subscriber lifecycle
We can see a function pubSubLifeCycle(), Points
A: We create a Just publisher to perform adding.
B: We create a Just subscriber to get its value.
C: We add the justSubscriber to justPublisher and add print() with publisher, that will print the whole lifecycle of the pub-sub, we can see in the console.
Publisher in detail with an example:
Just Publisher
A publisher that emits an output to each subscriber just once, and then finishes. and only one element a publisher emits.
Future Publisher
Future is a protocol.
Future can be used to asynchronously produce a single result and then complete.
It is invoked in the future when an element or error is available.
Based on promise: The promise closure receives one parameter: a `Result` that contains either a single element published by a ``Future``, or an error.
DataTaskPublisher call inside the Future publisher
Subscriber
Subscribers are methods with closures, where we access the output values.
sink:
The sink subscriber allows you to provide closures with your code that will receive output values and completions.
Assign:
Key Points About assign
:
- Reference Types Only:
assign
works only with reference types, i.e., objects that are instances of classes. This is because it needs a mutable reference to update the property.- You cannot use
assign
with value types likestruct
orenum
.
2. Properties Must Be Writable:
- The property you’re assigning the values to must be a writable property, meaning it can’t be a
let
constant. - If you want automatic updates on properties, you often use properties marked with
@Published
.
Example 1: Binding a Publisher to a Property
Let’s look at a simple example where we update a property using assign
.
Example 2: Using @Published
with assign
In a real-world application, you often use @Published
to make properties observable, especially in SwiftUI or when using Combine with UIKit.
Difference b/w assign and sink
- sink: Use when you need custom logic for handling values and completions. similar to KVC.
- assign: Use when you want to automatically assign a value to a property without extra logic.
What is AnyPublisher and eraseToAnyPublisher?
What is AnyPublisher
?
AnyPublisher
is a type-erased publisher in the Combine framework. It allows you to wrap any publisher and hide its exact type, focusing only on its output (Output
) and error (Failure
) types.
- Definition:
AnyPublisher<Output, Failure>
Output
: The type of values the publisher emits.Failure
: The type of error it can emit (must conform to theError
protocol).
Purpose of AnyPublisher
- Type Abstraction: It simplifies complex types in Combine chains by hiding the underlying publisher type.
- Protocol Conformance: It helps in defining protocols or APIs that work with Combine publishers.
- Consistency: You can standardize return types without exposing internal details.
Why Use AnyPublisher
?
When using Combine, publisher chains can grow in complexity and have deeply nested types. Without AnyPublisher
, returning the full type signature can make your code unreadable and hard to maintain.
Example Without AnyPublisher
func fetchData() -> Publishers.MapError<
Publishers.MapKeyPath<URLSession.DataTaskPublisher, Data>,
MyError
>
By using AnyPublisher
, you can simplify the return type:
func fetchData() -> AnyPublisher<Data, MyError>
How to Create an AnyPublisher
?
The most common way to create an AnyPublisher
is by using the eraseToAnyPublisher()
operator.
eraseToAnyPublisher()
is a Combine operator that converts any publisher into an AnyPublisher
. This method allows you to perform type-erasure on a publisher.
- It’s used to hide the type of the publisher chain while retaining its functionality.
Usage
- To hide complex publisher types in function return values.
- To enforce abstraction and encapsulation in Combine-based APIs.
How to Create an AnyPublisher
- Using
eraseToAnyPublisher()
You can convert any Combine publisher into anAnyPublisher
usingeraseToAnyPublisher()
.
import Combine
let publisher = Just(42)
.map { $0 * 2 }
.eraseToAnyPublisher() // Converts the chain into AnyPublisher<Int, Never>
- Explicit Initialization You can explicitly create an
AnyPublisher
from a custom publisher:
let publisher = AnyPublisher<Int, Never>(Just(42))
Usage of AnyPublisher
- Returning from Functions
func fetchData() -> AnyPublisher<Data, URLError> {
URLSession.shared.dataTaskPublisher(for: URL(string: "https://example.com")!)
.map(\.data)
.eraseToAnyPublisher()
}
- In Protocols
protocol APIService {
func fetchData(url: String) -> AnyPublisher<Data, Error>
}
class NetworkService: APIService {
func fetchData(url: String) -> AnyPublisher<Data, Error> {
// Example implementation
Just(Data())
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
}
- In Combine Pipelines Combine pipelines often involve complex chains of publishers. To keep the return type manageable, you use
AnyPublisher
.
Example with Error Handling
Here’s a more detailed example that uses AnyPublisher
to handle errors gracefully:
import Combine
import Foundation
enum MyError: Error {
case invalidURL
case networkError
}
func fetchRemoteData(from urlString: String) -> AnyPublisher<Data, MyError> {
guard let url = URL(string: urlString) else {
return Fail(error: MyError.invalidURL)
.eraseToAnyPublisher()
}
return URLSession.shared.dataTaskPublisher(for: url)
.map(\.data)
.mapError { _ in MyError.networkError } // Map URLSession error to MyError
.eraseToAnyPublisher()
}
// Usage
let cancellable = fetchRemoteData(from: "https://example.com")
.sink(receiveCompletion: { completion in
switch completion {
case .finished:
print("Finished successfully!")
case .failure(let error):
print("Error: \(error)")
}
}, receiveValue: { data in
print("Received data: \(data)")
})
Cheet-Sheet
Common Use Cases
- Fetching Data from API:
func fetchData() -> AnyPublisher<Data, URLError> {
URLSession.shared.dataTaskPublisher(for: URL(string: "https://example.com")!)
.map(\.data)
.eraseToAnyPublisher()
}
2. Transforming Data:
let publisher = [1, 2, 3, 4, 5].publisher
publisher.map { $0 * 2 }.sink { print($0) }
3. Error Handling:
let publisher = Fail<Int, Error>(error: MyError.example)
publisher.replaceError(with: -1).sink { print($0) }
4. Debouncing User Input:
textFieldPublisher
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.sink { print($0) }
.store(in: &cancellables)
5. Combine Multiple Publishers:
publisher1.combineLatest(publisher2)
.sink { print("Publisher 1: \($0), Publisher 2: \($1)") }
Common Patterns
- ViewModel with Combine:
class ViewModel {
@Published var searchText: String = ""
private var cancellables = Set<AnyCancellable>()
init() {
$searchText
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.sink { print("Search text: \($0)") }
.store(in: &cancellables)
}
}
- Binding UI Updates:
$searchResults
.receive(on: RunLoop.main)
.sink { [weak self] results in
self?.updateUI(with: results)
}
.store(in: &cancellables)
Find GitHub Source Code related to CombineLatest.
Important combine tutorial link:
GitHub Source Code
https://github.com/Nirajpaul2/Combine-in-Swift