Skip to content

Commit d2b4e0e

Browse files
committed
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.
1 parent e548a75 commit d2b4e0e

File tree

8 files changed

+86
-33
lines changed

8 files changed

+86
-33
lines changed

Readme.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ class KVOExample {
8484

8585
init() {
8686
let progress = Progress(totalUnitCount: 42)
87-
progress.values(for: \.fractionCompleted) { fraction in
87+
progress.monitorValues(for: \.fractionCompleted) { fraction in
8888
print("Progress is \(fraction.formatted(.percent))%")
8989
}.store(in: &cancellables)
9090
}
@@ -112,7 +112,7 @@ When you're integrating this into an app with Xcode then go to your project's Pa
112112
When you're integrating this using SPM on its own then add this to the list of dependencies your Package.swift file:
113113

114114
```swift
115-
.package(url: "https://github.com/samsonjs/AsyncMonitor.git", .upToNextMajor(from: "0.2"))
115+
.package(url: "https://github.com/samsonjs/AsyncMonitor.git", .upToNextMajor(from: "0.2.1"))
116116
```
117117

118118
and then add `"AsyncMonitor"` to the list of dependencies in your target as well.

Sources/AsyncMonitor/NSObject+AsyncKVO.swift

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,33 @@ public extension NSObjectProtocol where Self: NSObject {
99
/// - keyPath: The key path to observe on this object. The value must be `Sendable`.
1010
/// - options: KVO options to use for observation. Defaults to an empty set.
1111
/// - changeHandler: A closure that's executed with each new value.
12-
func values<Value: Sendable>(
12+
func monitorValues<Value: Sendable>(
1313
for keyPath: KeyPath<Self, Value>,
1414
options: NSKeyValueObservingOptions = [],
1515
changeHandler: @escaping (Value) -> Void
1616
) -> any AsyncCancellable {
17+
values(for: keyPath, options: options)
18+
.monitor(changeHandler)
19+
}
20+
21+
/// Returns an `AsyncSequence` of `Value`s for all changes to the given key path on this object.
22+
///
23+
/// - Parameters:
24+
/// - keyPath: The key path to observe on this object. The value must be `Sendable`.
25+
/// - options: KVO options to use for observation. Defaults to an empty set.
26+
func values<Value: Sendable>(
27+
for keyPath: KeyPath<Self, Value>,
28+
options: NSKeyValueObservingOptions = []
29+
) -> some AsyncSequence<Value, Never> {
1730
let (stream, continuation) = AsyncStream<Value>.makeStream()
18-
let token = self.observe(keyPath, options: options) { object, _ in
31+
let token: NSKeyValueObservation? = self.observe(keyPath, options: options) { object, _ in
1932
continuation.yield(object[keyPath: keyPath])
2033
}
21-
let locker = TokenLocker(token: token)
34+
// A nice side-effect of this is that the stream retains the token automatically.
35+
let locker = ValueLocker(value: token)
2236
continuation.onTermination = { _ in
23-
locker.clear()
24-
}
25-
return stream.monitor { value in
26-
_ = locker // keep this alive
27-
changeHandler(value)
37+
locker.modify { $0 = nil }
2838
}
39+
return stream
2940
}
3041
}

Sources/AsyncMonitor/TokenLocker.swift

Lines changed: 0 additions & 16 deletions
This file was deleted.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import Foundation
2+
3+
final class ValueLocker<Value>: @unchecked Sendable {
4+
private let lock = NSLock()
5+
private var unsafeValue: Value
6+
7+
init(value: Value) {
8+
unsafeValue = value
9+
}
10+
11+
var value: Value {
12+
lock.withLock { unsafeValue }
13+
}
14+
15+
func modify(_ f: (inout Value) -> Void) {
16+
lock.withLock {
17+
f(&unsafeValue)
18+
}
19+
}
20+
}

Tests/AsyncMonitorTests/AnyAsyncCancellableTests.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,19 @@ import Testing
88
let cancellable = TestCancellable()
99
subject = AnyAsyncCancellable(cancellable: cancellable)
1010
#expect(!cancellable.isCancelled)
11+
1112
subject = nil
13+
1214
#expect(cancellable.isCancelled)
1315
}
1416

1517
@Test func cancelsWhenCancelled() {
1618
let cancellable = TestCancellable()
1719
subject = AnyAsyncCancellable(cancellable: cancellable)
1820
#expect(!cancellable.isCancelled)
21+
1922
subject.cancel()
23+
2024
#expect(cancellable.isCancelled)
2125
}
2226
}

Tests/AsyncMonitorTests/AsyncCancellableTests.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ import Testing
1111
#expect(cancellables.count == 1)
1212
subject = nil
1313
#expect(weakSubject != nil)
14+
1415
cancellables.removeAll()
16+
1517
#expect(weakSubject == nil)
1618
}
1719
}

Tests/AsyncMonitorTests/NSObject+AsyncKVOTests.swift

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,48 @@ class AsyncKVOTests {
66
var subject: Progress? = Progress(totalUnitCount: 42)
77
var cancellable: (any AsyncCancellable)?
88

9-
@Test func yieldsChanges() async throws {
9+
@Test(.timeLimit(.minutes(1)))
10+
func monitorValuesYieldsChanges() async throws {
1011
let subject = try #require(subject)
1112
var values = [Double]()
12-
cancellable = subject.values(for: \.fractionCompleted) { progress in
13-
values.append(progress)
13+
let total = 3
14+
cancellable = subject.values(for: \.fractionCompleted)
15+
.prefix(total)
16+
.monitor { progress in
17+
values.append(progress)
18+
}
19+
20+
for n in 1...total {
21+
subject.completedUnitCount += 1
22+
while values.count < n {
23+
try await Task.sleep(for: .microseconds(2))
24+
}
1425
}
15-
for _ in 1...3 {
26+
27+
#expect(values.count == total)
28+
}
29+
30+
// It's important that the test or the progress-observing task are not on the same actor, so
31+
// we make the test @MainActor and observe progress values on another actor. Otherwise it's a
32+
// deadlock.
33+
@Test(.timeLimit(.minutes(1)))
34+
@MainActor func valuesYieldsChanges() async throws {
35+
let subject = try #require(subject)
36+
let total = 3
37+
let task = Task {
38+
var values = [Double]()
39+
for await progress in subject.values(for: \.fractionCompleted).prefix(total) {
40+
values.append(progress)
41+
}
42+
return values
43+
}
44+
await Task.yield()
45+
46+
for _ in 1...total {
1647
subject.completedUnitCount += 1
17-
await Task.yield()
1848
}
19-
#expect(values.count == 3)
49+
let values = await task.value
50+
51+
#expect(values.count == total)
2052
}
2153
}

Tests/AsyncMonitorTests/ReadmeExamples.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ class KVOExample {
5454

5555
init() {
5656
let progress = Progress(totalUnitCount: 42)
57-
progress.values(for: \.fractionCompleted) { fraction in
57+
progress.monitorValues(for: \.fractionCompleted) { fraction in
5858
print("Progress is \(fraction.formatted(.percent))%")
5959
}.store(in: &cancellables)
6060
}

0 commit comments

Comments
 (0)