Skip to content

Instantly share code, notes, and snippets.

@rnapier
Last active April 16, 2024 22:19
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save rnapier/fa7d4250ab3401a74d6da98363c95482 to your computer and use it in GitHub Desktop.
Save rnapier/fa7d4250ab3401a74d6da98363c95482 to your computer and use it in GitHub Desktop.
Musings on Notifications and Actors
/// Some exploration into how selector-based notification interact with actors.
///
/// Or in the words of Brent Simmons (@brentsimmons@indieweb.social),
/// "Selector-based Notification Observers Are Actually Good"
/// Overall, I'm reasonably convinced, in that it avoids the headaches of `deinit` in actors.
/// However, Combine-based observation is also good at this, so I don't yet have a strong opinion
/// about old-school selectors vs Combine beyond my usual nervousness around Combine.
/// Whether "I'd like to reduce Combine" is more or less powerful than "I'd like to reduce @objc"
/// is yet to be seen.
///
/// My one concern is that `addObserver` does not provide any warning or error if passed
/// an `async` selector. But it will then crash when used.
/// [Swift bug #60084](https://github.com/apple/swift/issues/60084)
/// This is fairly easy to detect in code-review, and I do not expect it to be a common source of
/// issues, but is not as type-safe as we generally expect Swift to be.
///
/// For those not highly familiar with the selector-based `addObserver`, in most cases it does not
/// require `removeObserver`. It's done automatically by the system.
/// [See Discussion in the docs.](https://developer.apple.com/documentation/foundation/notificationcenter/1415360-addobserver#)
import Foundation
// Compiled with strict concurrency
/// Actors need to make their notification handler `nonisolated` and synchronous.
/// The compiler warnings/errors will guide away from making @objc isolated, but
/// will instruct you to add `async`. This will produce a warning about non-sendable
/// Notification if you've used a signature with a Notification in it (that's not actually
/// required). **If you keep the async, the app will crash when a notification is posted.**
///
/// With `nonisolated` handlers, things work as expected. Getting access to the actor's
/// properties requires changing context (e.g. Task+await). Warnings and errors in the body
/// will guide you correctly.
///
/// Note that that `removeObserver` is not needed, eliminating the complexities of actor
/// `deinit`.
///
/// On the other hand, notifications are not promised to be processed in order, even if posted
/// sequentially from the same actor. This should be resolved by
/// [SE-0431](https://github.com/apple/swift-evolution/blob/main/proposals/0431-isolated-any-functions.md).
actor Service {
static let shared = Service()
public private(set) var count: Int = 0
public private(set) var started: Bool = false
func makeNewCount(byIncrementing value: Int) -> Int {
count += value
return count
}
func start() {
guard !started else { return }
NotificationCenter.default.addObserver(self, selector: #selector(handleTest),
name: .test, object: nil)
started = true
}
// Note that `nonisolated` is required here. Adding `async` here (which will be suggested
// by the compiler) will crash the program when a notification is posted.
@objc nonisolated func handleTest(_ note: Notification) {
// It is necessary to parse the Notification before switching contexts.
// Notification is not Sendable, and cannot be made Sendable since it includes
// a [String: Any].
let value = note.userInfo?["value"] as? Int ?? 0
Task {
print(await makeNewCount(byIncrementing: value))
}
}
}
/// MainActor types, which are likely the most appropriate types to observe Notifications
/// "just work." No special handling is required. Everything does what you think it does,
/// and you get to skip the `deinit` headache because `removeObserver` is automatic.
@MainActor final class MainActorService {
static let shared = MainActorService()
public private(set) var count: Int = 0
public private(set) var started: Bool = false
func start() {
guard !started else { return }
NotificationCenter.default.addObserver(self, selector: #selector(handleTest),
name: .test, object: nil)
}
// If you forget to mark this @objc, then errors will guide you correctly.
// If you mark this `async`, however, the app will crash when this is called.
@objc func handleTest(_ note: Notification) {
let value = note.userInfo?["value"] as? Int ?? 0
count += value
print(count)
}
}
//
// Test harness
//
import SwiftUI
extension Notification.Name {
static let test = Self("TestNotification")
}
struct ContentView: View {
var body: some View {
VStack {
Button("Send") {
NotificationCenter.default.post(name: .test, object: nil, userInfo: ["value" : 2])
}
}
.padding()
.task {
await Service.shared.start()
MainActorService.shared.start()
}
}
}
#Preview {
ContentView()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment