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.
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.
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
}
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.
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.
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)
}
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
}
}
}
And of course, let’s unit test it!
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)
}
}
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?
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.
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:
There are a handful of benefits that come from making use of the observer pattern. They include:
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.NotificationCenter
, the type-safe approach that we propose can prevent runtime crashes.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:
Do you have more ideas of other problems that can be solved with this pattern?
for question in questions {
question.ask()!
}