Observers in Swift - Part 1
Often when building apps, we find ourselves in situations when we need to set up a one-to-many relationship between objects. It can be when multiple objects want to react to changes in the same state, or when a certain event needs to be broadcasted to different parts of a system.
In such situations, it's very common to want to add some way for certain objects to be observed. Like with most programming techniques, there are multiple ways to add such observation capabilities to objects in Swift - and they all come with a different set of strengths & tradeoffs. We'll start by taking a look at two techniques this week, and then next week we'll continue with a couple of other ones.
Let's dive in! 😀
The use case
To be able to make a direct comparison between all the various observation techniques we'll try out, we're going to use the same use case for all of them. The example we'll use is an AudioPlayer
class, that lets other objects observe its PlaybackState
. Whenever the player starts playing, pauses or finishes playback, we want to notify its observers of the change in state. This will enable multiple objects to tie their logic to the same player - for example lists displaying playable items, a player UI and something like a "mini-player" being displayed at the bottom of the screen.
Here's what our AudioPlayer
looks like:
class AudioPlayer {
private var state = State.idle {
// We add a property observer on 'state', which lets us
// run a function on each value change.
didSet { stateDidChange() }
}
func play(_ item: Item) {
state = .playing(item)
startPlayback(with: item)
}
func pause() {
switch state {
case .idle, .paused:
// Calling pause when we're not in a playing state
// could be considered a programming error, but since
// it doesn't do any harm, we simply break here.
break
case .playing(let item):
state = .paused(item)
pausePlayback()
}
}
func stop() {
state = .idle
stopPlayback()
}
}
Above you can see that we use the technique from "Modelling state in Swift" to use an enum to model the internal state of AudioPlayer
. This enables us to get rid of optionals and multiple sources of truth - and instead gives us clear, well-defined states that the player can be in. Here's what our State
enum looks like:
private extension AudioPlayer {
enum State {
case idle
case playing(Item)
case paused(Item)
}
}
Above you can see that we call a stateDidChange()
method each time the state of the player changes. Our main task will be to fill in that method with various implementations depending on the different techniques we'll try out.
NotificationCenter
The first technique we'll take a look at is using the built-in NotificationCenter
API to broadcast notifications whenever the playback state changes. Like many other system-level Apple APIs, NotificationCenter
is singleton-based, but since we'll want to enable our AudioPlayer
class to be tested (and also to make it clear that it depends on NotificationCenter
), we'll inject a notification center instance in the initializer of AudioPlayer
, like this:
class AudioPlayer {
private let notificationCenter: NotificationCenter
init(notificationCenter: NotificationCenter = .default) {
self.notificationCenter = notificationCenter
}
}
To learn more about dependency injection, check out "Different flavors of dependency injection in Swift".
NotificationCenter
uses named notifications to identify what event is either observed or triggered. To avoid having to use inline strings as part of our API, we'll add an extension on NotificationCenter.Name
to have a single source of truth for our notification names. We'll add one for when playback started, one for when it was paused and one for when it stopped:
extension Notification.Name {
static var playbackStarted: Notification.Name {
return .init(rawValue: "AudioPlayer.playbackStarted")
}
static var playbackPaused: Notification.Name {
return .init(rawValue: "AudioPlayer.playbackPaused")
}
static var playbackStopped: Notification.Name {
return .init(rawValue: "AudioPlayer.playbackStopped")
}
}
The above extension will also enable users of our API to easily refer to one of our notification names with dot syntax; like .playbackStarted
, which is always really nice.
With the above extension in place, we can now start posting notifications. We'll fill in our stateDidChange()
method from before, and check the current state
to see what type of notification we should post. For the playing
and paused
states, we'll also pass the currently playing item as the notification's object
:
private extension AudioPlayer {
func stateDidChange() {
switch state {
case .idle:
notificationCenter.post(name: .playbackStopped, object: nil)
case .playing(let item):
notificationCenter.post(name: .playbackStarted, object: item)
case .paused(let item):
notificationCenter.post(name: .playbackPaused, object: item)
}
}
}
That's it! 👍 We can now easily observe the current state of playback from pretty much anywhere in our code base. As an example, here's how we might have a NowPlayingViewController
observe whenever playback started, to display the current title and duration:
class NowPlayingViewController: UIViewController {
deinit {
// If your app supports iOS 8 or earlier, you need to manually
// remove the observer from the center. In later versions
// this is done automatically.
notificationCenter.removeObserver(self)
}
override func viewDidLoad() {
super.viewDidLoad()
notificationCenter.addObserver(self,
selector: #selector(playbackDidStart),
name: .playbackStarted,
object: nil
)
}
@objc private func playbackDidStart(_ notification: Notification) {
guard let item = notification.object as? AudioPlayer.Item else {
let object = notification.object as Any
assertionFailure("Invalid object: \(object)")
return
}
titleLabel.text = item.title
durationLabel.text = "\(item.duration)"
}
}
The main advantages of using NotificationCenter
-based notifications is that they're quite easy to implement - both internally in the object that is being observed, and for anyone who wants to start observing it. It's also an API that most Swift developers are familiar with, since Apple themselves use it to deliver many types of system notifications, like keyboard events.
However, there are also some significant downsides of this approach. First of all, since NotificationCenter
is an Objective-C API, it can't use Swift features like generics to retain type safety. While this is something that can always be implemented on top (by creating some form of wrapper), the default way to use it requires us to do type casting like we do in the first few lines of playbackDidStart
above. This makes our code quite fragile, since we can't leverage the compiler to make sure that both our observers and the object being observed are using the same types for the broadcasted values.
Speaking of broadcasting, another downside of using NotificationCenter
is the fact that notifications are broadcasted app-wide without much containment. While this can be really convenient (you can observe any object from anywhere), it makes the relationships between the objects participating in the observation a lot more loose, making it much harder to maintain clear separation between various parts of an app - especially as the code base grows.
Observation protocols
Next up, let's take a look at how protocols can be used to create a bit more rigid and well-defined observation APIs. When using this technique we'll require all objects that are interested in observing our AudioPlayer
to conform to an AudioPlayerObserver
protocol. Just like how we defined three separate notifications for each playback state, we'll define three methods that can be used to observe each event, like this:
protocol AudioPlayerObserver: class {
func audioPlayer(_ player: AudioPlayer,
didStartPlaying item: AudioPlayer.Item)
func audioPlayer(_ player: AudioPlayer,
didPausePlaybackOf item: AudioPlayer.Item)
func audioPlayerDidStop(_ player: AudioPlayer)
}
To make it possible to pick just a single event to observe, we'll also use a protocol extension to add default (empty) implementations for each event:
extension AudioPlayerObserver {
func audioPlayer(_ player: AudioPlayer,
didStartPlaying item: AudioPlayer.Item) {}
func audioPlayer(_ player: AudioPlayer,
didPausePlaybackOf item: AudioPlayer.Item) {}
func audioPlayerDidStop(_ player: AudioPlayer) {}
}
Weak storage
When designing observation APIs, it's usually a good practice to only keep weak references to all observers. Otherwise, it's easy to introduce retain cycles when the owner of an observed object is also an observer itself. However, storing objects weakly in a Swift collection in a nice way is not always straight forward, since by default all collections retain their members strongly.
To solve this problem for our observation needs, we're going to introduce a small wrapper type, that simply keeps track of an observer with a weak reference:
private extension AudioPlayer {
struct Observation {
weak var observer: AudioPlayerObserver?
}
}
Using the above type, we can now add a collection of observations to our AudioPlayer
. In this case we'll pick a Dictionary
with ObjectIdentifier
keys to get constant time inserts and removals of observers:
class AudioPlayer {
private var observations = [ObjectIdentifier : Observation]()
}
ObjectIdentifier
is a built-in value type that acts as a unique identifier for a given instance of a class. To learn more - check out "Identifying objects in Swift".
We can now implement stateDidChange()
by iterating over all observations and calling the protocol method that corresponds to the current state. Worth noting is that, while we are iterating, we'll also take the opportunity to clean up any unused observations (in case the corresponding object has been deallocated).
private extension AudioPlayer {
func stateDidChange() {
for (id, observation) in observations {
// If the observer is no longer in memory, we
// can clean up the observation for its ID
guard let observer = observation.observer else {
observations.removeValue(forKey: id)
continue
}
switch state {
case .idle:
observer.audioPlayerDidStop(self)
case .playing(let item):
observer.audioPlayer(self, didStartPlaying: item)
case .paused(let item):
observer.audioPlayer(self, didPausePlaybackOf: item)
}
}
}
}
Observing
Finally, we need a way for objects conforming to AudioPlayerObserver
to register themselves as observers. We'll also want an easy way for objects to unregister themselves, in case they're no longer interested in updates. To achieve both of these things, we'll add an extension on AudioPlayer
that adds two new methods:
extension AudioPlayer {
func addObserver(_ observer: AudioPlayerObserver) {
let id = ObjectIdentifier(observer)
observations[id] = Observation(observer: observer)
}
func removeObserver(_ observer: AudioPlayerObserver) {
let id = ObjectIdentifier(observer)
observations.removeValue(forKey: id)
}
}
That's it! 🎉 We can now update NowPlayingViewController
to use our shiny new observation protocol instead of using NotificationCenter
:
class NowPlayingViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
player.addObserver(self)
}
}
extension NowPlayingViewController: AudioPlayerObserver {
func audioPlayer(_ player: AudioPlayer,
didStartPlaying item: AudioPlayer.Item) {
titleLabel.text = item.title
durationLabel.text = "\(item.duration)"
}
}
As you can see above, the main advantage of using an explicit observation protocol instead of relying on NotificationCenter
is that we get full compile time type safety. Since our protocol uses the AudiPlayer.Item
type directly, we no longer need to do any type casting in our observation method - resulting in a lot more clear and solid code.
Adding an explicit observation API can also improve discoverability of how an observable class works. Instead of having to figure out that NotificationCenter
should be used, it's pretty clear by looking at the API in our example that you're supposed to conform to AudioPlayerObserver
to observe the player.
However, one downside of this approach is that it requires more code internally within AudioPlayer
than when using NotificationCenter
. It also requires the introduction of additional protocols and types, which may be a downside if a code base is relying a lot on observations.
To be continued...
In part two, we'll continue by taking a look at more ways that observers can be implemented in Swift. Like always, I encourage you to experiment with these various techniques yourself to see how they can impact your code base, and which set of pros & cons that are the best fit for your project.
What do you think? Are you using one of these two observation techniques already, or will you try them out? Let me know, along with any other questions, comments or feedback that you might have - on Twitter @johnsundell.
You can find part two of this post here.
Thanks for reading! 🚀