Implementing Dark Mode Using the Observer Pattern

banner

Last week’s update to the SoundCloud iOS app includes support for Dark Mode. This took several months of work and collaboration between design and engineering teams across the company, so we wanted to share our approach to implementing Dark Mode and some of the obstacles we encountered along the way.

Apple did an excellent job implementing Dark Mode in iOS 13, but because it’s important for us to support compatibility with previous versions of iOS, we decided to build our own implementation of themes. To achieve this, we built a fast, type-safe, and powerful observer pattern in Swift.

Observer Pattern

The observer pattern is a one-to-many relationship between objects, meaning that when one object changes its state, all of its dependent objects are notified and updated with the new state. Looking at this design in another way, the observer pattern can be seen as a delegation pattern with one-to-many delegates. We can think of it as the foundation of reactive programming.

The key objects in this pattern are the subject and the observers. A subject may have any number of dependent observers, and all observers are notified whenever the subject undergoes a change in state. It’s important to note that the observers do not access the state directly, because this can lead to dangerous race conditions.

How? It could be that while an observer is accessing the subject’s state, the state is changed from another thread. But the possible combinations of things that can go wrong here are exponential, so it’s a requirement that the subject informs the observers about state changes and never the other way around.

uml

State to Be Notified

But first we need something to notify the observers of, which is the state that we want to spread across X number of observers. In our case: We want to notify the observers when a color theme changes across the app.

Let’s start by defining an abstract Theme as our state:

protocol Theme {
    var main: UIColor { get }
}

Now we can define, for example, a concrete DarkTheme:

final class DarkTheme: Theme {
    var main: UIColor = .black
}

Observers

In our case, an observer is going to be a View or a ViewController that is interested in using colors. Following the “program to an interface” philosophy, let’s first define an observer as a protocol:

protocol ThemeObserver {
    func apply(theme: Theme)
}

Now we can implement a concrete observer — for example, in a LoginViewController:

final class LoginViewController: UIViewController, ThemeObserver {
    func apply(theme: Theme) {
        backgroundColor = theme.main
    }
}

That’s great! Now our LoginView is an observer that can be notified about Theme updates.

Subject

Here is when things get really interesting. We need to define an object that will allow observers to subscribe to state changes by providing an interface for attaching and detaching observers. In addition, the subject will provide an interface to reach observer objects when its internal state changes. In response to a state change, the subject will broadcast the new state to all subscribed observers.

Abstract Subject

Following the interface segregation principle, let’s start defining the interface for a subject that allows observers to subscribe to and detach from it. This interface is going to be visible from any observer that wants to subscribe to notifications, however, the interface will make it impossible to access the state directly, thereby preventing race conditions or undesirable state updates from the clients:

protocol Subject {
    func subscribe(observer: Themable)
    func detach(observer: Themable)
}

Concrete Subject

Internal Data Structure

We want to be able to keep a reference to each observer, but without taking care of the memory management of these objects. In essence, we need to find a data structure that holds objects weakly, in order to prevent memory leaks.

First and foremost, we want to keep one (and only one) reference to each object to prevent a double subscription of the same observer. The main reason is that multiple subscriptions of the same observer can result in multiple notifications of new states.

In addition, we need to provide a fast and cheap way to subscribe new observers.

If you’re thinking this sounds like a Set data structure, you’re correct! We needed to build a Set that can only hold objects weakly. So after some research, we found a Foundation class called NSHashTable<ObjectType> that can reference objects weakly and add and access objects in constant time, O(1):

private var observers = NSHashTable<T>.weakObjects()

To make the data structure more useful, we can extend it and create a reusable generic WeakSet class:

import Foundation

final class WeakSet<T: AnyObject>: Sequence, ExpressibleByArrayLiteral {

    private var objects = NSHashTable<T>.weakObjects()

    public init(_ objects: [T]) {
        for object in objects {
            insert(object)
        }
    }

    public required convenience init(arrayLiteral elements: T...) {
        self.init(elements)
    }

    public var allObjects: [T] {
        return objects.allObjects
    }

    public var count: Int {
        return objects.count // Note that this count may be an overestimate, as entries are not necessarily purged from the `NSHashTable` immediately.
    }

    public func contains(_ object: T) -> Bool {
        return objects.contains(object)
    }

    public func insert(_ object: T) {
        objects.add(object)
    }

    public func remove(_ object: T) {
        objects.remove(object)
    }

    public func makeIterator() -> AnyIterator<T> {
        let iterator = objects.objectEnumerator()
        return AnyIterator {
            iterator.nextObject() as? T
        }
    }
}

Unit Test

And of course, let’s unit test it!

Concrete Subject

Now that we have a solid data structure to work with, we can define a concrete subject:

struct ThemeSubject: Subject  {

    private var observers: WeakSet<ThemeObserver> = []

    func subscribe(observer: ThemeObserver) {
        observers.insert(observer)
    }

    func detach(observer: ThemeObserver) {
        observers.remove(observer)
    }
}

Now Let’s Bring Everything Together

Sweet! So far, we have created a ThemeObserver and our ThemeSubject. Now let’s provide a default way to subscribe our observers to the subject. We can do this as a protocol extension of ThemeObserver. We will define a subscribe method that will access a shared instance of ThemeSubject to subscribe our view to the subject:

extension ThemeObserver {
    func subscribeForThemeUpdates() {
        ThemeSubject.shared.subscribe(observer: self)
    }
}

Now we can add a state to our ThemeSubject. In our example, this will be Theme:

struct ThemeSubject: Subject  {

    private var observers: WeakSet<ThemeObserver> = []

    func subscribe(observer: ThemeObserver) {
        observers.insert(observer)
    }

    func detach(observer: ThemeObserver) {
        observers.remove(observer)
    }

    var state: Theme {
        didSet {
            notify()
        }
    }

    init(state: Theme) {
        self.state = state
    }

    private func notify() {
        observers.compactMap { observer in
            observer.apply(theme: state)
        }
    }
}

That’s awesome! Now every time we set a new state to ThemeSubject, all our observers’ views will receive the notification with the new theme. The code below shows how we can use this:

final class LoginViewController: UIViewController, ThemeObserver {

    override func viewDidLoad() {
        super.viewDidLoad()
        subscribeForThemeUpdates()
    }

    func apply(theme: Theme) {
        backgroundColor = theme.main
    }
}

Remember that adding new views as theme observers will be in constant time, and changing the theme across our app will be linear. Quite powerful, right?

Obstacles We Encountered along the Way

While implementing themes on the iOS app, the major problem we encountered was the inconsistency of UIColor use. This is because some parts of the app set colors on Storyboards or XIB files, while in other parts, the colors were used as global variables. This led to us putting a large amount of effort into refactoring the UI layers in order to conform to the ThemeObservable protocol.

Another problem we discovered was the inconsistency and lack of UI components. For example, we found eight different implementations of the loading spinner. We thought this was a good opportunity to unify several views into common UI components.

When We Should Apply the Observer Pattern

The observer pattern is a powerful tool that we should all have in our toolset. So I’d like to share some tips on how to identify opportunities to apply it:

  • When an abstraction has two aspects, one depends on the other. Encapsulating these aspects in separate objects lets you vary and reuse them independently.
  • When a change in one object requires changing others and we don’t know how many objects need to be changed.
  • When an object should be able to notify other objects without making assumptions about what these objects are. Or, put another way, when you don’t want these objects tightly coupled.
  • When there are a lot of layers between the subject and observer so that the delegate pattern is not practical.

Benefits

There are a handful of benefits that come from making use of the observer pattern. They include:

  • Abstract coupling between the subject and the observers. All a subject knows is that it has a list of observers, each conforming to a simple interface of the abstract Observer protocol. The subject doesn’t know the concrete class of any observer. As a result, the coupling between a subject and observers is abstract and minimal. Consequently, the observers can belong to different layers of abstraction in a system.
  • Support for broadcast communication. The notification is automatically broadcast to all interested objects that have subscribed to it.
  • Benefits from reactive programming, without a huge third-party dependency.
  • Compared to NotificationCenter, the type-safe approach that we propose can prevent runtime crashes.

Conclusion

This post details how we created a dark mode for the SoundCloud iOS app using the observer pattern. But the pattern is not just limited to this kind of scenario. In fact, here are other real-world examples of where you can apply the observer pattern:

  • A music player that wants to notify different architectural layers of the app that playback has started.
  • When views need to observe and react to changes on a database.
  • When sending notification about the app lifecycle, e.g. when the app is in the background, foreground, etc.

Do you have more ideas of other problems that can be solved with this pattern?

for question in questions {
    question.ask()!
}