MobiusCore/Source/MobiusLoop.swift (101 lines of code) (raw):
// Copyright Spotify AB.
// SPDX-License-Identifier: Apache-2.0
import Foundation
/// A `MobiusLoop` is the core encapsulation of business logic in Mobius.
///
/// It stores a current model, applies incoming events, processes the resulting effects, and broadcasts model changes
/// to observers.
///
/// Use `Mobius.loop(update:effectHandler:)` to create an instance.
public final class MobiusLoop<Model, Event, Effect>: Disposable {
private let access = ConcurrentAccessDetector()
private var workBag: WorkBag
private var effectConnection: Connection<Effect>! = nil
private var consumeEvent: Consumer<Event>! = nil
private let modelPublisher: ConnectablePublisher<Model>
private var model: Model
private var disposable: CompositeDisposable?
init(
model: Model,
update: Update<Model, Event, Effect>,
eventSource: AnyEventSource<Event>,
eventConsumerTransformer: ConsumerTransformer<Event>,
effectHandler: AnyConnectable<Effect, Event>,
effects: [Effect],
logger: AnyMobiusLogger<Model, Event, Effect>
) {
let loggingUpdate = logger.wrap(update: update.updateClosure)
let workBag = WorkBag(accessGuard: access)
self.workBag = workBag
self.model = model
self.modelPublisher = ConnectablePublisher<Model>(accessGuard: access)
// consumeEvent is the closure that processes an event and handles the model and effect updates. It needs to
// be a closure so that it can be transformed by eventConsumerTransformer, and to handle ownership correctly:
// consumeEvent holds on to the update function and workbag, and also holds self while its work bag submission
// is queued.
//
// Originally the processNext(...) invocation was wrapped in a method, but that just spread things out more.
let consumeEvent = eventConsumerTransformer { [unowned self] event in
// Note: captures self strongly until the block is serviced by the workBag
let processNext = self.processNext
workBag.submit {
// Note: we must read self.model inside the submit block, since other queued blocks may have executed
// between submitting and getting here.
// This is an unowned read of `self`, but at this point `self` is being kept alive by the local
// `processNext`.
let model = self.model
processNext(loggingUpdate(model, event))
}
workBag.service()
}
self.consumeEvent = consumeEvent
// These must be set up after consumeEvent, which refers to self; that’s why they need to be IUOs.
self.effectConnection = effectHandler.connect(consumeEvent)
let eventSourceDisposable = eventSource.subscribe(consumer: consumeEvent)
self.disposable = CompositeDisposable(disposables: [
effectConnection,
modelPublisher,
eventSourceDisposable,
])
// Prime the modelPublisher, and queue up any initial effects.
processNext(.next(model, effects: effects))
// When we’re fully initialized, we can process any initial effects plus events that may have been queued up
// by the effect handler or event source when we connected to them.
workBag.start()
}
deinit {
dispose()
}
/// Add an observer of model changes to this loop. If `getMostRecentModel()` is non-nil,
/// the observer will immediately be notified of the most recent model. The observer will be
/// notified of future changes to the model until the loop or the returned `Disposable` is
/// disposed.
///
/// - Parameter consumer: an observer of model changes
/// - Returns: a `Disposable` that can be used to stop further notifications to the observer
@discardableResult
public func addObserver(_ consumer: @escaping Consumer<Model>) -> Disposable {
return access.guard {
modelPublisher.connect(to: consumer)
}
}
public func dispose() {
access.guard {
let disposable = self.disposable
self.disposable = nil
disposable?.dispose()
}
}
/// Extract the latest model from the loop.
///
/// This property is discouraged; in general, it is preferable to add an observer with `addObserver`.
public var latestModel: Model {
return access.guard { model }
}
/// Send an event to the loop.
///
/// - Parameter event: The event to dispatch.
public func dispatchEvent(_ event: Event) {
return access.guard {
guard !disposed else {
// Callers are responsible for ensuring dispatchEvent is never entered after dispose.
MobiusHooks.errorHandler("\(debugTag): event submitted after dispose", #file, #line)
}
unguardedDispatchEvent(event)
}
}
/// Like `dispatchEvent`, but without asserting that the loop hasn’t been disposed.
///
/// This should not be used directly, but is useful in constructing asynchronous wrappers around loops (like
/// `MobiusController`, where the `eventConsumerTransformer` is used to implement equivalent async-safe assertions).
public func unguardedDispatchEvent(_ event: Event) {
consumeEvent(event)
}
// MARK: - Implementation details
/// Apply a `Next`:
///
/// * Store the new model, if any, in self.model
/// * Post the new model, if any, to observers
/// * Queue up any effects in the workBag
/// * Service the workBag
private func processNext(_ next: Next<Model, Effect>) {
if let newModel = next.model {
model = newModel
modelPublisher.post(model)
}
for effect in next.effects {
workBag.submit {
self.effectConnection.accept(effect)
}
}
workBag.service()
}
/// Test whether the loop has been disposed.
private var disposed: Bool {
return disposable == nil
}
/// A string to identify the MobiusLoop; currently the type name including type arguments.
fileprivate var debugTag: String {
return "\(type(of: self))"
}
}
extension MobiusLoop: CustomDebugStringConvertible {
public var debugDescription: String {
return access.guard {
if disposed {
return "disposed \(debugTag)!"
}
return "\(debugTag){ \(String(reflecting: model)) }"
}
}
}