🔔

Swift 6.2からのConcurrency SafeなNotification

に公開

はじめに

iOSやmacOSのアプリケーションの開発をする上でNotificationCenterを使うタイミングは時々あります。
NotificationCenterはグローバルに通知を飛ばすことができ、OSからのイベントやCore Dataの変更を購読したりできます。
このNotificationCenterですが、Objective-C時代のAPIを引きずっていて、現在のSwiftに最適化されていませんでした。

SF-0011で見直しが入り、Swift 6.2からは今までよりもモダンで安全に通知の購読や発行ができるようになりました。
この記事ではその使い方を紹介します。

使い方

基本的な使い方

新しいAPIではMessageと呼ばれるもので通知の購読や発行をします。
通知の型としてNotificationCenter.AsyncMessageに準拠した型を用意します。

Message型の定義
struct UserLogInMessage: NotificationCenter.AsyncMessage {
    // 通知を発行するクラス
    typealias Subject = AuthManager

    // 通知に持たせたい値をプロパティとして宣言する
    let userID: String
}

通知の発行にはpost関数を使います。

通知を発行
let message = UserLogInMessage(userID: "123")
NotificationCenter.default.post(message, subject: AuthManager.shared)

通知の監視にはaddObserver関数を使います。
通知を発行するインスタンスとMessageの型を指定すると、対象の通知が発行された時にクロージャーが実行されます。通知を発行するインスタンスは省略可能で、省略すると通知を発行するインスタンスに関係なく全ての通知を受け取ります。

通知の購読
let token = NotificationCenter.default.addObserver(
    of: AuthManager.shared, // 通知を発行するインスタンスを指定
    for: UserLogInMessage.self // Messageの型を指定
) { message in
    print("Logged In: \(message.userID)")
}

返り値のtokenが保持されている限りは、通知は購読され続けます。
tokenをリリースしたり、removeObserver関数を呼ぶことで購読を終了できます。

NotificationCenter.default.removeObserver(token)

Messageの種類

Messageには先ほどの例で使用したAsyncMessageMainActorMessageの二種類があります。

AsyncMessage

AsyncMessageは非同期に通知を発行するMessageです。
通知の発行とその処理は同期的に扱われないので、発行した側の処理は、購読しているクロージャーの処理を待たずに続けられます。

let token = NotificationCenter.default.addObserver(
    of: AuthManager.shared,
    for: UserLogInMessage.self
) { message in
    print("3. Received: \(message.userID)")
}

print("1. Will send a post")
let message = UserLogInMessage(userID: "123")
NotificationCenter.default.post(message, subject: AuthManager.shared)
print("2. Did send the post")

これは、通知を処理するタイミングがそこまでセンシティブでない場合に有効です。

なお、発行する側と処理する側の二箇所のIsolation DomainAsyncMessageが存在するので、AsyncMessageSendableであることが求められます。

MainActorMessage

一方のMainActorMessageは通知の発行と処理の両方がMainActorに隔離されているMessageです。さらに通知の発行と処理が同期的に処理されます。

MainActorに隔離されていること/同期的であることを除けば、AsyncMessageと使い方に大きな違いはありません。

struct UserWillLogInMessage: NotificationCenter.MainActorMessage {
    typealias Subject = AuthManager // 通知の発行をするクラス
    let userID: String
}

let token = NotificationCenter.default.addObserver(
    of: AuthManager.shared,
    for: UserWillLogInMessage.self
) { message in
    // MainActor上で実行される
    print("2. Received: \(message.userID)")
}

// MainActorから発行する
print("1. Will send a post")
let willLogInMessage = UserWillLogInMessage(userID: "123")
NotificationCenter.default.post(willLogInMessage, subject: AuthManager.shared)
print("3. Did send the post")

MainActorMessageAsyncMessageとは異なり、同期的に処理されるので、タイミング的にセンシティブな通知に向いています。
また、MainActorに隔離されている点から、UI関係の通知にも適してそうです。

既存のNotificationとの互換性

この新しいAPIは既存のNotificationと互換性があります。
つまり、今までNotificationとして発行された通知をMessageとして受け取れたり、新しくMessageとして発行した通知をNotificationとして受け取れたりできます。

例えば、みなさんのアプリでもうすでにユーザーがログインした時にuserWillLogInNotificationを発行しているとします。
この時、UserWillLogInMessageというMessageを定義し、その中にNotificationをパースする処理を追加することで、userWillLogInNotificationUserWillLogInMessageとして購読できます。

具体的には

  1. nameプロパティを宣言して通知の名前を指定し
  2. NotificationからUserWillLogInMessageに変換するmakeMessageを実装する

だけです。

struct UserWillLogInMessage: NotificationCenter.MainActorMessage {
    typealias Subject = AuthManager

    // 1. Notificationの名前を指定
    static let name = Notification.Name("userWillLogInNotification")

    let userID: String

    // 2. 受け取ったNotificationからUserWillLogInMessageを作成
    static func makeMessage(_ notification: Notification) -> UserWillLogInMessage? {
        guard let userID = notification.userInfo?["userID"] as? String else {
            return nil
        }
        return UserWillLogInMessage(userID: userID)
    }
}

逆も可能です。
makeNotificationを実装することで、Messageとして通知を発行して、古いNotificationとして購読することもできます。

struct UserWillLogInMessage: NotificationCenter.MainActorMessage {
    typealias Subject = AuthManager
    
    // Notificationの名前を指定
    static let name = Notification.Name("userWillLogInNotification")
    
    let userID: String
    
    static func makeNotification(_ message: UserWillLogInMessage) -> Notification {
        Notification(name: Self.name, object: nil, userInfo: ["userID": message.userID])
    }
}

既存のNotificationに慣れ親しんでいる人からすると、上でobjectnilを渡していることに不安を抱えるかもしれませんが、フレームワーク側でsubjectの値を自動で埋めてくれるので、ここではnilを使って問題ありません。

NotificationCenter.default.post(
    UserWillLogInMessage(userID: "123"),
    subject: AuthManager.shared // この値が`object`に自動で使われる
)

MessageIdentifier

ちょっとした便利機能として、MessageIdentifierというものがあります。
以下のようなコードを書いてstatic varを宣言しておくと、SE-0299で導入されたように、購読側のコード量を減らしたり、コード補完で一覧を眺めたりできるので、開発体験が少し改善されます。

extension NotificationCenter.MessageIdentifier where Self == NotificationCenter.BaseMessageIdentifier<UserWillLogInMessage> {
    static var userWillLogIn: Self { .init() }
}

XcodeでMessageIdentifierのコード補完をしているスクリーンショット

Appleプラットフォームの通知

Appleプラットフォームに関係するいくつかの通知については、すでにMessageの型とMessageIdentifierの宣言が用意されているので、開発者が自分でuserInfoをパースしたりする必要はありません。

例えば、UIApplicationdidBecomeActiveや、UIScenewillEnterForegroundが定義されています。

自分でパースする処理を書く前に、Appleのドキュメントを確認して、すでに定義されているか確認することをお勧めします。

新しいAPIで達成されたこと

この新しいAPIを使うことで、「コンパイル時にConcurrency Checkingができるようになったこと」「より厳しく型をつけられる」という利点があります。

前者については、今までのAPIでは通知を発行する側/処理する側でIsolation Domainの制限がなかったので、どのactorからでも通知を発行できましたし、購読する側はどのactorで通知が発行されたのか予想して、場合によってはMainActor.assumeIsolatedを使う必要がありました。
しかし、今回のAPIでMainActorMessageAsyncMessageが導入されたことで、「MainActorで発行された通知」や「どのスレッドで扱っても良い通知」という区別はつくようになり、以前よりも安心して通知の発行や監視ができるようになりました。
(個人的には、任意のグローバルactorを指定できないのは少しモヤっとしますが...)

また、後者に関してはより実感が伴います。以前のAPIだと、userInfoobjectの型が[String: Any]Anyだったりして、キャストする必要があり、かなり大変でした。
自分も、そのあたりの課題を解決するために、「NotificationCenterを型安全に扱う」という記事を書き、typed-notificationsというOSSの開発までしていたのですが、このあたりが標準の仕組みとして導入されたのはとても嬉しいです。

おわりに

Swift 6.2 / OS 26から導入された新しいNotificationのAPIについて紹介をしました。
この新しいAPIのおかげで今までより、Concurrency Safeに、そしてより厳しい型で通知の発行と購読ができるので、(OS 26以上という制約はありますが)積極的に使っていくと良いと思います。

Discussion