forked from spotify/Mobius.swift
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathMobiusController.swift
More file actions
331 lines (295 loc) · 13.5 KB
/
MobiusController.swift
File metadata and controls
331 lines (295 loc) · 13.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
// Copyright Spotify AB.
// SPDX-License-Identifier: Apache-2.0
import Foundation
/// Defines a controller that can be used to start and stop MobiusLoops.
///
/// If a loop is stopped and then started again via the controller, the new loop will continue from where the last one
/// left off.
public final class MobiusController<Model, Event, Effect> {
typealias Loop = MobiusLoop<Model, Event, Effect>
typealias ViewConnectable = AsyncDispatchQueueConnectable<Model, Event>
private struct StoppedState {
var modelToStartFrom: Model
var viewConnectables: [UUID: ViewConnectable]
}
private struct RunningState {
var loop: Loop
var viewConnectables: [UUID: ViewConnectable]
var disposables: CompositeDisposable
}
private typealias State = AsyncStartStopStateMachine<StoppedState, RunningState>
private let loopFactory: (Model) -> Loop
private let loopQueue: DispatchQueue
private let viewQueue: DispatchQueue
private let state: State
/// A Boolean indicating whether the MobiusLoop is running or not.
public var running: Bool {
return state.running
}
/// See `Mobius.Builder.makeController` for documentation
init(
builder: Mobius.Builder<Model, Event, Effect>,
initialModel: Model,
initiate: Initiate<Model, Effect>? = nil,
logger: AnyMobiusLogger<Model, Event, Effect>,
loopQueue loopTargetQueue: DispatchQueue,
viewQueue: DispatchQueue
) {
/*
Ownership graph after initing:
┏━━━━━━━━━━━━┓
┌────┨ controller ┠────────┬──┐
│ ┗━━━━━━━━━━┯━┛ │ │
┏━━━━━━┷━━━━━━┓ ┏━━━┷━━━━━━━┓ │ │
┃ loopFactory ┃ ┃ viewQueue ┃ │ │
┗━━━━━━━━━┯━━━┛ ┗━━━━━━━━━━━┛ │ │
┏━━━━┷━━━━━━━━━━━━━━━━━━┓ │ │
┃ flipEventsToLoopQueue ┃ ┌──┘ │
┗━━━━━━━━━━━━━━┯━━━━━━┯━┛ │ │
│ ┏━┷━━━┷━┓ │
│ ┃ state ┃ │
│ ┗━┯━━━━━┛ │
│ │ ┌───────┘
┏━┷━━━━━━┷━┷┓
┃ loopQueue ┃
┗━━━━━━━━━━━┛
In order to construct this bottom-up and fulfill definitive initialization requirements, state and loopQueue are
duplicated in local variables.
*/
// The internal loopQueue is a serial queue targeting the provided queue, so that targeting a concurrent queue
// doesn’t result in concurrent work on the underlying MobiusLoop. This behaviour is documented on
// `Mobius.Builder.makeController`.
let loopQueue = DispatchQueue(label: "MobiusController \(Model.self)", target: loopTargetQueue)
self.loopQueue = loopQueue
self.viewQueue = viewQueue
let state = State(
state: StoppedState(modelToStartFrom: initialModel, viewConnectables: [:]),
queue: loopQueue
)
self.state = state
// Maps an event consumer to a new event consumer that asynchronously invokes the original on the loop queue.
//
// The input will be the core `MobiusLoop`’s event dispatcher, which asserts that it isn’t invoked after the
// loop is disposed. This doesn’t play nicely with asynchrony, so here we fail silently if the controller is
// stopped before the asynchronous block executes.
func flipEventsToLoopQueue(consumer: @escaping Consumer<Event>) -> Consumer<Event> {
return { event in
loopQueue.async {
guard state.running else {
// If we got here, the controller was stopped while this async block was queued. Callers can’t
// possibly avoid this except through complete external serialization of all access to the
// controller, so it’s not a usage error.
//
// Note that since we’re on the loop queue at this point, `state` can’t be transitional; it is
// necessarily fully running or stopped at this point.
return
}
consumer(event)
}
}
}
// Wrap initiator (if any) in a logger
let actualInitiate: Initiate<Model, Effect>
if let initiate = initiate {
actualInitiate = logger.wrap(initiate: initiate)
} else {
actualInitiate = { First(model: $0) }
}
let decoratedBuilder = builder
.withEventConsumerTransformer(flipEventsToLoopQueue)
loopFactory = { model in
let first = actualInitiate(model)
return decoratedBuilder.start(from: first.model, effects: first.effects)
}
}
deinit {
if running {
stop()
}
}
/// Connect a view to this controller.
///
/// The `Connectable` will be given an event consumer, which the view should use to send events to the `MobiusLoop`.
/// The view should also return a `Connection` that accepts models and renders them. Disposing the connection should
/// make the view stop emitting events.
///
/// - Parameter connectable: the view to connect
/// - Returns: an identifier that can be used to disconnect the view
/// - Attention: fails via `MobiusHooks.errorHandler` if the loop is running
@discardableResult
public func connectView<ViewConnectable: Connectable>(
_ connectable: ViewConnectable
) -> UUID where ViewConnectable.Input == Model, ViewConnectable.Output == Event {
do {
let id = UUID()
try state.mutate { stoppedState in
stoppedState.viewConnectables[id] = AsyncDispatchQueueConnectable(connectable, acceptQueue: viewQueue)
}
return id
} catch {
MobiusHooks.errorHandler(
errorMessage(error, default: "\(Self.debugTag): cannot connect a view while running"),
#file,
#line
)
}
}
/// Disconnect the connected view from this controller.
///
/// May not be called directly from an effect handler running on the controller’s loop queue.
///
/// - Attention: fails via `MobiusHooks.errorHandler` if the loop is running, if there is more than 1 connection,
/// or if there isn't anything to disconnect
public func disconnectView() {
do {
try state.mutate { stoppedState in
guard stoppedState.viewConnectables.count <= 1 else {
throw ErrorMessage(message: "\(Self.debugTag): missing view connection id, cannot disconnect")
}
guard let id = stoppedState.viewConnectables.keys.first else {
throw ErrorMessage(message: "\(Self.debugTag): no view connected, cannot disconnect")
}
stoppedState.viewConnectables[id] = nil
}
} catch {
MobiusHooks.errorHandler(
errorMessage(error, default: "\(Self.debugTag): cannot disconnect view while running; call stop first"),
#file,
#line
)
}
}
/// Disconnect a connected view from this controller.
///
/// May not be called directly from an effect handler running on the controller’s loop queue.
///
/// - Parameter id: the identifier received from calling `connectView(_:)`
/// - Attention: fails via `MobiusHooks.errorHandler` if the loop is running or if the id is not connected
public func disconnectView(id: UUID) {
do {
try state.mutate { stoppedState in
guard stoppedState.viewConnectables[id] != nil else {
throw ErrorMessage(message: "\(Self.debugTag): invalid view connection, cannot disconnect")
}
stoppedState.viewConnectables[id] = nil
}
} catch {
MobiusHooks.errorHandler(
errorMessage(error, default: "\(Self.debugTag): cannot disconnect view while running; call stop first"),
#file,
#line
)
}
}
/// Start a MobiusLoop from the current model.
///
/// May not be called directly from an effect handler running on the controller’s loop queue.
///
/// - Attention: fails via `MobiusHooks.errorHandler` if the loop already is running.
public func start() {
do {
try state.transitionToRunning { stoppedState in
let loop = loopFactory(stoppedState.modelToStartFrom)
let disposables: [Disposable] = [loop] + stoppedState.viewConnectables.values.map { connectable in
let connection = connectable.connect { [weak loop] event in
guard let loop = loop else {
// This failure should not be reached under normal circumstances because it is handled by
// AsyncDispatchQueueConnectable. Stopping here means that the viewConnectable called its
// consumer reference after stop() has disposed the connection and deallocated the loop.
MobiusHooks.errorHandler("\(Self.debugTag): cannot use invalid consumer", #file, #line)
}
loop.unguardedDispatchEvent(event)
}
loop.addObserver(connection.accept)
return connection
}
return RunningState(
loop: loop,
viewConnectables: stoppedState.viewConnectables,
disposables: CompositeDisposable(disposables: disposables)
)
}
} catch {
MobiusHooks.errorHandler(
errorMessage(error, default: "\(Self.debugTag): cannot start a controller while already running"),
#file,
#line
)
}
}
/// Stop the currently running MobiusLoop.
///
/// When the loop is stopped, the last model of the loop will be remembered and used as the first model the next
/// time the loop is started.
///
/// May not be called directly from an effect handler running on the controller’s loop queue.
/// To stop the loop as an effect, dispatch to a different queue.
///
/// - Attention: fails via `MobiusHooks.errorHandler` if the loop isn't running
public func stop() {
do {
try state.transitionToStopped { runningState in
runningState.disposables.dispose()
return StoppedState(
modelToStartFrom: runningState.loop.latestModel,
viewConnectables: runningState.viewConnectables
)
}
} catch {
MobiusHooks.errorHandler(
errorMessage(error, default: "\(Self.debugTag): cannot stop a controller while not running"),
#file,
#line
)
}
}
/// Replace which model the controller should start from.
///
/// May not be called directly from an effect handler running on the controller’s loop queue.
///
/// - Parameter model: the model with the state the controller should start from
/// - Attention: fails via `MobiusHooks.errorHandler` if the loop is running
public func replaceModel(_ model: Model) {
do {
try state.mutate { stoppedState in
stoppedState.modelToStartFrom = model
}
} catch {
MobiusHooks.errorHandler(
errorMessage(error, default: "\(Self.debugTag): cannot replace model while running"),
#file,
#line
)
}
}
/// Get the current model of the loop that this controller is running, or the most recent model if it's not running.
///
/// May not be called directly from an effect handler running on the controller’s loop queue.
///
/// - Returns: a model with the state of the controller
public var model: Model {
return state.syncRead {
switch $0 {
case .stopped(let state):
return state.modelToStartFrom
case .running(let state):
return state.loop.latestModel
}
}
}
/// Simple error that just carries an error message out of a closure for us
private struct ErrorMessage: Error {
let message: String
}
/// If `error` is an `ErrorMessage`, return its payload; otherwise, return the provided default message.
private func errorMessage(_ error: Swift.Error, default defaultMessage: String) -> String {
if let errorMessage = error as? ErrorMessage {
return errorMessage.message
} else {
return defaultMessage
}
}
private static var debugTag: String {
return "MobiusController<\(Model.self), \(Event.self), \(Effect.self)>"
}
}