🔔

NotificationCenterを型安全に扱う

2023/12/12に公開

はじめに

iOSからのイベントを監視したい場合など、NotificationCenterの使用を避けられない場合があります。
NotificationCenter自体はObjective-Cの時代から存在していたため、そのインターフェースにはObjecvtive-Cの名残があり、Swiftの型安全の思想とは若干の乖離があります。

この記事では、NotificationCenterを型安全に扱うための試行錯誤を紹介したいと思います。

NotificationCenterのおさらい

まずは簡単にNotificationCenterのおさらいをしたいと思います。
NotificationCenterはアプリの中で発生したイベントを通知したり、監視したりするためのクラスです。

NotificationCenterの使い方

簡単な使い方を見てみましょう。
ここでは、「DBの中のTodoというエンティティが更新される直前の通知」を例に説明します。

通知の定義

まずは通知を定義します。

let todoWillUpdateNotification = Notification.Name("todoWillUpdateNotification")

通知の発行

次にDBが更新される時に通知を発行します。

let database: Database = ...
let insertedTodos: [Todo] = ...
let deletedTodos: [Todo] = ...
let modifiedTodos: [Todo] = ...

NotificationCenter.default.post(
    name: todoWillUpdateNotification,
    object: database,
    userInfo: [
        "insert": insertedTodos,
        "delete": deletedTodos,
        "modify": modifiedTodos
    ]
)

postに渡している引数の説明をします。

  • name: 発行する通知の名前です。事前に定義したtodoWillUpdateNotificationを渡しています
  • object: 通知の送信者です。今回の場合、データベースの変更に関する通知なので、送信者にDatabaseを渡しています。
  • userInfo: 通知に関する情報です。今回の場合、追加/削除/変更されるTodoの情報を渡しています。

通知の監視

通知を監視するには、selectorを用いた方法、クロージャーを用いた方法、Combineを用いた方法、Swift concurrencyを用いた方法があります。
ここではCombineを用いた方法を紹介します。

let cancelable = NotificationCenter.default.publisher(
    for: todoWillUpdateNotification,
    object: database
)
.sink { notification in
    let insertedTodos = (notification.userInfo?["insert"] as? [Todo]) ?? []
    let deletedTodos = (notification.userInfo?["delete"] as? [Todo]) ?? []
    let modifiedTodos = (notification.userInfo?["modify"] as? [Todo]) ?? []
    // ...
}

publisherに渡している引数の説明をします。

  • for: 監視する通知の名前です。
  • object: 通知の送信者を指定できます。特定の送信者に関する通知を受け取りたい場合に指定します。nilを渡した場合、送信者に限らず全ての通知を監視します。

何が辛いのか

NotificationCenterの辛いところの一つに、型安全ではないことが挙げられます。

例えば、userInfoから値を取り出す際には、[AnyHaashable: Any]から適切なキーを用いて適切にキャストする必要があります。

let insertedTodos = (notification.userInfo?["insert"] as? [Todo]) ?? []
let deletedTodos = (notification.userInfo?["delete"] as? [Todo]) ?? []
let modifiedTodos = (notification.userInfo?["modify"] as? [Todo]) ?? []

つまり、通知を監視するには、開発者はそのuserInfoがどのような構造なのか把握する必要があります。

これは通知を発行する側にもいえます。
どのような構造のuserInfoを期待して監視しているのかを把握しながら通知を発行する必要があります。

また、もしリファクタリング等でuserInfoの構造を変えてしまっても、コンパイラエラーにならないので、自力で監視している場所を全て調べて修正する必要があります。

引数のobjectについても同様です。
objectの型はAnyなので、どのような型がobjectになるのかコンパイラは保証してくれません。

解決策

ここからは、どのようにこれらの問題を解決するのか解説していきます。

理想のインターフェース

まずは通知を発行する理想のインターフェースについて考えてみます。
関数のインターフェースや設計を考える際に、呼び出し側がどうあるべきかを一度考えてみるのは良いことです。
今回も実装の詳細は一旦置いといて、どのように通知を発行できるべきか、呼び出し側の観点から考えてみます。

今回抑えたいポイントは「userInfoやobjectを型安全にし、もし予期していない型が渡されたらコンパイルエラーにする」です。

それを踏まえると、このような呼び出しが理想ではないでしょうか?

struct TodoUpdateNotificationContent {
    let insert: [Todo]
    let delete: [Todo]
    let modify: [Todo]
}

let content: TodoUpdateNotificationContent = ...

NotificationCenter.default.post(
    name: todoWillUpdateNotification,
    object: database, // Database以外の型を渡すとコンパイルエラーになる
    userInfo: content // TodoWillUpdateNotificationContent以外の型を渡すとコンパイルエラーになる
)

もう少し観察してみましょう。
一度別の通知についても考えてみます。
例えば、authenticationStateChangeNotificationという「アカウントの認証状態が変わった時の通知」を考えます。
この通知に渡すobjectやuserInfoの型はDatabaseやTodoUpdateNotificationContentではないはずです。

つまり「nameに渡す値が変われば、objectやuserInfoに渡せる型も変わってくる」ということです。

実装

ここまでを踏まえると、このような実装が考えられます。

// -------- 通知の発行 --------

struct TypedNotificationDefinition<Object, UserInfo> {    
    var name: Notification.Name
    var encode: (UserInfo) -> [AnyHashable: Any]
    var decode: ([AnyHashable: Any]) throws -> UserInfo
}

extension NotificaationCenter {
    func post<Object, UserInfo>(
        _ definition: TypedNotificationDefinition<Object, UserInfo>,
        object: Object?,
        userInfo: UserInfo
    ) {
        let rawUseInfo = definition.encode(userInfo)
        post(name: definition.name, object: object, userInfo: rawUseInfo)
    }
}

// -------- 通知の監視 --------

struct TypedNotification<Object, UserInfo> {
    let name: Notification.Name
    let object: Object?
    let userInfo: UserInfo
}

extension NotificationCenter {
    func publisher<Object: AnyObject, UserInfo>(
        _ definition: TypedNotificationDefinition<Object, UserInfo>,
        object: Object? = nil
    ) -> AnyPublisher<TypedNotification<Object, UserInfo>, Never> {
        publisher(for: definition.name, object: object)
            .compactMap { notification in
                do {
		    let userInfo = try definition.decode(notification.userInfo ?? [:])
                    return TypedNotification(
                        name: notification.name,
                        object: notification.object as? Object,
                        userInfo: userInfo
                    )
                } catch {
                    assertionFailure("userInfoのパースに失敗")
                    return nil
                }
            }
            .eraseToAnyPublisher()
    }
}

先ほど例に出したtodoWillUpdateNotificationが、この実装だとどうなるのか見てみましょう。

まずは、TypedNotificationDefinitionの変数を定義します。
この変数には、通知の名前とuserInfoのencode/decodeのロジックが書かれています。
(static varでextensionに定義しているのは.(ドット)でコード補完を効かせるためです。)

extension TypedNotificationDefinition {
    static var todoWillUpdateNotification: TypedNotificationDefinition<Database, TodoUpdateNotificationContent> {
        .init(
            name: Notification.Name("todoWillUpdateNotification"),
            encode: { content in
                [
                    "insert": content.insert,
                    "delete": content.delete,
                    "modify": content.modify
                ]
            },
            decode: { userInfo in
                let insert = (userInfo["insert"] as? [Todo]) ?? []
                let delete = (userInfo["delete"] as? [Todo]) ?? []
                let modify = (userInfo["modify"] as? [Todo]) ?? []
                return TodoUpdateNotificationContent(insert: insert, delete: delete, modify: modify)
            }
        )
    }
}

依然としてuserInfoの変換ロジックを書かなければいけないものの、そのロジックがこのTypedNotificationDefinitionの中に閉じていることがポイントです。
この変数を利用して通知を発行したり監視したりする時に、呼び出し側がそのuserInfoの構造について気にする必要はなくなりました。

そして、通知の発行と監視はこのようにします。
先ほど理想に掲げたような型安全なインターフェースを実現できていると思います。

let databae: Databse = ...

// 通知の発行
let content: TodoUpdateNotificationContent = ...
NotificationCenter.default.post(
    .todoWillUpdateNotification,
    object: database, // Database以外を渡すとコンパイルエラーになる
    userInfo: content // TodoUpdateNotificationContent以外を渡すとコンパイルエラーになる
)

// 通知の監視
let cancelable = NotificationCenter.default.publisher(
    .todoWillUpdateNotification, 
    object: database // Database以外を渡すとコンパイルエラーになる
)
.sink { notification in
    // ↓ 型安全にuserInfoから値が取れる
    let insertedTodos: [Todo] = notification.userInfo.insert
}

使い勝手を良くする

さて当初の型安全にする目的は達成できましたが、使い勝手に関してはまだ改善ができそうです。
いくら安全なコードでも、その使い勝手が良くないと (未来の自分を含めた) 他の開発者に使ってもらえません。

UserInfoの構造の重複

今の実装でまず最初に改善できそうなのは、userInfoの変換ロジックを各TypedNotificationDefinitionに書かなければいけないことです。

iOSが発行する通知には共通のuserInfoの構造を持つものがあります。
例えば、キーボードを開いた時のkeyboardWillShowNotificationと、キーボードを閉じた時のkeyboardDidShowNotificationは同じuserInfoの構造をしています。
今回は例として、todoWillUpdateNotificationに加えて、全く同じUserInfoの構造をしたtodoDidUpdateNotificationを追加する例を考えます。

すると以下のようなコードが考えられます。
各通知の定義に対して毎回、encodeとdecodeを書くのはちょっとしたことですが手間なことがわかります。

extension TypedNotificationDefinition {
    static var todoWillUpdateNotification: TypedNotificationDefinition<Database, TodoUpdateNotificationContent> {
        .init(
            name: Notification.Name("todoWillUpdateNotification"),
            encode: { content in convertToUserInfo(from: content) },
            decode: { userInfo in try convertToContent(from: userInfo) }
        )
    }
    
    static var todoDidUpdateNotification: TypedNotificationDefinition<Database, TodoUpdateNotificationContent> {
        .init(
            name: Notification.Name("todoDidUpdateNotification"),
            encode: { content in convertToUserInfo(from: content) },
            decode: { userInfo in try convertToContent(from: userInfo) }
        )
    }
 }
 
func convertToUserInfo(from content: TodoUpdateNotificationContent) -> [AnyHashable: Any] {
    [
        "insert": content.insert,
        "delete": content.delete,
        "modify": content.modify
    ]
}

func convertToContent(from userInfo: [AnyHashable: Any]) throws -> TodoUpdateNotificationContent {
    let insert = (userInfo["insert"] as? [Todo]) ?? []
    let delete = (userInfo["delete"] as? [Todo]) ?? []
    let modify = (userInfo["modify"] as? [Todo]) ?? []
    return TodoUpdateNotificationContent(insert: insert, delete: delete, modify: modify)
}

これを共通化する方法としてはprotocolを使う方法が良さそうです。
encodeとdeocdeの処理がしているのは自分が定義した型とuserInfoの相互変換です。
なので、UserInfoRepresentableという型を用意してみましょう。

protocol UserInfoRepresentable {
    var userInfo: [AnyHashable: Any] { get }

    init(userInfo: [AnyHashable: Any]) throws
}

さらに先ほど定義したTypedNotificationDefinitionにUserInfoRepresentableに特化したinitを追加します。

extension TypedNotificationDefinition where UserInfo: UserInfoRepresentable {
    init(name: Notification.Name) {
        self.init(
            name: name,
            encode: { content in content.userInfo },
            decode: { userInfo in try UserInfo(userInfo: userInfo)}
        )
    }
}

そして自分が用意している型をUserInfoRepresentableに準拠させます。

struct TodoUpdateNotificationContent: UserInfoRepresentable {
    let insert: [Todo]
    let delete: [Todo]
    let modify: [Todo]
    
    var userInfo: [AnyHashable : Any] { 
        [
            "insert": content.insert,
            "delete": content.delete,
            "modify": content.modify
        ]
    }
    
    init(userInfo: [AnyHashable : Any]) throws {
        let insert = (userInfo["insert"] as? [Todo]) ?? []
        let delete = (userInfo["delete"] as? [Todo]) ?? []
        let modify = (userInfo["modify"] as? [Todo]) ?? []
        self.init(insert: insert, delete: delete, modify: modify)
    }
}

最後に通知の定義をします。かなりスッキリしたことがわかります。

extension TypedNotificationDefinition {
    static var todoWillUpdateNotification: TypedNotificationDefinition<Database, TodoUpdateNotificationContent> {
        .init(name: Notification.Name("todoWillUpdateNotification"))
    }
    
    static var todoDidUpdateNotification: TypedNotificationDefinition<Database, TodoUpdateNotificationContent> {
        .init(name: Notification.Name("todoDidUpdateNotification"))
    }
 }

UserInfoの変換ロジック

さて、UserInfoの構造のロジックの重複自体は無くすことができ、使い勝手が向上しました。
しかし、まだ改善できます。
次に改善できそうな点として、UserInfoRepresentableの実装を書くことです。

先ほど紹介した、TodoUpdateNotificationContentの実装を再度掲載します。
var userInfo: [AnyHashable: Any]init(userInfo:)の実装は変数名をキーに使うことが多そうなものの、protocolなどではうまく共通化できなそうです。

struct TodoUpdateNotificationContent: UserInfoRepresentable {
    let insert: [Todo]
    let delete: [Todo]
    let modify: [Todo]
    
    var userInfo: [AnyHashable : Any] { 
        [
            "insert": content.insert,
            "delete": content.delete,
            "modify": content.modify
        ]
    }
    
    init(userInfo: [AnyHashable : Any]) throws {
        let insert = (userInfo["insert"] as? [Todo]) ?? []
        let delete = (userInfo["delete"] as? [Todo]) ?? []
        let modify = (userInfo["modify"] as? [Todo]) ?? []
        self.init(insert: insert, delete: delete, modify: modify)
    }
}

このようなユースケースの場合Swift5.9から導入されたマクロを使うのが良さそうです。
マクロの実装は煩雑になるので、ここでは紹介しませんが@UserInfoRepresentableマクロを用意すると上記のコードは以下のようにスッキリさせることができます。

@UserInfoRepresentable
struct TodoUpdateNotificationContent {
    let insert: [Todo]
    let delete: [Todo]
    let modify: [Todo]
}

自分でUserInfoとの相互変換のコードを書かなくて良くなったので、とても良さそうです。

また、もし変数名以外をkeyに使いたいのなら、別の@UserInfoKeyというマクロを定義して上書きすることもできます。
(下の例は変数名は"update"ですが、userInfoとの変換のkeyには"modify"が使われます。)

@UserInfoRepresentable
struct TodoUpdateNotificationContent {
    let insert: [Todo]
    let delete: [Todo]
    @UserInfoKey("modify") let update: [Todo]
}

通知の定義をスッキリさせる

最後の改善点として、通知の定義です。
再度先ほど紹介した通知の定義のコードを掲載します。

extension TypedNotificationDefinition {
    static var todoWillUpdateNotification: TypedNotificationDefinition<Database, TodoUpdateNotificationContent> {
        .init(name: Notification.Name("todoWillUpdateNotification"))
    }
    
    static var todoDidUpdateNotification: TypedNotificationDefinition<Database, TodoUpdateNotificationContent> {
        .init(name: Notification.Name("todoDidUpdateNotification"))
    }
 }

悪くはないものの、getterの中でinitを呼ぶのは決まりきっていたり、通知の名前が変数名と同じなことも多そうであったりします。

ここもマクロの出番です。
ここでもマクロの実装は省略しますが、@Notificationマクロを導入することでスッキリさせることができます。

extension TypedNotificationDefinition {
    @Notification
    static var todoWillUpdateNotification: TypedNotificationDefinition<Database, TodoUpdateNotificationContent>
    
    @Notification
    static var todoDidUpdateNotification: TypedNotificationDefinition<Database, TodoUpdateNotificationContent>
 }

利用例

ここまで、段階的に実装を更新して紹介したので、結局どれだけ便利になったのかがわかりづらいかもしれません。
そこで、authenticationStateChangeNotificationという「アカウントの認証状態が変わった時の通知」を型安全に追加する例を紹介します。

@UserInfoRepresentsable
struct AuthenticationState {
    var userID: String
    var isLoggedIn: Bool
}

extension TypedNotificationDefinition {
    @Notification
    static var authenticationStateChangeNotification: TypedNotificationDefinition<Auth, AuthenticationState>
 }

たったこれだけで型安全に通知を発行したり、監視できるようになりました。

let auth: Auth = ...
let authenticationState = ...

NotificationCenter.default.post(
    .authenticationStateChangeNotification,
    userInfo: authenticationState,
    object: auth
)

let cancellable = NotificationCenter.default.publisher(
    .authenticationStateChangeNotification,
    object: auth
).sink { notification in 
    let userID = notification.userInfo.userID
    let idLoggedIn = notification.userInfo.isLoggedIn
    // ...
}

おわりに

NotificationCenterを型安全に使う方法を紹介しました。
実はここまで紹介した実装はOSSライブラリとして公開しています。
こちらにはマクロの実装も含まれているので、よかったらチェックしてみてください。(少し実装や命名は異なります)

Discussion