6
6
import Foundation
7
7
import Combine
8
8
import SwiftUI
9
+ import os
9
10
10
11
/// Fx is a publisher that publishes actions and never fails.
11
12
public typealias Fx < Action> = AnyPublisher < Action , Never >
@@ -16,11 +17,15 @@ public protocol ModelProtocol: Equatable {
16
17
associatedtype Action
17
18
associatedtype Environment
18
19
20
+ associatedtype UpdateType : UpdateProtocol where
21
+ UpdateType. Model == Self ,
22
+ UpdateType. Action == Self . Action
23
+
19
24
static func update(
20
25
state: Self ,
21
26
action: Action ,
22
27
environment: Environment
23
- ) -> Update < Self >
28
+ ) -> UpdateType
24
29
}
25
30
26
31
extension ModelProtocol {
@@ -35,16 +40,16 @@ extension ModelProtocol {
35
40
state: Self ,
36
41
actions: [ Action ] ,
37
42
environment: Environment
38
- ) -> Update < Self > {
43
+ ) -> UpdateType {
39
44
actions. reduce (
40
- Update ( state: state) ,
45
+ UpdateType ( state: state) ,
41
46
{ result, action in
42
47
let next = update (
43
48
state: result. state,
44
49
action: action,
45
50
environment: environment
46
51
)
47
- return Update (
52
+ return UpdateType (
48
53
state: next. state,
49
54
fx: result. fx. merge ( with: next. fx) . eraseToAnyPublisher ( ) ,
50
55
transaction: next. transaction
@@ -74,70 +79,66 @@ extension ModelProtocol {
74
79
state: Self ,
75
80
action viewAction: ViewModel . Action ,
76
81
environment: ViewModel . Environment
77
- ) -> Update < Self > {
82
+ ) -> UpdateType {
78
83
// If getter returns nil (as in case of a list item that no longer
79
84
// exists), do nothing.
80
85
guard let inner = get ( state) else {
81
- return Update ( state: state)
86
+ return UpdateType ( state: state)
82
87
}
83
88
let next = ViewModel . update (
84
89
state: inner,
85
90
action: viewAction,
86
91
environment: environment
87
92
)
88
- return Update (
93
+ return UpdateType (
89
94
state: set ( state, next. state) ,
90
95
fx: next. fx. map ( tag) . eraseToAnyPublisher ( ) ,
91
96
transaction: next. transaction
92
97
)
93
98
}
94
99
}
95
100
96
- /// Update represents a state change, together with an `Fx` publisher,
101
+ /// `UpdateProtocol` represents a state change, together with an `Fx` publisher,
97
102
/// and an optional `Transaction`.
98
- public struct Update < Model: ModelProtocol > {
99
- /// `State` for this update
100
- public var state : Model
101
- /// `Fx` for this update.
102
- /// Default is an `Empty` publisher (no effects)
103
- public var fx : Fx < Model . Action >
104
- /// The transaction that should be set during this update.
105
- /// Store uses this value to set the transaction while updating state,
106
- /// allowing you to drive explicit animations from your update function.
107
- /// If left `nil`, store will defer to the global transaction
108
- /// for this state update.
109
- /// See https://developer.apple.com/documentation/swiftui/transaction
110
- public var transaction : Transaction ?
111
-
112
- public init (
103
+ public protocol UpdateProtocol {
104
+ associatedtype Model
105
+ associatedtype Action
106
+
107
+ init (
113
108
state: Model ,
114
- fx: Fx < Model . Action > ,
109
+ fx: Fx < Action > ,
115
110
transaction: Transaction ?
116
- ) {
117
- self . state = state
118
- self . fx = fx
119
- self . transaction = transaction
120
- }
111
+ )
121
112
113
+ var state : Model { get set }
114
+ var fx : Fx < Action > { get set }
115
+ var transaction : Transaction ? { get set }
116
+ }
117
+
118
+ extension UpdateProtocol {
122
119
public init ( state: Model , animation: Animation ? = nil ) {
123
- self . state = state
124
- self . fx = Empty ( completeImmediately: true ) . eraseToAnyPublisher ( )
125
- self . transaction = Transaction ( animation: animation)
120
+ self . init (
121
+ state: state,
122
+ fx: Empty ( completeImmediately: true ) . eraseToAnyPublisher ( ) ,
123
+ transaction: Transaction ( animation: animation)
124
+ )
126
125
}
127
-
126
+
128
127
public init (
129
128
state: Model ,
130
- fx: Fx < Model . Action > ,
129
+ fx: Fx < Action > ,
131
130
animation: Animation ? = nil
132
131
) {
133
- self . state = state
134
- self . fx = fx
135
- self . transaction = Transaction ( animation: animation)
132
+ self . init (
133
+ state: state,
134
+ fx: fx,
135
+ transaction: Transaction ( animation: animation)
136
+ )
136
137
}
137
-
138
+
138
139
/// Merge existing fx together with new fx.
139
140
/// - Returns a new `Update`
140
- public func mergeFx( _ fx: Fx < Model . Action > ) -> Update < Model > {
141
+ public func mergeFx( _ fx: Fx < Action > ) -> Self {
141
142
var this = self
142
143
this. fx = self . fx. merge ( with: fx) . eraseToAnyPublisher ( )
143
144
return this
@@ -153,6 +154,34 @@ public struct Update<Model: ModelProtocol> {
153
154
}
154
155
}
155
156
157
+ /// Concrete implementation of `UpdateProtocol`.
158
+ /// Update represents a state change, together with an `Fx` publisher,
159
+ /// and an optional `Transaction`.
160
+ public struct Update < Model: ModelProtocol > : UpdateProtocol {
161
+ /// `State` for this update
162
+ public var state : Model
163
+ /// `Fx` for this update.
164
+ /// Default is an `Empty` publisher (no effects)
165
+ public var fx : Fx < Model . Action >
166
+ /// The transaction that should be set during this update.
167
+ /// Store uses this value to set the transaction while updating state,
168
+ /// allowing you to drive explicit animations from your update function.
169
+ /// If left `nil`, store will defer to the global transaction
170
+ /// for this state update.
171
+ /// See https://developer.apple.com/documentation/swiftui/transaction
172
+ public var transaction : Transaction ?
173
+
174
+ public init (
175
+ state: Model ,
176
+ fx: Fx < Model . Action > ,
177
+ transaction: Transaction ?
178
+ ) {
179
+ self . state = state
180
+ self . fx = fx
181
+ self . transaction = transaction
182
+ }
183
+ }
184
+
156
185
/// A store is any type that can
157
186
/// - get a state
158
187
/// - send actions
@@ -175,17 +204,41 @@ public protocol StoreProtocol {
175
204
public final class Store < Model> : ObservableObject , StoreProtocol
176
205
where Model: ModelProtocol
177
206
{
178
- /// Stores cancellables by ID
179
- private( set) var cancellables : [ UUID : AnyCancellable ] = [ : ]
207
+ /// Cancellable for fx subscription.
208
+ private var cancelFx : AnyCancellable ?
209
+
180
210
/// Private for all actions sent to the store.
181
- private var _actions : PassthroughSubject < Model . Action , Never >
211
+ private var _actions = PassthroughSubject < Model . Action , Never > ( )
212
+
182
213
/// Publisher for all actions sent to the store.
183
214
public var actions : AnyPublisher < Model . Action , Never > {
184
215
_actions. eraseToAnyPublisher ( )
185
216
}
217
+
218
+ /// Source publisher for batches of fx modeled as publishers.
219
+ private var _fxBatches = PassthroughSubject < Fx < Model . Action > , Never > ( )
220
+
221
+ /// `fx` represents a flat stream of actions from all fx publishers.
222
+ private var fx : AnyPublisher < Model . Action , Never > {
223
+ _fxBatches
224
+ . flatMap ( { publisher in publisher } )
225
+ . receive ( on: DispatchQueue . main)
226
+ . eraseToAnyPublisher ( )
227
+ }
228
+
229
+ /// Publisher for updates performed on state
230
+ private var _updates = PassthroughSubject < Model . UpdateType , Never > ( )
231
+
232
+ /// Publisher for updates performed on state.
233
+ /// `updates` is guaranteed to fire after the state has changed.
234
+ public var updates : AnyPublisher < Model . UpdateType , Never > {
235
+ _updates. eraseToAnyPublisher ( )
236
+ }
237
+
186
238
/// Current state.
187
239
/// All writes to state happen through actions sent to `Store.send`.
188
240
@Published public private( set) var state : Model
241
+
189
242
/// Environment, which typically holds references to outside information,
190
243
/// such as API methods.
191
244
///
@@ -202,24 +255,47 @@ where Model: ModelProtocol
202
255
/// app is stopped.
203
256
public var environment : Model . Environment
204
257
258
+ /// Logger to log actions sent to store.
259
+ private var logger : Logger
260
+ /// Should log?
261
+ var loggingEnabled : Bool
262
+
205
263
public init (
206
264
state: Model ,
207
- environment: Model . Environment
265
+ environment: Model . Environment ,
266
+ loggingEnabled: Bool = false ,
267
+ logger: Logger ? = nil
208
268
) {
209
269
self . state = state
210
270
self . environment = environment
211
- self . _actions = PassthroughSubject < Model . Action , Never > ( )
271
+ self . loggingEnabled = loggingEnabled
272
+ self . logger = logger ?? Logger (
273
+ subsystem: " ObservableStore " ,
274
+ category: " Store "
275
+ )
276
+
277
+ self . cancelFx = self . fx
278
+ . sink ( receiveValue: { [ weak self] action in
279
+ self ? . send ( action)
280
+ } )
212
281
}
213
282
214
283
/// Initialize with a closure that receives environment.
215
284
/// Useful for initializing model properties from environment, and for
216
285
/// kicking off actions once at store creation.
217
286
public convenience init (
218
287
create: ( Model . Environment ) -> Update < Model > ,
219
- environment: Model . Environment
288
+ environment: Model . Environment ,
289
+ loggingEnabled: Bool = false ,
290
+ logger: Logger ? = nil
220
291
) {
221
292
let update = create ( environment)
222
- self . init ( state: update. state, environment: environment)
293
+ self . init (
294
+ state: update. state,
295
+ environment: environment,
296
+ loggingEnabled: loggingEnabled,
297
+ logger: logger
298
+ )
223
299
self . subscribe ( to: update. fx)
224
300
}
225
301
@@ -229,69 +305,40 @@ where Model: ModelProtocol
229
305
public convenience init (
230
306
state: Model ,
231
307
action: Model . Action ,
232
- environment: Model . Environment
308
+ environment: Model . Environment ,
309
+ loggingEnabled: Bool = false ,
310
+ logger: Logger ? = nil
233
311
) {
234
- self . init ( state: state, environment: environment)
312
+ self . init (
313
+ state: state,
314
+ environment: environment,
315
+ loggingEnabled: loggingEnabled,
316
+ logger: logger
317
+ )
235
318
self . send ( action)
236
319
}
237
320
238
- /// Subscribe to a publisher of actions, piping them through to
239
- /// the store.
240
- ///
241
- /// Holds on to the cancellable until publisher completes.
242
- /// When publisher completes, removes cancellable.
321
+ /// Subscribe to a publisher of actions, send the actions it publishes
322
+ /// to the store.
243
323
public func subscribe( to fx: Fx < Model . Action > ) {
244
- // Create a UUID for the cancellable.
245
- // Store cancellable in dictionary by UUID.
246
- // Remove cancellable from dictionary upon effect completion.
247
- // This retains the effect pipeline for as long as it takes to complete
248
- // the effect, and then removes it, so we don't have a cancellables
249
- // memory leak.
250
- let id = UUID ( )
251
-
252
- // Receive Fx on main thread. This does two important things:
253
- //
254
- // First, SwiftUI requires that any state mutations that would change
255
- // views happen on the main thread. Receiving on main ensures that
256
- // all fx-driven state transitions happen on main, even if the
257
- // publisher is off-main-thread.
258
- //
259
- // Second, if we didn't schedule receive on main, it would be possible
260
- // for publishers to complete immediately, causing receiveCompletion
261
- // to attempt to remove the publisher from `cancellables` before
262
- // it is added. By scheduling to receive publisher on main,
263
- // we force publisher to complete on next tick, ensuring that it
264
- // is always first added, then removed from `cancellables`.
265
- let cancellable = fx
266
- . receive (
267
- on: DispatchQueue . main,
268
- options: . init( qos: . default)
269
- )
270
- . sink (
271
- receiveCompletion: { [ weak self] _ in
272
- self ? . cancellables. removeValue ( forKey: id)
273
- } ,
274
- receiveValue: { [ weak self] action in
275
- self ? . send ( action)
276
- }
277
- )
278
- self . cancellables [ id] = cancellable
324
+ self . _fxBatches. send ( fx)
279
325
}
280
326
281
327
/// Send an action to the store to update state and generate effects.
282
328
/// Any effects generated are fed back into the store.
283
329
///
284
330
/// Note: SwiftUI requires that all UI changes happen on main thread.
285
- /// We run effects as-given, without forcing them on to main thread.
286
- /// This means that main-thread effects will be run immediately, enabling
287
- /// you to drive things like withAnimation via actions.
288
- /// However it also means that publishers which run off-main-thread MUST
289
- /// make sure that they join the main thread (e.g. with
290
- /// `.receive(on: DispatchQueue.main)`).
331
+ /// `send(_:)` is run *synchronously*. It is up to you to guarantee it is
332
+ /// run on main thread when SwiftUI is being used.
291
333
public func send( _ action: Model . Action ) {
292
- /// Broadcast action to any outside subscribers
293
- self . _actions. send ( action)
294
- // Generate next state and effect
334
+ if loggingEnabled {
335
+ logger. log ( " Action: \( String ( describing: action) ) " )
336
+ }
337
+
338
+ // Dispatch action before state change
339
+ _actions. send ( action)
340
+
341
+ // Create next state update
295
342
let next = Model . update (
296
343
state: self . state,
297
344
action: action,
@@ -319,8 +366,12 @@ where Model: ModelProtocol
319
366
self . state = next. state
320
367
}
321
368
}
322
- // Run effect
369
+
370
+ // Run effects
323
371
self . subscribe ( to: next. fx)
372
+
373
+ // Dispatch update after state change
374
+ self . _updates. send ( next)
324
375
}
325
376
}
326
377
0 commit comments