MobiusCore/Source/MobiusController.swift (181 lines of code) (raw):
// 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>
typealias ViewConnection = Connection<Model>
private struct StoppedState {
var modelToStartFrom: Model
var viewConnectable: ViewConnectable?
}
private struct RunningState {
var loop: Loop
var viewConnectable: 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, viewConnectable: nil),
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.
///
/// May not be called while the loop is running.
///
/// 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.
///
/// - Attention: fails via `MobiusHooks.errorHandler` if the loop is running or if the controller already is
/// connected
public func connectView<ViewConnectable: Connectable>(
_ connectable: ViewConnectable
) where ViewConnectable.Input == Model, ViewConnectable.Output == Event {
do {
try state.mutate { stoppedState in
guard stoppedState.viewConnectable == nil else {
throw ErrorMessage(message: "\(Self.debugTag): only one view may be connected at a time")
}
stoppedState.viewConnectable = AsyncDispatchQueueConnectable(connectable, acceptQueue: viewQueue)
}
} 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 or if there isn't anything to
/// disconnect
public func disconnectView() {
do {
try state.mutate { stoppedState in
guard stoppedState.viewConnectable != nil else {
throw ErrorMessage(message: "\(Self.debugTag): no view connected, cannot disconnect")
}
stoppedState.viewConnectable = 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)
var disposables: [Disposable] = [loop]
if let viewConnectable = stoppedState.viewConnectable {
let viewConnection = viewConnectable.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(viewConnection.accept)
disposables.append(viewConnection)
}
return RunningState(
loop: loop,
viewConnectable: stoppedState.viewConnectable,
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
let model = runningState.loop.latestModel
runningState.disposables.dispose()
return StoppedState(modelToStartFrom: model, viewConnectable: runningState.viewConnectable)
}
} 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)>"
}
}