Weekly Swift articles, podcasts and tips by John Sundell.

Observers in Swift - Part 2

Published on 22 Apr 2018

This week, we'll continue exploring various ways to implement the observer pattern in Swift. Last week we took a look at using the NotificationCenter API and observation protocols to enable an AudioPlayer to be observed, and this week we'll do the same thing but instead focusing on multiple closure-based techniques.

If you missed last week's post, I recommend reading it first, since we'll build on the types and techniques from that post in this one.

Closures

Closures are a huge part of modern API design. From asynchronous APIs with callbacks, to functional operations (like using forEach or map on a collection) - closures are everywhere, both in the Swift standard library and in the apps we write as third party developers.

The beauty of closures is that they enable API users to capture the current context that they're in, and use it when reacting to an event - in our case when the state of our AudioPlayer changes. This enables a much more "lightweight" call site, which can lead to simpler code - but closures also come with their own set of tradeoffs.

Let's take a look at how we can add a closure-based observation API to our AudioPlayer. We'll start by defining a tuple that we'll use to keep track of arrays of observation closures that have been added for each of our three events - started, paused and stopped, like this:

class AudioPlayer {
    private var observations = (
        started: [(AudioPlayer, Item) -> Void](),
        paused: [(AudioPlayer, Item) -> Void](),
        stopped: [(AudioPlayer) -> Void]()
    )
}

We could, of course, use separate properties above - but like we took a look at in "Using tuples as lightweight types in Swift" - using tuples to group together related properties can be a really neat way to organize things in a simple manner.

Next up, let's define our observation methods. Just like our previous approaches, we'll define one method for each observation event, making things clearly separated. Each method takes a closure, and we'll continue passing the current playback item to the started and paused observers, while just passing the player itself to the stopped ones:

extension AudioPlayer {
    func observePlaybackStarted(using closure: @escaping (AudioPlayer, Item) -> Void) {
        observations.started.append(closure)
    }

    func observePlaybackPaused(using closure: @escaping (AudioPlayer, Item) -> Void) {
        observations.paused.append(closure)
    }

    func observePlaybackStopped(using closure: @escaping (AudioPlayer) -> Void) {
        observations.stopped.append(closure)
    }
}

Passing the player itself to all closures is in general a good practice, to avoid accidental retain cycles if the player is used within one of its own observation closures and we forget to capture it weakly. For more information, check out "Capturing objects in Swift closures".

With the above in place, all that remains is to call all observation closures for the matching event when the player's state changes, like this:

private extension AudioPlayer {
    func stateDidChange() {
        switch state {
        case .idle:
            observations.stopped.forEach { closure in
                closure(self)
            }
        case .playing(let item):
            observations.started.forEach { closure in
                closure(self, item)
            }
        case .paused(let item):
            observations.paused.forEach { closure in
                closure(self, item)
            }
        }
    }
}

Let's take our new API for a spin! Here's what our NowPlayingViewController (from part one of this post) looks like when we use our new closure-based observation API:

class NowPlayingViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        let titleLabel = self.titleLabel
        let durationLabel = self.durationLabel

        player.observePlaybackStarted { player, item in
            titleLabel.text = item.title
            durationLabel.text = "\(item.duration)"
        }
    }
}

Pretty nice and clean! 👍

However, there's one major downside with the above approach - there's no way to remove an observation. While that's fine for objects that own the object that they're observing (since the observed object will be deallocated along with its owner), it's not ideal for shared objects (which our AudioPlayer is likely to be), since added observations will pretty much remain forever.

Tokens

One way to enable observations to be removed is to use tokens. Just like we did in "Using tokens to handle async Swift code", we can return an ObservationToken every time an observation closure is added - which can then later be used to cancel the observation and remove the closure.

Let's start by creating the token class itself, which simply acts as a wrapper around a closure that can be called to cancel an observation:

class ObservationToken {
    private let cancellationClosure: () -> Void

    init(cancellationClosure: @escaping () -> Void) {
        self.cancellationClosure = cancellationClosure
    }

    func cancel() {
        cancellationClosure()
    }
}

Since closures don't really have a concept of identity, we need to add some way to uniquely identify an observation in order to be able to remove it. One way to do that is to simply turn our array of closures from before into dictionaries instead, with UUID values as keys, like this:

class AudioPlayer {
    private var observations = (
        started: [UUID : (AudioPlayer, Item) -> Void](),
        paused: [UUID : (AudioPlayer, Item) -> Void](),
        stopped: [UUID : (AudioPlayer) -> Void]()
    )
}

We'll then assign a new UUID value to each closure when it's inserted. To simplify this process, we can add a simple extension on Dictionary when the Key type is UUID, enabling us to perform the insert and creation of the identifier in one go:

private extension Dictionary where Key == UUID {
    mutating func insert(_ value: Value) -> UUID {
        let id = UUID()
        self[id] = value
        return id
    }
}

With the above in place, we're ready to update our observation methods to return tokens. We'll use @discardableResult to make it optional to actually use the returned token, to avoid generating any warnings when observing objects that the observer itself owns.

In each method, we'll use our insert method from above, and then create an ObservationToken instance with a closure that removes the observation, like this:

extension AudioPlayer {
    @discardableResult
    func observePlaybackStarted(using closure: @escaping (AudioPlayer, Item) -> Void)
        -> ObservationToken {
        let id = observations.started.insert(closure)

        return ObservationToken { [weak self] in
            self?.observations.started.removeValue(forKey: id)
        }
    }

    @discardableResult
    func observePlaybackPaused(using closure: @escaping (AudioPlayer, Item) -> Void)
        -> ObservationToken {
        let id = observations.paused.insert(closure)

        return ObservationToken { [weak self] in
            self?.observations.paused.removeValue(forKey: id)
        }
    }

    @discardableResult
    func observePlaybackStopped(using closure: @escaping (AudioPlayer) -> Void)
        -> ObservationToken {
        let id = observations.stopped.insert(closure)

        return ObservationToken { [weak self] in
            self?.observations.stopped.removeValue(forKey: id)
        }
    }
}

Before we're ready to give our new token-based API a try, we need to make a slight modification to our player's stateDidChange method as well, to use the values property when iterating over each dictionary (since we're not interested in the keys here):

private extension AudioPlayer {
    func stateDidChange() {
        switch state {
        case .idle:
            observations.stopped.values.forEach { closure in
                closure(self)
            }
        case .playing(let item):
            observations.started.values.forEach { closure in
                closure(self, item)
            }
        case .paused(let item):
            observations.paused.values.forEach { closure in
                closure(self, item)
            }
        }
    }
}

We can now have our NowPlayingViewController unregister itself as an observer whenever it gets deallocated, like this:

class NowPlayingViewController: UIViewController {
    private var observationToken: ObservationToken?

    deinit {
        observationToken?.cancel()
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        let titleLabel = self.titleLabel
        let durationLabel = self.durationLabel

        observationToken = player.observePlaybackStarted { player, item in  
            titleLabel.text = item.title
            durationLabel.text = "\(item.duration)"
        }
    }
}

While a token-based API can be really nice in situations when you want to dynamically be able to create and remove observations for multiple objects, as you can see above - it becomes a bit of a hassle when all we want to do is add a simple observation. Forgetting to call cancel on the token can also be a common mistake, and it requires us to keep more state in our observers (by storing the tokens).

Let's see if we can find a way to improve our solution. 🤔

The best of both worlds

Let's explore a way to keep our token-based API (since it does add value by giving us more fine-grained control over our observations), but still reduce boilerplate.

One way to do that is to tie an observation closure to the lifetime of the observer itself, without requiring the observer to conform to a specific protocol (like we did with AudioPlayerObserver last week). What we'll do is add an API that lets us pass any object as an observer, while also passing a closure, like before. We'll then capture that object weakly, and guard against it being nil in order to determine if the observation is still valid, like this:

extension AudioPlayer {
    @discardableResult
    func addPlaybackStartedObserver<T: AnyObject>(
        _ observer: T,
        closure: @escaping (T, AudioPlayer, Item) -> Void
    ) -> ObservationToken {
        let id = UUID()

        observations.started[id] = { [weak self, weak observer] player, item in
            // If the observer has been deallocated, we can
            // automatically remove the observation closure.
            guard let observer = observer else {
                self?.observations.started.removeValue(forKey: id)
                return
            }

            closure(observer, player, item)
        }

        return ObservationToken { [weak self] in
            self?.observations.started.removeValue(forKey: id)
        }
    }
}

With the above, we kind of get the best of both worlds. We can use tokens if we need to or prefer to do so, and we can also automatically remove any observations an object has registered when it gets deallocated. Now we can safely observe our player in NowPlayingViewController like this:

class NowPlayingViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        player.addPlaybackStartedObserver(self) {
            vc, player, item in

            vc.titleLabel.text = item.title
            vc.durationLabel.text = "\(item.duration)"
        }
    }
}

Above you can also see an added benefit of passing in the observer itself, even when using a closure-based approach, since this lets us pass the observer along to the closure - removing the need to manually capture self or any properties we need 👍.

Conclusion

In this two-part post we've tried multiple ways to add observation capabilities to an object - by using notifications, observation protocols, closures and tokens. There's of course many more ways to use observers in Swift - including using things like Functional Reactive Programming and Event Dispatch, so this is definitely a topic we'll return to later in upcoming posts.

Regardless if you've used any of the techniques that we've taken a look at before or not, I hope you've got some ideas or inspiration as to how observers could be used in your own code base. As we've seen, there's really no silver bullet here, and all techniques we've tried out have their own sets of advantages and tradeoffs - so it's all about picking a solution that fits best for any given use case.

What do you think? Do you already use any of the observer techniques from this two-part post, or will you try some of them out? Let me know, along with any questions, feedback or comments you might have - on Twitter @johnsundell.

Thanks for reading! 🚀