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() { init() {
let progress = Progress(totalUnitCount: 42) let progress = Progress(totalUnitCount: 42)
progress.values(for: \.fractionCompleted) { fraction in progress.monitorValues(for: \.fractionCompleted) { fraction in
print("Progress is \(fraction.formatted(.percent))%") print("Progress is \(fraction.formatted(.percent))%")
}.store(in: &cancellables) }.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: When you're integrating this using SPM on its own then add this to the list of dependencies your Package.swift file:
```swift ```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. 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`. /// - 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. /// - options: KVO options to use for observation. Defaults to an empty set.
/// - changeHandler: A closure that's executed with each new value. /// - changeHandler: A closure that's executed with each new value.
func values<Value: Sendable>( func monitorValues<Value: Sendable>(
for keyPath: KeyPath<Self, Value>, for keyPath: KeyPath<Self, Value>,
options: NSKeyValueObservingOptions = [], options: NSKeyValueObservingOptions = [],
changeHandler: @escaping (Value) -> Void changeHandler: @escaping (Value) -> Void
) -> any AsyncCancellable { ) -> 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 (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]) 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 continuation.onTermination = { _ in
locker.clear() locker.modify { $0 = nil }
}
return stream.monitor { value in
_ = locker // keep this alive
changeHandler(value)
} }
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() let cancellable = TestCancellable()
subject = AnyAsyncCancellable(cancellable: cancellable) subject = AnyAsyncCancellable(cancellable: cancellable)
#expect(!cancellable.isCancelled) #expect(!cancellable.isCancelled)
subject = nil subject = nil
#expect(cancellable.isCancelled) #expect(cancellable.isCancelled)
} }
@ -16,7 +18,9 @@ import Testing
let cancellable = TestCancellable() let cancellable = TestCancellable()
subject = AnyAsyncCancellable(cancellable: cancellable) subject = AnyAsyncCancellable(cancellable: cancellable)
#expect(!cancellable.isCancelled) #expect(!cancellable.isCancelled)
subject.cancel() subject.cancel()
#expect(cancellable.isCancelled) #expect(cancellable.isCancelled)
} }
} }

View file

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

View file

@ -6,16 +6,48 @@ class AsyncKVOTests {
var subject: Progress? = Progress(totalUnitCount: 42) var subject: Progress? = Progress(totalUnitCount: 42)
var cancellable: (any AsyncCancellable)? var cancellable: (any AsyncCancellable)?
@Test func yieldsChanges() async throws { @Test(.timeLimit(.minutes(1)))
func monitorValuesYieldsChanges() async throws {
let subject = try #require(subject) let subject = try #require(subject)
var values = [Double]() var values = [Double]()
cancellable = subject.values(for: \.fractionCompleted) { progress in let total = 3
cancellable = subject.values(for: \.fractionCompleted)
.prefix(total)
.monitor { progress in
values.append(progress) values.append(progress)
} }
for _ in 1...3 {
for n in 1...total {
subject.completedUnitCount += 1 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() { init() {
let progress = Progress(totalUnitCount: 42) let progress = Progress(totalUnitCount: 42)
progress.values(for: \.fractionCompleted) { fraction in progress.monitorValues(for: \.fractionCompleted) { fraction in
print("Progress is \(fraction.formatted(.percent))%") print("Progress is \(fraction.formatted(.percent))%")
}.store(in: &cancellables) }.store(in: &cancellables)
} }