Skip to content

Commit 55ff38a

Browse files
committed
Add state update merging to _App (a bit of an oversight, I originally only implemented it for ViewGraphNode)
1 parent deb2b5e commit 55ff38a

File tree

3 files changed

+121
-96
lines changed

3 files changed

+121
-96
lines changed

Sources/SwiftCrossUI/State/Publisher.swift

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import Dispatch
2+
13
/// A type that produces valueless observations.
24
public class Publisher {
35
/// The id for the next observation (ids are used to cancel observations).
@@ -55,4 +57,115 @@ public class Publisher {
5557
#endif
5658
return self
5759
}
60+
61+
// TODO: English this explanation more better.
62+
/// Observes a publisher for changes and runs an action on the main thread whenever
63+
/// a change occurs. This pattern generally leads to starvation if events are produced
64+
/// faster than the serial handler can handle them, so this method deals with that by
65+
/// ensuring that only one update is allowed to be waiting at any given time. The
66+
/// implementation guarantees that at least one update will occur after each update,
67+
/// but if for example 4 updates arrive while a previous update is getting serviced,
68+
/// then all 4 updates will get serviced by a single run of `closure`.
69+
///
70+
/// Note that some backends don't have a concept of a main thread, so you the updates
71+
/// won't always end up on a 'main thread', but they are guaranteed to run serially.
72+
func observeOnMainThreadAvoidingStarvation<Backend: AppBackend>(
73+
backend: Backend,
74+
action closure: @escaping () -> Void
75+
) -> Cancellable {
76+
// All of the concurrency related code is there to detect when updates can be merged
77+
// together (a.k.a. when one of the updates is unnecessary).
78+
let protectingQueue = DispatchQueue(label: "state update merging")
79+
let concurrentUpdateHandlingQueue = DispatchQueue(
80+
label: "concurrent update handling queue",
81+
attributes: .concurrent
82+
)
83+
let synchronizationSemaphore = DispatchSemaphore(value: 1)
84+
85+
// State shared betwen all calls to the closure defined below.
86+
var updateIsQueued = false
87+
var updateIsRunning = false
88+
var aCurrentJobDidntHaveToWait = false
89+
90+
return observe {
91+
// I'm sorry if you have to make sense of this... Take my comments as a peace offering.
92+
93+
// Hop to a dispatch queue to avoid blocking any threads in the Swift Structured
94+
// Concurrency thread pool in the case that the state update originated from a task.
95+
concurrentUpdateHandlingQueue.async {
96+
// If no one is running, then we run without waiting, and if someone's running
97+
// but no one's waiting, then we wait and prevent anyone else from waiting.
98+
// This ensures that at least one update will always happen after every update
99+
// received so far, without letting unnecessary updates queue up. The reason
100+
// that we can merge updates like this is that all state updates are built equal;
101+
// they don't carry any information other than that they happened.
102+
var shouldWait = false
103+
protectingQueue.sync {
104+
if !updateIsQueued {
105+
shouldWait = true
106+
}
107+
108+
if updateIsRunning {
109+
updateIsQueued = true
110+
} else {
111+
updateIsRunning = true
112+
aCurrentJobDidntHaveToWait = true
113+
}
114+
}
115+
116+
guard shouldWait else {
117+
return
118+
}
119+
120+
// Waiting just involves attempting to jump to the main thread.
121+
backend.runInMainThread {
122+
// This semaphore is used because some backends don't put us on the main
123+
// thread since they don't have the concept of a single UI thread like
124+
// macOS does.
125+
//
126+
// If `backend.runInMainThread` is truly putting us on the main thread,
127+
// then this never have to block significantly, otherwise we're just
128+
// blocking some random thread, so either way we're fine since we've
129+
// explicitly hopped to a dispatch queue to escape any cooperative
130+
// Swift Structured Concurrency thread pool the state update may have
131+
// originated from.
132+
synchronizationSemaphore.wait()
133+
134+
protectingQueue.sync {
135+
// If a current job didn't have to wait, then that's us. Due to
136+
// concurrency that doesn't mean we were the first update triggered.
137+
// That is, we could've been the job that set `updateIsQueued` to
138+
// true while still being the job that reached this line first (before
139+
// the one that set `updateIsRunning` to true). And that's why I've
140+
// implemented the check in this way with a protected 'global' and not
141+
// a local variable (being first isn't a property we can know ahead
142+
// of time). I use 'global' in the sense of shared between all calls
143+
// to the state update handling closure for a given ViewGraphNode.
144+
//
145+
// The reason that `aCurrentJobDidntHaveToWait` is needed at all is
146+
// so that we can know whether `updateIsQueued`'s value is due to us
147+
// or someone else/no one.
148+
if aCurrentJobDidntHaveToWait {
149+
aCurrentJobDidntHaveToWait = false
150+
} else {
151+
updateIsQueued = false
152+
}
153+
}
154+
155+
closure()
156+
157+
// If someone is waiting then we leave `updateIsRunning` equal to true
158+
// because they'll immediately begin running as soon as we exit and we
159+
// don't want an extra person queueing until they've actually started.
160+
protectingQueue.sync {
161+
if !updateIsQueued {
162+
updateIsRunning = false
163+
}
164+
}
165+
166+
synchronizationSemaphore.signal()
167+
}
168+
}
169+
}
170+
}
58171
}

Sources/SwiftCrossUI/ViewGraph/ViewGraphNode.swift

Lines changed: 5 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -107,100 +107,12 @@ public class ViewGraphNode<NodeView: View, Backend: AppBackend> {
107107
let tag = String(String(describing: NodeView.self).split(separator: "<")[0])
108108
backend.tag(widget: widget, as: tag)
109109

110-
// Updates the view and its children when state changes (children are always updated first).
111-
// All of the concurrency related code is there to detect when updates can be merged
112-
// together (a.k.a. when one of the updates is unnecessary).
113-
let protectingQueue = DispatchQueue(label: "state update debouncing")
114-
let concurrentUpdateHandlingQueue = DispatchQueue(
115-
label: "concurrent update handling queue",
116-
attributes: .concurrent
117-
)
118-
var updateIsQueued = false
119-
var updateIsRunning = false
120-
var aCurrentJobDidntHaveToWait = false
121-
let synchronizationSemaphore = DispatchSemaphore(value: 1)
122-
cancellable = view.state.didChange.observe { [weak self] in
123-
guard let self = self else { return }
124-
125-
// I'm sorry if you have to make sense of this... Take my comments as a peace offering.
126-
127-
// Hop to a dispatch queue to avoid blocking any threads in the Swift Structured
128-
// Concurrency thread pool in the case that the state update originated from a task.
129-
concurrentUpdateHandlingQueue.async {
130-
// If no one is running, then we run without waiting, and if someone's running
131-
// but no one's waiting, then we wait and prevent anyone else from waiting.
132-
// This ensures that at least one update will always happen after every update
133-
// received so far, without letting unnecessary updates queue up. The reason
134-
// that we can merge updates like this is that all state updates are built equal;
135-
// they don't carry any information other than that they happened.
136-
var shouldWait = false
137-
protectingQueue.sync {
138-
if !updateIsQueued {
139-
shouldWait = true
140-
}
141-
142-
if updateIsRunning {
143-
updateIsQueued = true
144-
} else {
145-
updateIsRunning = true
146-
aCurrentJobDidntHaveToWait = true
147-
}
148-
}
149-
150-
guard shouldWait else {
151-
return
152-
}
153-
154-
// Waiting just involves attempting to jump to the main thread.
155-
self.backend.runInMainThread {
156-
// This semaphore is used because some backends don't put us on the main
157-
// thread since they don't have the concept of a single UI thread like
158-
// macOS does.
159-
//
160-
// If `backend.runInMainThread` is truly putting us on the main thread,
161-
// then this never have to block significantly, otherwise we're just
162-
// blocking some random thread, so either way we're fine since we've
163-
// explicitly hopped to a dispatch queue to escape any cooperative
164-
// Swift Structured Concurrency thread pool the state update may have
165-
// originated from.
166-
synchronizationSemaphore.wait()
167-
168-
protectingQueue.sync {
169-
// If a current job didn't have to wait, then that's us. Due to
170-
// concurrency that doesn't mean we were the first update triggered.
171-
// That is, we could've been the job that set `updateIsQueued` to
172-
// true while still being the job that reached this line first (before
173-
// the one that set `updateIsRunning` to true). And that's why I've
174-
// implemented the check in this way with a protected 'global' and not
175-
// a local variable (being first isn't a property we can know ahead
176-
// of time). I use 'global' in the sense of shared between all calls
177-
// to the state update handling closure for a given ViewGraphNode.
178-
//
179-
// The reason that `aCurrentJobDidntHaveToWait` is needed at all is
180-
// so that we can know whether `updateIsQueued`'s value is due to us
181-
// or someone else/no one.
182-
if aCurrentJobDidntHaveToWait {
183-
aCurrentJobDidntHaveToWait = false
184-
} else {
185-
updateIsQueued = false
186-
}
187-
}
188-
189-
self.bottomUpUpdate()
190-
191-
// If someone is waiting then we leave `updateIsRunning` equal to true
192-
// because they'll immediately begin running as soon as we exit and we
193-
// don't want an extra person queueing until they've actually started.
194-
protectingQueue.sync {
195-
if !updateIsQueued {
196-
updateIsRunning = false
197-
}
198-
}
199-
200-
synchronizationSemaphore.signal()
201-
}
110+
// Update the view and its children when state changes (children are always updated first).
111+
cancellable = view.state.didChange
112+
.observeOnMainThreadAvoidingStarvation(backend: backend) { [weak self] in
113+
guard let self = self else { return }
114+
self.bottomUpUpdate()
202115
}
203-
}
204116
}
205117

206118
/// Stops observing the view's state.

Sources/SwiftCrossUI/_App.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,15 +60,15 @@ class _App<AppRoot: App> {
6060
)
6161
self.sceneGraphRoot = rootNode
6262

63-
self.cancellable = self.app.state.didChange.observe {
64-
self.backend.runInMainThread {
63+
self.cancellable = self.app.state.didChange
64+
.observeOnMainThreadAvoidingStarvation(backend: self.backend) { [weak self] in
65+
guard let self = self else { return }
6566
self.sceneGraphRoot?.update(
6667
self.app.body,
6768
backend: self.backend,
6869
environment: self.environment
6970
)
7071
}
71-
}
7272
}
7373
}
7474
}

0 commit comments

Comments
 (0)