Last active
September 26, 2024 07:59
-
-
Save rnapier/fa7d4250ab3401a74d6da98363c95482 to your computer and use it in GitHub Desktop.
Musings on Notifications and Actors
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/// 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