1
+ import CoreFoundation
1
2
import Dispatch
3
+ import Foundation
2
4
3
5
/// A type that produces valueless observations.
4
6
public class Publisher {
@@ -11,6 +13,12 @@ public class Publisher {
11
13
/// Human-readable tag for debugging purposes.
12
14
private var tag : String ?
13
15
16
+ /// The time at which the last update merging event occurred in
17
+ /// `observeOnMainThreadAvoidingStarvation`.
18
+ private var lastUpdateMergeTime : CFAbsoluteTime = 0
19
+ /// The amount of time taken per state update, exponentially averaged over time.
20
+ private var exponentiallySmoothedUpdateLength : Double = 0
21
+
14
22
/// Creates a new independent publisher.
15
23
public init ( ) { }
16
24
@@ -58,18 +66,33 @@ public class Publisher {
58
66
return self
59
67
}
60
68
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
+ /// A specialized version of `observe(with:)` designed to mitigate main thread
70
+ /// starvation issues observed on weaker systems when using the Gtk3Backend.
71
+ ///
72
+ /// If observations are produced faster than the update handler (`closure`) can
73
+ /// run, then the main thread quickly saturates and there's not enough time
74
+ /// between view state updates for the backend to re-render the affected UI
75
+ /// elements.
76
+ ///
77
+ /// This method ensures that only one update can queue up at a time. When an
78
+ /// observation arrives while an update is already queued, the observation's
79
+ /// resulting update gets 'merged' (which just means dropped, but unlike a
80
+ /// dropped frame, a dropped update has no detrimental effects).
69
81
///
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 > (
82
+ /// When updates are getting merged often, this generally means that the
83
+ /// update handler is still running constantly (since there's always going to
84
+ /// be a new update waiting before the the running update completes). In this
85
+ /// situation we introduce a sleep after handling each update to give the backend
86
+ /// time to catch up. Hueristically I've found that a delay of 1.5x the length of
87
+ /// the update is required on my old Linux laptop using ``Gtk3Backend``, so I'm
88
+ /// going with that for now. Importantly, this delay is only used whenever updates
89
+ /// start running back-to-back with no gap so it shouldn't affect fast systems
90
+ /// like my Mac under any usual circumstances.
91
+ ///
92
+ /// If the provided backend has the notion of a main thread, then the update
93
+ /// handler will end up on that thread, but regardless of backend it's
94
+ /// guaranteed that updates will always run serially.
95
+ func observeAsUIUpdater< Backend: AppBackend > (
73
96
backend: Backend ,
74
97
action closure: @escaping ( ) -> Void
75
98
) -> Cancellable {
@@ -80,6 +103,13 @@ public class Publisher {
80
103
return observe {
81
104
// Only allow one update to wait at a time.
82
105
guard semaphore. wait ( timeout: . now( ) ) == . success else {
106
+ // It's a bit of a hack but we just reuse the serial update handling queue
107
+ // for synchronisation since updating this variable isn't super time sensitive
108
+ // as long as it happens within the next update or two.
109
+ let mergeTime = CFAbsoluteTimeGetCurrent ( )
110
+ serialUpdateHandlingQueue. async {
111
+ self . lastUpdateMergeTime = mergeTime
112
+ }
83
113
return
84
114
}
85
115
@@ -95,7 +125,32 @@ public class Publisher {
95
125
// we've already processed, leading to stale view contents.
96
126
semaphore. signal ( )
97
127
128
+ // Run the closure and while we're at it measure how long it takes
129
+ // so that we can use it when throttling if updates start backing up.
130
+ let start = CFAbsoluteTimeGetCurrent ( )
98
131
closure ( )
132
+ let elapsed = CFAbsoluteTimeGetCurrent ( ) - start
133
+
134
+ // I chose exponential smoothing because it's simple to compute, doesn't
135
+ // require storing a window of previous values, and quickly converges to
136
+ // a sensible value when the average moves while still somewhat ignoring
137
+ // outliers.
138
+ self . exponentiallySmoothedUpdateLength =
139
+ elapsed / 2 + self . exponentiallySmoothedUpdateLength / 2
140
+ }
141
+
142
+ if CFAbsoluteTimeGetCurrent ( ) - self . lastUpdateMergeTime < 1 {
143
+ // The factor of 1.5 was determined empirically. This algorithm is
144
+ // open for improvements since it's purely here to reduce the risk
145
+ // of UI freezes.
146
+ let throttlingDelay = self . exponentiallySmoothedUpdateLength * 1.5
147
+
148
+ // Sleeping on a dispatch queue generally isn't a good idea because
149
+ // you prevent the queue from servicing any other work, but in this
150
+ // case that's the whole point. The goal is to give the main thread
151
+ // a break, which we do by blocking this queue and in effect guarding
152
+ // the main thread from subsequent updates until we wake up again.
153
+ Thread . sleep ( forTimeInterval: throttlingDelay)
99
154
}
100
155
}
101
156
}
0 commit comments