iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🔔

Concurrency-Safe Notifications in Swift 6.2

に公開

Introduction

In the development of iOS and macOS applications, there are times when we use NotificationCenter.
NotificationCenter allows for global broadcasting of notifications, enabling us to subscribe to events from the OS or changes in Core Data.
This NotificationCenter had been dragging along APIs from the Objective-C era and was not optimized for modern Swift.

SF-0011 introduced a revision, and starting from Swift 6.2, it is now possible to subscribe to and post notifications in a more modern and safe way than before.
In this article, I will introduce how to use it.

Usage

Basic Usage

In the new API, you subscribe to and post notifications using what is called a Message.
You prepare a type that conforms to NotificationCenter.AsyncMessage as the notification type.

Definition of Message type
struct UserLogInMessage: NotificationCenter.AsyncMessage {
    // The class that posts the notification
    typealias Subject = AuthManager

    // Declare the values you want the notification to carry as properties
    let userID: String
}

The post function is used to post a notification.

Post a notification
let message = UserLogInMessage(userID: "123")
NotificationCenter.default.post(message, subject: AuthManager.shared)

The addObserver function is used to monitor notifications.
By specifying the instance that posts the notification and the Message type, a closure is executed when the target notification is posted. The instance posting the notification is optional; if omitted, you will receive all notifications regardless of the instance that posted them.

Subscribe to notification
let token = NotificationCenter.default.addObserver(
    of: AuthManager.shared, // Specify the instance that posts the notification
    for: UserLogInMessage.self // Specify the Message type
) { message in
    print("Logged In: \(message.userID)")
}

As long as the returned token is held, the notification will continue to be subscribed to.
You can terminate the subscription by releasing the token or by calling the removeObserver function.

NotificationCenter.default.removeObserver(token)

Message Types

There are two types of Messages: AsyncMessage, which was used in the previous example, and MainActorMessage.

AsyncMessage

AsyncMessage is a message that posts notifications asynchronously.
Since the posting of the notification and its processing are not handled synchronously, the processing on the sender's side continues without waiting for the processing in the subscribed closure.

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")

This is effective when the timing of processing the notification is not particularly sensitive.

Furthermore, since AsyncMessage exists across two Isolation Domains—the posting side and the processing side—it is required to be Sendable.

MainActorMessage

On the other hand, MainActorMessage is a Message where both the posting and processing of the notification are isolated to the MainActor. Furthermore, the posting and processing are handled synchronously.

Aside from being isolated to the MainActor and being synchronous, there are no major differences in usage compared to AsyncMessage.

struct UserWillLogInMessage: NotificationCenter.MainActorMessage {
    typealias Subject = AuthManager // The class that posts the notification
    let userID: String
}

let token = NotificationCenter.default.addObserver(
    of: AuthManager.shared,
    for: UserWillLogInMessage.self
) { message in
    // Executed on the MainActor
    print("2. Received: \(message.userID)")
}

// Post from the 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")

Unlike AsyncMessage, MainActorMessage is processed synchronously, making it suitable for notifications where timing is sensitive.
Also, given its isolation to the MainActor, it appears well-suited for UI-related notifications.

Compatibility with existing Notifications

This new API is compatible with the existing Notification system.
In other words, notifications previously posted as a Notification can be received as a Message, and notifications newly posted as a Message can be received as a Notification.

For example, suppose your app already posts a userWillLogInNotification when a user logs in.
By defining a Message called UserWillLogInMessage and adding logic to parse the Notification inside it, you can subscribe to userWillLogInNotification as a UserWillLogInMessage.

Specifically, it's just two steps:

  1. Declare the name property to specify the notification name.
  2. Implement makeMessage to convert the Notification into a UserWillLogInMessage.
struct UserWillLogInMessage: NotificationCenter.MainActorMessage {
    typealias Subject = AuthManager

    // 1. Specify the notification name
    static let name = Notification.Name("userWillLogInNotification")

    let userID: String

    // 2. Create UserWillLogInMessage from the received Notification
    static func makeMessage(_ notification: Notification) -> UserWillLogInMessage? {
        guard let userID = notification.userInfo?["userID"] as? String else {
            return nil
        }
        return UserWillLogInMessage(userID: userID)
    }
}

The reverse is also possible.
By implementing makeNotification, you can post a notification as a Message and subscribe to it as an old-style Notification.

struct UserWillLogInMessage: NotificationCenter.MainActorMessage {
    typealias Subject = AuthManager
    
    // Specify the notification name
    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])
    }
}

For those familiar with the existing Notification system, passing nil to object above might be concerning. However, since the framework automatically fills in the subject value, using nil here is perfectly fine.

NotificationCenter.default.post(
    UserWillLogInMessage(userID: "123"),
    subject: AuthManager.shared // This value is automatically used for `object`
)

MessageIdentifier

As a handy feature, there is something called MessageIdentifier. By declaring a static var as shown in the code below, you can reduce the amount of code on the subscriber side and browse through the list via code completion—similar to what was introduced in SE-0299—which slightly improves the development experience.

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

Screenshot of code completion for MessageIdentifier in Xcode

Notifications on Apple Platforms

For several notifications related to Apple platforms, Message types and MessageIdentifier declarations are already provided, so developers do not need to parse userInfo themselves.

For example, didBecomeActive for UIApplication and willEnterForeground for UIScene are defined.

Before writing your own parsing logic, I recommend checking Apple's documentation to see if they are already defined.

What has been achieved with the new API

By using this new API, there are benefits such as "enabling Concurrency Checking at compile time" and "the ability to apply stricter typing."

Regarding the former, with the previous API, there were no Isolation Domain restrictions for the sender or receiver side, so notifications could be posted from any actor, and the subscriber had to guess which actor the notification was posted on, sometimes necessitating the use of MainActor.assumeIsolated.
However, with the introduction of MainActorMessage and AsyncMessage in this API, the distinction between "notifications posted on the MainActor" and "notifications that can be handled on any thread" has become clear, allowing for more secure posting and monitoring of notifications than before.
(Personally, I'm a bit bothered by the inability to specify arbitrary global actors, but...)

As for the latter, the improvement is even more tangible. With the previous API, the types for userInfo and object were [String: Any] or Any, requiring manual casting, which was quite a hassle.
I myself had written an article titled "Handling NotificationCenter Type-Safely" and even developed an OSS called typed-notifications to solve these issues, so I am very happy that this has been introduced as a standard mechanism.

Conclusion

I have introduced the new Notification API available from Swift 6.2 / OS 26.
Thanks to this new API, you can post and subscribe to notifications with better concurrency safety and stricter typing than before. While there is a restriction of requiring OS 26 or higher, I recommend actively using it.

Discussion