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:

  1. Emitting values
  2. Completing
  3. 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")

just publisher combine

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>()

empty publisher combine

The Empty initialiser takes a parameter completeImmediately that is true by default. Make this false 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)

error publisher combine

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.

simple data stream

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 of 0.5 publishes an event approximately every half-second.
  • runLoop: RunLoop. The run loop on which the Timer runs. Typically specified as .main to update UI. Alternatively, this could be .background if needed.
  • mode: RunLoop.Mode. The mode of the RunLoop. 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! 😎