KISS

Keep It Simple Stupid

Global notification center in iOS is an anti-pattern

| comments

There is the standard class in the Apple’s Foundation framework: NotificationCenter, which is used to deliver global notifications to anyone who subscribes to them. Sounds like a great idea, doesn’t it? I think that it brings more harm than good and is used too much in iOS applications — just like the Singleton pattern. Both have their own use cases and are useful in specific circumstances, but those are quite rare. This article is about how to decrease the dependency on the global notification center.

Prelude

But first, what triggered this is I was updating a project from swift 4 to 4.2 and that required a dozen of replacements like this:

1
2
3
4
-NotificationCenter.default.addObserver(self, selector: #selector(appDidEnterBackground), name: NSNotification.Name.UIApplicationDidEnterBackground, object: nil)
-NotificationCenter.default.addObserver(self, selector: #selector(appWillEnterForeground), name: NSNotification.Name.UIApplicationWillEnterForeground, object: nil)
+NotificationCenter.default.addObserver(self, selector: #selector(appDidEnterBackground), name: UIApplication.didEnterBackgroundNotification, object: nil)
+NotificationCenter.default.addObserver(self, selector: #selector(appWillEnterForeground), name: UIApplication.willEnterForegroundNotification, object: nil)

Just a simple rename of a few system constants, however having to replace them multiple times in a project indicates at least that it’s not DRY, which in turn points to a bigger issue. That is, a number of entities are subscribed to this global event bus (notification center) and each does something when the app goes to background or foreground. Testing becomes more complicated because now your tests depend on UIKit (where the notification name is defined), the real NotificationCenter and knowing the notification names. The entity (observer) itself is also more complicated because knowing what these notifications are and where they come from and how to subscribe to them is an additional responsibility (and reason to change, as confirmed by the necessary changes during the migration). Due to the dependency on UIKit, the entity is now restricted to iOS.

Sample Downloader

Let’s take a look at some sample code (note that it’s very simplified to show the point of the post). We’ll have a class that downloads some data by an identifier, it doesn’t matter how it does it, but it does matter that an iOS app is supposed to close network connections when going to background, so the most straightforward way to handle that is:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class Downloader {
    typealias CancellationHandle = () -> ()

    func httpGet(_ url: URL, completion: @escaping (Data?) -> ()) -> CancellationHandle {
        let task = URLSession.shared.dataTask(with: url) { (data, _, _) in
            completion(data)
        }
        task.resume()

        return {
            task.cancel()
        }
    }

    /// The handle of the currently running request if any.
    private var handle: CancellationHandle?

    init() {
        NotificationCenter.default.addObserver(self, selector: #selector(appDidEnterBackgroundNotification), name: UIApplication.didEnterBackgroundNotification, object: nil)
    }

    func download(byId id: Int, completion: @escaping (String?) -> ()) {
        handle = httpGet(URL(string: "http://example.com/" + id.description)!) { data in
            completion(data.flatMap { String(data: $0, encoding: .utf8) })
        }
    }

    @objc func appDidEnterBackgroundNotification() {
        handle?()
        handle = nil
    }

    deinit {
        NotificationCenter.default.removeObserver(self)
    }
}

Here the download(byId:completion:) is the API for the user (httpGet is an implementation detail), the completion is called with the data or nil in case of an error, and the download is cancelled automatically on backgrounding by subscribing to the UIApplication.didEnterBackgroundNotification from the default NotificationCenter. The notification center is an implicit dependency, which makes it harder to reuse this downloader where it is not available, similar to this quote:

… the problem with object-oriented languages is they’ve got all this implicit environment that they carry around with them. You wanted a banana but what you got was a gorilla holding the banana and the entire jungle.

So a test for this case would look like this (I don’t show faking a slow response necessary to make this a true unit test) — we have to use the real notification center and real notifications:

1
2
3
4
5
6
7
8
9
func testBackgroundingShouldCancelCurrentRequest() {
    let sut = Downloader()

    sut.download(byId: 42) { result in
        // should not get a response
    }

    NotificationCenter.default.post(name: UIApplication.didEnterBackgroundNotification, object: nil)
}

One step to help with the tests a little is to inject the notification center property and use a separate one in tests. That doesn’t solve the main problem though. What does?

Applying SRP

Following the Single Responsibility Principle, the responsibility of knowing how to subscribe to the notifications should be extracted into a separate entity:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
protocol ApplicationStateNotifierProtocol {
    typealias Completion = () -> ()

    var onBackgrounding: Completion? { get set }
    var onForegrounding: Completion? { get set }
}

class ApplicationStateNotifier: ApplicationStateNotifierProtocol {
    var onBackgrounding: Completion?
    var onForegrounding: Completion?

    init() {
        NotificationCenter.default.addObserver(self, selector: #selector(appDidEnterBackgroundNotification), name: UIApplication.didEnterBackgroundNotification, object: nil)
        NotificationCenter.default.addObserver(self, selector: #selector(appWillEnterForegroundNotification), name: UIApplication.willEnterForegroundNotification, object: nil)
    }

    @objc func appDidEnterBackgroundNotification() {
        self.onBackgrounding?()
    }

    @objc func appWillEnterForegroundNotification() {
        self.onForegrounding?()
    }

    deinit {
        NotificationCenter.default.removeObserver(self)
    }
}

There is the protocol to be used by clients and the standard implementation — extracted from the first Downloader. Now this notifier has only one responsibility: knowing how to subscribe to the notifications and calling a completion block when they arrive. The Downloader now knows only about the two changing application states, but not where they are coming from:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Downloader {
    typealias CancellationHandle = () -> ()

    func httpGet(_ url: URL, completion: @escaping (Data?) -> ()) -> CancellationHandle {
        // the same as before
    }

    /// The handle of the currently running request if any.
    private var handle: CancellationHandle?
    private var notifier: ApplicationStateNotifierProtocol

    public init(notifier: ApplicationStateNotifierProtocol) {
        self.notifier = notifier
        self.notifier.onBackgrounding = {
            self.handle?()
            self.handle = nil
        }
    }

    func download(byId id: Int, completion: @escaping (String?) -> ()) {
        handle = httpGet(URL(string: "http://example.com/" + id.description)!) { data in
            completion(data.flatMap { String(data: $0, encoding: .utf8) })
        }
    }
}

Testing also becomes simpler by using a fake notifier where we can send the state events very easily:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class FakeApplicationStateNotifier: ApplicationStateNotifierProtocol {
    var onBackgrounding: Completion?
    var onForegrounding: Completion?
}

func testBackgroundingShouldCancelCurrentRequest() {
    let fakeNotifier = FakeApplicationStateNotifier()
    let dl = Downloader(notifier: fakeNotifier)

    dl.download(byId: 42) { result in
        // should not get a response
    }

    fakeNotifier.onBackgrounding?()
}

Improving the interactions

I prefer going one step forward and using FRP, for example with ReactiveSwift because it provides a cleaner and more uniform interface for events. First we create an enum to represent the two events we’re interested in and define a Signal that will send them (ReactiveCocoa provides a very convenient NotificationCenter.reactive.notifications(forName:) abstraction that creates Signals for notifications):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
extension UIApplication {
    enum ActivityState {
        case background
        case foreground
    }
}

func const <A, B> (_ x: A) -> (B) -> A {
    return { _ in x }
}

extension Reactive where Base: UIApplication {
    var activityStateSignal: Signal<UIApplication.ActivityState, NoError> {
        let backgroundSignal = NotificationCenter.default.reactive
            .notifications(forName: UIApplication.didEnterBackgroundNotification)
            .map(const(UIApplication.ActivityState.background))
        let foregroundSignal = NotificationCenter.default.reactive
            .notifications(forName: UIApplication.willEnterForegroundNotification)
            .map(const(UIApplication.ActivityState.foreground))

        let (signal, observer) = Signal<Signal<UIApplication.ActivityState, NoError>, NoError>.pipe()
        let combinedSignal = signal.flatten(.merge)

        [backgroundSignal, foregroundSignal].forEach(observer.send(value:))
        observer.sendCompleted()

        return combinedSignal
    }
}

The signal accessed with UIApplication.reactive.activityStateSignal will send the proper state events for the two notifications. I’m using the const function (see also the Haskell’s Prelude const function) to ignore the Notification object from the .notifications(forName:)’s signal and always return the specific state. Note that since this property returns a “cold” Signal, it will only send events when the state changes, but won’t send anything on subscribing.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Downloader {
    typealias CancellationHandle = () -> ()

    func httpGet(_ url: URL, completion: @escaping (Data?) -> ()) -> CancellationHandle {
        // the same as before
    }

    /// The handle of the currently running request if any.
    private var handle: CancellationHandle?

    public init(activityStateSignal: Signal<UIApplication.ActivityState, NoError>) {
        activityStateSignal.observeValues { activityState in
            switch activityState {
            case .background:
                self.handle?()
                self.handle = nil
            default: break
            }
        }
    }

    func download(byId id: Int, completion: @escaping (String?) -> ()) {
        handle = httpGet(URL(string: "http://example.com/" + id.description)!) { data in
            completion(data.flatMap { String(data: $0, encoding: .utf8) })
        }
    }
}

The Downloader now accepts a Signal of the application state events and observes it. Testing it doesn’t require a special fake anymore, just create a compatible Signal manually:

1
2
3
4
5
6
7
8
9
10
func testBackgroundingShouldCancelCurrentRequest() {
    let (activityStateSignal, activityStateObserver) = Signal<UIApplication.ActivityState, NoError>.pipe()
    let dl = Downloader(activityStateSignal: activityStateSignal)

    dl.download(byId: 42) { result in
        // should not get a response
    }

    activityStateObserver.send(value: .background)
}

Conclusion

If all the classes in the project were using a Signal-based or the ApplicationStateNotifierProtocol-based abstraction, they would be simpler and contain one fewer responsibility. And updating the notification names would require changes only in one place.

Comments