mirror of
https://github.com/samsonjs/AsyncMonitor.git
synced 2026-03-25 08:25:47 +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() {
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@ import Testing
|
|||
#expect(cancellables.count == 1)
|
||||
subject = nil
|
||||
#expect(weakSubject != nil)
|
||||
|
||||
cancellables.removeAll()
|
||||
|
||||
#expect(weakSubject == nil)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue