Comments (3)
This is a very good question, and something important to attack.
It takes some time to develop the machinery necessary to attack this problem, but I can sketch a bit of what is necessary.
First, ideally everything is done in the reducer instead of the scene delete. This makes it testable and more understandable. But, as you said, it's difficult to do with long living effects.
The idea is that you have a "kick-off" action that starts up the effect, such as onAppear
in SwiftUI or viewWillAppear
in UIKit. Then you have a tear down action to stop the effect, such as onDisappear
or viewDidDisappear
.
And the way you can stop an effect is to introduce an operator that enhances an effect with the capability of being canceled, as well as a new effect that is capable of canceling an in-flight cancellable effect. This gist does that:
https://gist.github.com/mbrandonw/1c4ca76f1927420921b3871dd2153497
(also a fellow Point-Free subscriber @alexito4 made a nice video on using this code http://youtube.com/watch?v=VAB3lysXU9o)
Once you have those capabilities you can do things like this in your reducer:
struct CancelNotificationToken: Hashable { static let shared = CancelNotificationToken() }
case .onAppear:
// Start listening for notifications
return [
NotificationCenter.default.publisher
.map(MyAction.receivedNotification)
.eraseToEffect()
]
case .onDisappear:
// Stop listening for notifications
return [
Effect.cancel(id: CancelNotificationToken.shared)
.fireAndForget(),
]
That's the basics. The ergonomics could probably be improved a bit, and there may be some edge cases to think through (especially because we haven't done this with notification center yet, so there may be more to do for this case), but this is how you can start listening for long-living effects and then shut them down when you are done.
Hope it helps!
from episode-code-samples.
My approach to this problem is based on what Elm calls a "subscription". I call it an "extrinsic" in my implementation to avoid confusion with Combine's Subscription
. You can find my complete implementation in this gist.
Elm subscriptions work like this:
-
Elm has an opaque type
Sub
that represents a persistent source of external events. -
Various Elm kernel packages provide functions to construct subscriptions. This includes things like
Time.every
andBrowser.onKeyPress
. -
When you create your
Platform.Program
using a function likeBrowser.element
, you give it asubscriptions
function. Thesubscriptions
function takes your model as an argument and returns aSub
. -
Elm provides
Sub.none
andSub.batch
, so the oneSub
yoursubscriptions
function returns can actually be any number of subscriptions, including zero. -
After every model update, the Elm kernel calls your
subscriptions
function on the new model and intelligently starts/stops subscriptions as needed.
I implemented a similar API in my version of the Composable Architecture as follows:
-
I defined a protocol
Extrinsic
:public protocol Extrinsic: Hashable { associatedtype Action func publisher() -> AnyPublisher<Action, Never> }
It's a subprotocol of
Hashable
so that theStore
can efficiently determine which extrinics are added and removed after each model update. -
I modified the root
Store
constructor to be parameterized by a concreteExtrinsic
type, and to take anextrinsics
function that returns the desired extrinsics for a model value. In my implementation, the constructor is a static method:public static func root<Ex: Extrinsic>( withModel model: Model, reducer: Reducer<Model, Action>, extrinsics: @escaping (Model) -> Set<Ex> ) -> Store<Model, Action> where Ex.Action == Action { return RootStore( model: model, reducer: reducer, extrinsicCollection: ExtrinsicCollection(extrinsics)) }
-
The
ExtrinsicCollection
class holds theextrinsics
method and manages the CombineSubscription
s to the current set of extrinsics. It is generic over both the model type and the extrinsic type, but it subclassesExtrinsicCollectionBase
, which is only generic over the model and action types:fileprivate class ExtrinsicCollectionBase<Model, Action> { func update(with store: RootStore<Model, Action>) { fatalError("subclass responsibility") } } fileprivate class ExtrinsicCollection<Model, Ex: Extrinsic>: ExtrinsicCollectionBase<Model, Ex.Action> { init(_ make: @escaping (Model) -> Set<Ex>) { self.make = make } override func update(with store: RootStore<Model, Ex.Action>) { let exs = make(store.model) for ex in ticketForEx.keys.filter({ !exs.contains($0) }) { ticketForEx.removeValue(forKey: ex)?.cancel() } for ex in exs.subtracting(ticketForEx.keys) { ticketForEx[ex] = ex.publisher() .sink(receiveValue: { [weak store] in store?.send($0) }) } } private let make: (Model) -> Set<Ex> private var ticketForEx: [Ex: AnyCancellable] = [:] }
-
The root
Store
has a property that holds itsExtrinsicCollection
. SinceExtrinsicCollectionBase
is generic over the same types asStore
(and not over theExtrinsic
type), theStore
doesn't have to be generic over the type implementingExtrinsic
:private let extrinsicCollection: ExtrinsicCollectionBase<Model, Action>?
-
Every time the
Store
handles an action, after running effects, it updates itsextrinsicCollection
:override func send(_ action: Action) { let effects = reducer.apply(action, to: &_model) for effect in effects { var ticket: AnyCancellable? = nil ticket = effect.sink( receiveCompletion: { _ in ticket = nil }, receiveValue: { self.send($0) }) withExtendedLifetime(ticket) { } } extrinsicCollection?.update(with: self) subject.send() }
Here's what it looks like in use. Let's say I have this (ridiculous) model:
struct MyModel {
var timers: [String: TimeInterval] = [:]
var dings: [String] = []
}
The user can add and remove repeating timers with names. Every time a timer fires, I add its name to dings
. But if the current AVAudioSession
gets interrupted, I want to remove all timers and clear dings
. Here are my actions and my reducer:
extension MyModel {
enum Action {
case setTimer(String, TimeInterval)
case removeTimer(String)
case timerFired(String)
case audioSessionInterrupted
}
mutating func apply(_ action: Action) -> [Effect<Action>] {
switch action {
case .setTimer(let name, let interval):
timers[name] = interval
case .removeTimer(let name):
timers.removeValue(forKey: name)
case .timerFired(let name):
dings.append(name)
case .audioSessionInterrupted:
timers = [:]
dings = []
}
return []
}
}
I define an extrinsic for a repeating timer and another for audio session interruptions:
extension MyModel {
enum Extrinsics: Extrinsic {
case timer(String, TimeInterval)
case audioSessionInterruptions
func publisher() -> AnyPublisher<Action, Never> {
switch self {
case .timer(let name, let interval):
return Timer.publish(every: interval, on: .main, in: .common)
.map { _ in Action.timerFired(name) }
.eraseToAnyPublisher()
case .audioSessionInterruptions:
return NotificationCenter.default
.publisher(for: .AVAudioSessionInterruption)
.map { _ in Action.audioSessionInterrupted }
.eraseToAnyPublisher()
}
}
}
func extrinsics() -> Set<Extrinsics> {
var exs = Set(timers.map { Extrinsics.timer($0, $1) })
if !timers.isEmpty {
exs.insert(.audioSessionInterruptions)
}
return exs
}
}
And then I create my Store
like this:
let rootStore = Store<MyModel, MyModel.Action>.root(
withModel: .init(timers: [:]),
reducer: Reducer { $0.apply($1) },
extrinsics: { $0.extrinsics() })
from episode-code-samples.
How might I handle testing long running observers using the Step test architecture?
I have the following test, and I believe my mock callbacks are being called correctly as the trips array is assigned correctly and the state equality test passes, but the receive step fails with "Timed out waiting for the effect to complete".
func testLoadAllTrips() {
assert(
initialValue: TripListView.ViewState(trips: [], selection: Set()),
reducer: tripFeatureReducer,
environment: Apollo(MockApollo<AllTripsQuery>(store: ApolloStore(cache: InMemoryNormalizedCache()), watchQuery: {
return GraphQLResult<AllTripsQuery.Data>.init(data: .init(tripsList: .init(items: [.init(id: "geneva2020", name: "Geneva 2020", summary: "Trip to Geneva to rennovate apartment")])), errors: nil, source: .server, dependentKeys: nil)
})),
steps:
Step(.send, .watchTrips) {
$0.trips = []
},
Step(.receive, .tripsDidRefresh(.success([geneva]))) {
$0.trips = [geneva]
}
// ,
// Step(.send, .stopWatchingTrips) { _ in
// }
)
}
Adding the final .stopWatchingTrips action fails because it of course hasn't finished the receive step, which makes sense.
Do we need a 3rd step type to represent cancellable?
from episode-code-samples.
Related Issues (20)
- @FocusState not working with new @BindableState API HOT 5
- UIApplication.shared.windows deprecated in iOS 15 HOT 1
- 0167 Navigation is weird on iPad HOT 1
- Please fix the error Episode #76 ~ HOT 2
- [0171-modularization-pt1] Setting Up HOT 3
- modularization folders architecture HOT 1
- Missing episode 187: URL Route? HOT 1
- Cannot convert value of type - Examples will not compile using Xcode 13.3 HOT 2
- Request: Re-Record Episode 100 to 103 and Update Source Code HOT 3
- Episode 196: Runtime warning work only in half of the cases HOT 1
- minor bug in episode 198 sample HOT 1
- Code sample for episode 220 is missing HOT 1
- Strange navigation on episode 220 HOT 1
- Episode 2 -> Fish playground -> Ambiguous use of 'init(utf8String:)'
- Episode 0228 - Composable Navigation: Destinations sample code not found HOT 1
- navigationDestination not triggered HOT 3
- Modern SwiftUI: Standups App HOT 1
- Episode 241 code not published HOT 2
- Episode 109 sheet show twice when item name changed HOT 1
- Typo in Navigation Example code
Recommend Projects
-
React
A declarative, efficient, and flexible JavaScript library for building user interfaces.
-
Vue.js
🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
-
Typescript
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
-
TensorFlow
An Open Source Machine Learning Framework for Everyone
-
Django
The Web framework for perfectionists with deadlines.
-
Laravel
A PHP framework for web artisans
-
D3
Bring data to life with SVG, Canvas and HTML. 📊📈🎉
-
Recommend Topics
-
javascript
JavaScript (JS) is a lightweight interpreted programming language with first-class functions.
-
web
Some thing interesting about web. New door for the world.
-
server
A server is a program made to process requests and deliver data to clients.
-
Machine learning
Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.
-
Visualization
Some thing interesting about visualization, use data art
-
Game
Some thing interesting about game, make everyone happy.
Recommend Org
-
Facebook
We are working to build community through open source technology. NB: members must have two-factor auth.
-
Microsoft
Open source projects and samples from Microsoft.
-
Google
Google ❤️ Open Source for everyone.
-
Alibaba
Alibaba Open Source for everyone
-
D3
Data-Driven Documents codes.
-
Tencent
China tencent open source team.
from episode-code-samples.