In this post, we will look at the Publisher
protocol in Swift’s Combine framework. What is it? Why is it? đ€
Why does Publisher exist?
Combine is swift’s reactive programming framework. A reactive programming framework works with streams of data emitted over time.
Combine needs something that can model a data stream. This is the role of the Publisher
protocol.
The protocol looks like this:
protocol Publisher<Output, Failure>
For the protocol to model a data stream it needs to allow for three things:
- Emitting values
- Completing
- Failing
Notice that Publisher
is generic over <Output, Failure>
. These two associated types provide the context for emitting values and failing.
- Output is the type of value that is emitted by the
Publisher
. - Failure is the type of failure that occurs if the data stream fails. It conforms to the
Error
type.
If you have a data stream that never errors you can use the Never enum type to model this. This is useful when publishing data to the UI. Instead of errors you typically want to publish an alternative value such as a cached response, loading states, placeholder data etc.
So the fundamental reason for a Publisher
protocol is to model a data stream.
Simple Publishers
Let’s look at simple publisher structures that are often used for placeholder content, testing, debugging and enabling compilation.
Just
The Just
struct creates a straightforward publisher that will emit one value and then complete.
Just("A")
This is handy for placeholder content. For example, if you are building a view and want to eventually connect a publisher that will emit strings, you can use Just
with a string until your production-ready publisher exists.
Empty
Empty
is another convenient placeholder publisher. It immediately terminates without emitting any values.
let empty = Empty<Int>()
The
Empty
initialiser takes a parametercompleteImmediately
that istrue
by default. Make thisfalse
and you have an empty publisher that never emits a value.
Fail
The Fail
publisher will immediately fail the data stream with the specified error. It is mostly used for testing and debugging.
enum ErrorDomain: Error { case example }
let fail = Fail<Int, Error>(error: ErrorDomain.example)
Publisher from a Sequence
A data stream can be viewed as a sequence of events over time.
Therefore a Sequence
is a great source for a simple publisher.
Imagine we want a data stream that emits integers over time. The data emitted might look something like this.
A lot of people try writing Just([1, 2, 3, 4, 5])
but this would emit the whole array of integers as one event.
Thankfully Array
conforms to Sequence
. Sequence
has a convenient property called publisher
that creates a publisher that emits the elements from the source sequence.
[1, 2, 3, 4, 5].publisher
There we have it. Our first publisher emitting multiple events.
Subscribe to a Publisher
Now we know how to create publishers. Next, we want something to watch our publishers. Enter subscribers. Subscribers create subscriptions to a publisher and start observing events.
There are a few ways to subscribe to publishers. For now, we will use the sink
method.
The Sink Subscriber
sink
is an instance method of the Publisher
protocol.
If the error type is Never
then the sink
method will provide one simple closure to handle emitted values.
[1, 2, 3].publisher
.sink { int in
print(int)
}
In cases where the publisher can fail or complete, sink
provides another closure called receiveCompletion
. It allows you to process completion and failure events.
With a [1, 2, 3].publisher
the error type is Never
. In the below example, we map an error type into the publisher so we can see how sink
looks with both closures.
enum ErrorDomain: Error { case example }
[1, 2, 3].publisher
.mapError { _ in ErrorDomain.example }
.sink { result in
switch result {
case .failure(let error):
print(error)
case .finished:
print("finished")
}
} receiveValue: { int in
print(int)
}
The sink
function is really useful for basic subscriptions. It returns a type of AnyCancellable
. This is so you can manage the memory and lifecycle of the subscriptions to publishers. You can store a reference to sink
like so.
let cancellable = [1, 2, 3].publisher
.sink { int in
print(int)
}
Why Publisher is Generic
The Publisher
protocol is generic because it gives us:
- the same interface for viewing events emitted over time
- a way to emit different values and errors for each publisher
We can easily create different publishers that act in the same way but emit different values.
["a", "b", "c"].publisher // publisher of strings
[1, 2, 3].publisher // publisher of integers
[true, false, true].publisher // publisher of bools
These simple publishers are synchronous. But as Publisher
provides a common interface for events emitted over time the downstream subscriber does not need to worry about whether they are synchronous or not. A subscriber can easily observe events from synchronous and asynchronous data streams through the Publisher
interface.
In this sense Publisher
protocol provides flexibility and consistency.
Before Combine, if you wanted to do an API call using URLSessionDataTask
, you’d handle the response in a closure. If you wanted to create a Timer
you might handle the timer with a #selector
function. If you wanted to interpret UISearchBar
input, you might use a delegate. That’s three different patterns for handling events emitted over time.
By using publishers, you can model all these different types of data streams as publishers. If you map the output of all the publishers to a matching Output
and Failure
types then you can easily merge the publishers too.
Transform a Publisher with Operators
The Publisher
protocol provides access to numerous operator functions. These functions make it easy to change the type of publisher or the type of Output
and Failure
for a given publisher.
For example, we can take our publisher of integers and map it to the string type instead.
[1, 2, 3].publisher // publisher of integers
.map(String.init) // now a publisher of strings
The map
function takes the values emitted by the upstream publisher, in this case [1, 2, 3].publisher
and passes them as an input to the String.init
function (i.e. the string initialiser), which outputs a String
. So the resulting Publisher
now emits a String
rather than an Int
.
We can also change or handle error types. Take our simple Fail
publisher. If wanted to replace the emitted error we can easily use .replaceError(with:)
.
enum ErrorDomain: Error { case example }
let fail = Fail<Int, Error>(error: ErrorDomain.example)
.replaceError(with: 0)
In this example, we replace the error with 0. This also transforms the publisher Failure
type from Error
to Never
.
EraseToAnyPublisher
When you have a long chain of operators, each operator is creating a new type of publisher based on the upstream publisher and the operator itself. The chain of operators creates complicated nested types. Here’s a chain that takes a failing publisher, replaces the error with 0, creates a string, and filters the string based on whether it contains the text “Now”.
let publisher = Fail<Int, Error>(error: ErrorDomain.example)
.replaceError(with: 0)
.map { _ in "Now I am a string" }
.filter { $0.contains("Now") }
The type of this publisher is:
Publishers.Filter<Publishers.Map<Publishers.ReplaceError<Fail<Int, Error>>, String>>
Not very clear right?
To manage these nested types publishers have the eraseToAnyPublisher()
method. This is a form of “type erasure”. This method erases the complex nested types and makes the publisher appear as a simpler AnyPublisher<String, Never>
to any downstream subscribers.
Apple Foundation Publishers
The Foundation framework provides a few built-in publishers that are convenient and easy to use. Let’s take a look at URLSession’s DataTaskPublisher
, NotificationCenter.Publisher
and Timer.Publisher
.
URLSession and DataTaskPublisher
The URLSession
class provides the convenient dataTaskPublisher(for:)
method, which returns a DataTaskPublisher
based on a URL. This is most useful for basic API calls.
The dataTaskPublisher emits a tuple of (data: Data, response: URLResponse)
, much like the existing closure-based handler. The error is handled by the publisher’s failure event.
In a typical API call that returns some JSON, we map to the received data, decode to a matching type and handle errors.
Here’s how that might look.
let url = URL(string: "https://jsonplaceholder.typicode.com/posts/1")!
let cancellable = URLSession.shared
.dataTaskPublisher(for: url)
.map(\.data)
.decode(type: Post.self, decoder: JSONDecoder())
.replaceError(with: .empty)
.sink { it in
print(it)
}
Impressive how much less code it takes for a straightforward network call.
Retry an API Call
API calls on mobile devices can fail for arbitrary reasons such as network connectivity issues, device limitations, or server-side problems. In this case, you might want to retry a call. Retrying an API call in Combine is incredibly simple. Just use the retry
operator.
let cancellable = URLSession.shared
.dataTaskPublisher(for: url)
.retry(1)
NotificationCenter Publisher
The NotificationCenter
class provides the publisher(for:)
method, which returns a Publisher
based on the notification you observe. It makes it easy to observe system and application-level events.
The publisher emits the Notification
so you get access to its name, object
and userInfo
.
There are numerous notifications we could observe such as:
UIApplication.didBecomeActiveNotification
: This notification is posted when the app becomes active, i.e., after it is launched or resumed from the background.UIApplication.willResignActiveNotification
: This notification is posted when the app is about to move from the active to the inactive state, such as when a phone call is received or when a pop-up appears.UIApplication.didReceiveMemoryWarningNotification
: This notification is posted when the system is running low on memory and the app is asked to release any unnecessary resources.UIApplication.willTerminateNotification
: This notification is posted when the app is about to be terminated by the system.UIApplication.significantTimeChangeNotification
: This notification is posted when the system clock is changed.UIApplication.didFinishLaunchingNotification
: This notification is posted when the app finishes launching.UIKeyboard.willShowNotification
: This notification is posted when the keyboard is about to be shown.NSUserDefaults.didChangeNotification
: This notification is posted when the values stored in the NSUserDefaults are changed.
Let’s create a publisher based on the UIApplication.significantTimeChangeNotification
.
let systemClockChangedPublisher = NotificationCenter.default
.publisher(for: UIApplication.significantTimeChangeNotification)
This gives us a NotificationCenter.Publisher
that emits its event whenever the system clock is changed.
Timer Publisher
The Timer
class provides a publish
instance method which returns a TimerPublisher
. The function takes multiple parameters. Let’s look a the 3 parameters that are required to create a TimerPublisher
.
interval: TimeInterval
. The time interval on which to publish events. For example, a value of0.5
publishes an event approximately every half-second.runLoop: RunLoop
. The run loop on which theTimer
runs. Typically specified as.main
to update UI. Alternatively, this could be.background
if needed.mode: RunLoop.Mode
. The mode of theRunLoop
. Often specified as.common
.
A TimerPublisher
emits the current date. Here’s how one might look.
let timerPublisher = Timer.publish(every: 1, on: .main, in: .common)
.autoconnect()
ConnectablePublisher
Notice in the timerPublisher
example there is an operator called autoconnect()
. This is a method of the ConnectablePublisher
protocol. A TimerPublisher
conforms to ConnectablePublisher
. A ConnectablePublisher
doesn’t start emitting events until you call its connect()
method. This gives us control over when the publisher starts emitting events. For example, we may want to subscribe multiple subscribers to our TimerPublisher
before starting the timer itself. That way all subscribers would be in sync with the timer.
AutoConnect
The .autoconnect()
method tells the TimerPublisher
to emit events as soon as a subscriber is attached. This is convenient in situations where you do not want to wait or delay emitting events, such as connecting a publisher to UI that we want to immediately update.
In our TimerPublisher
example it means that once we subscribe something, such as sink
, the timer will start emitting events. We won’t need to call connect()
.
In contrast, let’s say you had an app that displayed up-to-date weather information. A ConnectablePublisher
provides weather information. But let’s also say some complicated UI rendering or animation happens before the weather information is displayed. In this case, you may want to call connect()
after the UI updates have finished ensuring the user is presented with the latest information.
Merge Multiple Publishers
Publisher’s common interface means it is easy to merge or “Combine” đ publishers.
Let’s take some of the publishers we talked about and merge them.
Imagine we want to log when an API call was made, when an app became active and when a timer was running, in whatever order the events are emitted.
First off we can create a publisher for each source data stream.
// API call publisher
let dataTaskPublisher = URLSession.shared.dataTaskPublisher(for: url)
.map { $0.data }
.map { try? JSONDecoder().decode(Post.self, from: $0) }
.replaceError(with: nil)
// NotificationCenter publisher
let systemClockChangedPublisher = NotificationCenter.default
.publisher(for: UIApplication.significantTimeChangeNotification)
// Timer publisher
let timerPublisher = Timer.publish(every: 1, on: .main, in: .common)
.autoconnect()
Next, we can map each publisher to a common type. In this case, we will map to a string to log. We also append .eraseToAnyPublisher()
so that downstream subscribers see a consistent type. This means they will emit matching Output
and Failure
types.
let apiCall = dataTaskPublisher
.map { _ in "API call attempted"}
.eraseToAnyPublisher()
let clockChanged = systemClockChangedPublisher
.map { _ in "System Clock Changed Notification"}
.eraseToAnyPublisher()
let timer = timerPublisher
.map { _ in "Time Emitted Event"}
.eraseToAnyPublisher()
Now we can use Publishers.Merge3
to merge all the publishers into one and subscribe to it with sink
.
let log = Publishers.Merge3(clockChanged, timer, apiCall)
let subscription = log
.sink(receiveCompletion: { completion in
print("completion: \(completion)")
}, receiveValue: { string in
print(string)
})
There you have it. Multiple disparate publishers observe unrelated asynchronous events coming together in one stream in a surprisingly small amount of straightforward Combine code.
Conclusion
The publisher protocol in the Combine framework is a neat way to handle data streams in iOS development. It’s flexible and versatile, allowing you to manipulate data streams and even merge multiple ones. Plus, Apple has made it even easier by providing some pre-built publishers for common scenarios. Hopefully, it’ll make your life a lot easier and your app will be all the better for it! đ