mirror of
https://github.com/samsonjs/AsyncMonitor.git
synced 2026-04-27 14:57:39 +00:00
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:
parent
e548a7534c
commit
d2b4e0e382
8 changed files with 87 additions and 34 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
20
Sources/AsyncMonitor/ValueLocker.swift
Normal file
20
Sources/AsyncMonitor/ValueLocker.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
values.append(progress)
|
cancellable = subject.values(for: \.fractionCompleted)
|
||||||
}
|
.prefix(total)
|
||||||
for _ in 1...3 {
|
.monitor { progress in
|
||||||
|
values.append(progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue