mirror of
https://github.com/samsonjs/samhuri.net.git
synced 2026-04-27 14:57:40 +00:00
Write a post about AsyncMonitor and NotificationSmuggler
This commit is contained in:
parent
eba6e2c12f
commit
46a355cfba
1 changed files with 144 additions and 0 deletions
|
|
@ -0,0 +1,144 @@
|
||||||
|
---
|
||||||
|
Author: Sami Samhuri
|
||||||
|
Title: Type-safe notifications and async stream monitoring with Swift 6
|
||||||
|
Date: 6th June, 2025
|
||||||
|
Timestamp: 2025-06-06T14:27:11-07:00
|
||||||
|
Tags: Swift, iOS, notifications, async, concurrency, AsyncMonitor, NotificationSmuggler
|
||||||
|
---
|
||||||
|
|
||||||
|
Swift 6 concurrency checking made handling notifications without warnings kinda tedious. The old Combine approach doesn't work with `@Sendable` closures and manually managing tasks gets repetitive. I made a couple of tiny Swift packages to help out with the situation: [AsyncMonitor](https://github.com/samsonjs/AsyncMonitor) which wraps task management, and [NotificationSmuggler](https://github.com/samsonjs/NotificationSmuggler) which adds a type-safe interface on top of `Notification` and `NotificationCenter`.
|
||||||
|
|
||||||
|
## The manual approach
|
||||||
|
|
||||||
|
Here's what I was writing over and over:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// Causes Sendable warnings in Swift 6
|
||||||
|
let task = Task { [weak self] in
|
||||||
|
for await notification in NotificationCenter.default.notifications(named: .NSCalendarDayChanged) {
|
||||||
|
guard let self else { return }
|
||||||
|
await self.handleDayChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Store it somewhere, hope you remember to cancel it...
|
||||||
|
```
|
||||||
|
|
||||||
|
And when you have a bunch of these then you wind up with lots of properties to track them and other boilerplate.
|
||||||
|
|
||||||
|
## AsyncMonitor
|
||||||
|
|
||||||
|
[AsyncMonitor](https://github.com/samsonjs/AsyncMonitor) wraps the task lifecycle. Instead of managing tasks manually you use streams like Combine publishers:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
import AsyncMonitor
|
||||||
|
|
||||||
|
let cancellable = NotificationCenter.default
|
||||||
|
.notifications(named: .NSCalendarDayChanged)
|
||||||
|
.map(\.name)
|
||||||
|
.monitor { _ in
|
||||||
|
print("The date is now \(Date.now)")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `monitor` method creates a `Task` internally and handles cleanup when the cancellable goes out of scope. The closure is async so you can await in it.
|
||||||
|
|
||||||
|
You're free to do the usual `[weak self]` and `guard let self else { return }` dance, but there's a variant that accepts a context parameter that's automatically weakified. Your closure receives a strong reference:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
NotificationCenter.default
|
||||||
|
.notifications(named: .NSCalendarDayChanged)
|
||||||
|
.map(\.name)
|
||||||
|
.monitor(context: self) { _self, _ in
|
||||||
|
_self.dayChanged()
|
||||||
|
}.store(in: &cancellables)
|
||||||
|
```
|
||||||
|
|
||||||
|
The monitor finishes automatically after finding a nil context.
|
||||||
|
|
||||||
|
### KVO
|
||||||
|
|
||||||
|
Sometimes you need KVO and the old ways are best. There's a KVO extension that bridges to async sequences:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// AVPlayer's Combine publisher for rate doesn't publish all the values
|
||||||
|
player.monitorValues(for: \.rate) { rate in
|
||||||
|
print("Player rate: \(rate)")
|
||||||
|
}.store(in: &cancellables)
|
||||||
|
```
|
||||||
|
|
||||||
|
### What about Combine?
|
||||||
|
|
||||||
|
Combine works but doesn't mesh well with Swift 6 concurrency. The `@Sendable` requirements make it annoying. You can write the Task code manually but it gets repetitive when you have a lot of observers.
|
||||||
|
|
||||||
|
## NotificationSmuggler
|
||||||
|
|
||||||
|
[NotificationSmuggler](https://github.com/samsonjs/NotificationSmuggler) solves a different problem: type-safe notifications. No more dumpster diving in `userInfo`.
|
||||||
|
|
||||||
|
Define your contraband:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
import NotificationSmuggler
|
||||||
|
|
||||||
|
struct ProjectExportComplete: Smuggled, Sendable {
|
||||||
|
let projectID: String
|
||||||
|
let outputURL: URL
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `Smuggled` protocol generates a unique notification name and userInfo key from your type name.
|
||||||
|
|
||||||
|
Smuggle your illicit goods like so:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
NotificationCenter.default.smuggle(ProjectExportComplete(projectID: "project-123", outputURL: exportURL))
|
||||||
|
|
||||||
|
// which is short for
|
||||||
|
|
||||||
|
NotificationCenter.default.post(.smuggle(ProjectExportComplete(projectID: "project-123", outputURL: exportURL)))
|
||||||
|
```
|
||||||
|
|
||||||
|
And on the other side it's as easy as any other notification using an extension on `NotificationCenter`:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
for await notification in NotificationCenter.default.notifications(for: ProjectExportComplete.self) {
|
||||||
|
print("Project \(notification.projectID) exported to \(notification.outputURL)")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using them together
|
||||||
|
|
||||||
|
They work well together. Here's a more full example:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
import AsyncMonitor
|
||||||
|
import NotificationSmuggler
|
||||||
|
|
||||||
|
struct BackupCompleteNotification: Smuggled, Sendable {
|
||||||
|
let success: Bool
|
||||||
|
let totalSize: Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationCenter.default
|
||||||
|
.notifications(for: BackupCompleteNotification.self)
|
||||||
|
.monitor(context: self) { _self, notification in
|
||||||
|
_self.updateBackupStatus(success: notification.success)
|
||||||
|
}.store(in: &cancellables)
|
||||||
|
|
||||||
|
// elsewhere
|
||||||
|
|
||||||
|
NotificationCenter.default.smuggle(BackupCompleteNotification(success: true, totalSize: 42))
|
||||||
|
```
|
||||||
|
|
||||||
|
## That's it
|
||||||
|
|
||||||
|
Both libraries are small and focused. AsyncMonitor is about 100 lines, NotificationSmuggler is smaller. Zero dependencies.
|
||||||
|
|
||||||
|
AsyncMonitor requires iOS 17. It supports both iOS 17 and iOS 18 with different initializers due to changes in inheriting actor isolation.
|
||||||
|
|
||||||
|
- [AsyncMonitor on GitHub](https://github.com/samsonjs/AsyncMonitor)
|
||||||
|
- [AsyncMonitor on Swift Package Index](https://swiftpackageindex.com/samsonjs/AsyncMonitor)
|
||||||
|
|
||||||
|
NotificationSmuggler requires iOS 17.
|
||||||
|
|
||||||
|
- [NotificationSmuggler on GitHub](https://github.com/samsonjs/NotificationSmuggler)
|
||||||
|
- [NotificationSmuggler on Swift Package Index](https://swiftpackageindex.com/samsonjs/NotificationSmuggler)
|
||||||
Loading…
Reference in a new issue