Handling long-running observers #93
Replies: 4 comments
-
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 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! |
Beta Was this translation helpful? Give feedback.
-
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 Elm subscriptions work like this:
I implemented a similar API in my version of the Composable Architecture as follows:
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 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 let rootStore = Store<MyModel, MyModel.Action>.root(
withModel: .init(timers: [:]),
reducer: Reducer { $0.apply($1) },
extrinsics: { $0.extrinsics() }) |
Beta Was this translation helpful? Give feedback.
-
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? |
Beta Was this translation helpful? Give feedback.
-
has there been any conclusions on the best practice for handling subscriptions like these? |
Beta Was this translation helpful? Give feedback.
-
I'm wondering how should we approach representing long-running observers as effects?
For example,
NotificationCenter.default.publisher
never completes which, I believe, makes it hard to represent as anEffect
of composable architecture.As a workaround, I'm currently creating and observing
NotificationCenter
's publisher in theSceneDelegate
, and then sending an appropriate action into the store directly, since I have access to it from there.That works, but it doesn't feel right and makes testing very hard. Any input would be greatly appreciated.
Beta Was this translation helpful? Give feedback.
All reactions