Change the KVO monitoring API

Instead of having a values method that observes and monitors, break out
a values method that returns an AsyncStream and then a monitorValues
method that calls values(for: keyPath).monitor. That method is kind of
superfluous, not sure if it's good to keep it or not.
This commit is contained in:
Sami Samhuri 2025-04-26 17:41:59 -07:00
parent e548a7534c
commit d2b4e0e382
No known key found for this signature in database
8 changed files with 87 additions and 34 deletions

View file

@ -84,7 +84,7 @@ class KVOExample {
init() {
let progress = Progress(totalUnitCount: 42)
progress.values(for: \.fractionCompleted) { fraction in
progress.monitorValues(for: \.fractionCompleted) { fraction in
print("Progress is \(fraction.formatted(.percent))%")
}.store(in: &cancellables)
}
@ -112,7 +112,7 @@ When you're integrating this into an app with Xcode then go to your project's Pa
When you're integrating this using SPM on its own then add this to the list of dependencies your Package.swift file:
```swift
.package(url: "https://github.com/samsonjs/AsyncMonitor.git", .upToNextMajor(from: "0.2"))
.package(url: "https://github.com/samsonjs/AsyncMonitor.git", .upToNextMajor(from: "0.2.1"))
```
and then add `"AsyncMonitor"` to the list of dependencies in your target as well.

View file

@ -9,22 +9,33 @@ public extension NSObjectProtocol where Self: NSObject {
/// - keyPath: The key path to observe on this object. The value must be `Sendable`.
/// - options: KVO options to use for observation. Defaults to an empty set.
/// - changeHandler: A closure that's executed with each new value.
func values<Value: Sendable>(
func monitorValues<Value: Sendable>(
for keyPath: KeyPath<Self, Value>,
options: NSKeyValueObservingOptions = [],
changeHandler: @escaping (Value) -> Void
) -> any AsyncCancellable {
values(for: keyPath, options: options)
.monitor(changeHandler)
}
/// Returns an `AsyncSequence` of `Value`s for all changes to the given key path on this object.
///
/// - Parameters:
/// - keyPath: The key path to observe on this object. The value must be `Sendable`.
/// - options: KVO options to use for observation. Defaults to an empty set.
func values<Value: Sendable>(
for keyPath: KeyPath<Self, Value>,
options: NSKeyValueObservingOptions = []
) -> some AsyncSequence<Value, Never> {
let (stream, continuation) = AsyncStream<Value>.makeStream()
let token = self.observe(keyPath, options: options) { object, _ in
let token: NSKeyValueObservation? = self.observe(keyPath, options: options) { object, _ in
continuation.yield(object[keyPath: keyPath])
}
let locker = TokenLocker(token: token)
// A nice side-effect of this is that the stream retains the token automatically.
let locker = ValueLocker(value: token)
continuation.onTermination = { _ in
locker.clear()
}
return stream.monitor { value in
_ = locker // keep this alive
changeHandler(value)
locker.modify { $0 = nil }
}
return stream
}
}

View file

@ -1,16 +0,0 @@
import Foundation
final class TokenLocker: @unchecked Sendable {
private let lock = NSLock()
private var unsafeToken: NSKeyValueObservation?
init(token: NSKeyValueObservation) {
unsafeToken = token
}
func clear() {
lock.withLock {
unsafeToken = nil
}
}
}

View file

@ -0,0 +1,20 @@
import Foundation
final class ValueLocker<Value>: @unchecked Sendable {
private let lock = NSLock()
private var unsafeValue: Value
init(value: Value) {
unsafeValue = value
}
var value: Value {
lock.withLock { unsafeValue }
}
func modify(_ f: (inout Value) -> Void) {
lock.withLock {
f(&unsafeValue)
}
}
}

View file

@ -8,7 +8,9 @@ import Testing
let cancellable = TestCancellable()
subject = AnyAsyncCancellable(cancellable: cancellable)
#expect(!cancellable.isCancelled)
subject = nil
#expect(cancellable.isCancelled)
}
@ -16,7 +18,9 @@ import Testing
let cancellable = TestCancellable()
subject = AnyAsyncCancellable(cancellable: cancellable)
#expect(!cancellable.isCancelled)
subject.cancel()
#expect(cancellable.isCancelled)
}
}

View file

@ -11,7 +11,9 @@ import Testing
#expect(cancellables.count == 1)
subject = nil
#expect(weakSubject != nil)
cancellables.removeAll()
#expect(weakSubject == nil)
}
}

View file

@ -6,16 +6,48 @@ class AsyncKVOTests {
var subject: Progress? = Progress(totalUnitCount: 42)
var cancellable: (any AsyncCancellable)?
@Test func yieldsChanges() async throws {
@Test(.timeLimit(.minutes(1)))
func monitorValuesYieldsChanges() async throws {
let subject = try #require(subject)
var values = [Double]()
cancellable = subject.values(for: \.fractionCompleted) { progress in
values.append(progress)
}
for _ in 1...3 {
let total = 3
cancellable = subject.values(for: \.fractionCompleted)
.prefix(total)
.monitor { progress in
values.append(progress)
}
for n in 1...total {
subject.completedUnitCount += 1
await Task.yield()
while values.count < n {
try await Task.sleep(for: .microseconds(2))
}
}
#expect(values.count == 3)
#expect(values.count == total)
}
// It's important that the test or the progress-observing task are not on the same actor, so
// we make the test @MainActor and observe progress values on another actor. Otherwise it's a
// deadlock.
@Test(.timeLimit(.minutes(1)))
@MainActor func valuesYieldsChanges() async throws {
let subject = try #require(subject)
let total = 3
let task = Task {
var values = [Double]()
for await progress in subject.values(for: \.fractionCompleted).prefix(total) {
values.append(progress)
}
return values
}
await Task.yield()
for _ in 1...total {
subject.completedUnitCount += 1
}
let values = await task.value
#expect(values.count == total)
}
}

View file

@ -54,7 +54,7 @@ class KVOExample {
init() {
let progress = Progress(totalUnitCount: 42)
progress.values(for: \.fractionCompleted) { fraction in
progress.monitorValues(for: \.fractionCompleted) { fraction in
print("Progress is \(fraction.formatted(.percent))%")
}.store(in: &cancellables)
}