I have the following scenario - I am using ReactiveSwift's Action
to trigger a network request in my app. This network request is potentially expensive due to the processing that is done on it's response. So, when a caller tries to apply the Action I would like to do the following:
Ideally the solution would be thread safe, as callers may try to apply the Action
from different threads.
Now I've managed to cobble together something that works using examples of caching in ReactiveSwift, but I'm almost certain I'm doing something wrong particularly in how I'm having to reset my MutableProperty
to nil
when the Action
completes. Note that I'm also using static variables to ensure my multiple instances of the UseCase
can't bypass my intended behaviour. Also, my example signals Never
output but in the real world they may:
class UseCase {
private static let sharedAction = Action<Void, Never, AnyError> {
return SignalProducer.empty.delay(10, on: QueueScheduler.main).on(completed: {
print("Done")
UseCase.sharedProducer.value = nil
})
}
private static let sharedProducer = MutableProperty<SignalProducer<Never, AnyError>?>(nil)
func sync() -> SignalProducer<Never, AnyError> {
let result = UseCase.sharedProducer.modify { value -> Result<SignalProducer<Never, AnyError>, NoError> in
if let inProgress = value {
print("Using in progress")
return Result(value: inProgress)
} else {
print("Starting new")
let producer = UseCase.sharedAction.apply().flatMapError { error -> SignalProducer<Never, AnyError> in
switch error {
case .disabled: return SignalProducer.empty
case .producerFailed(let error): return SignalProducer(error: error)
}
}.replayLazily(upTo: 1)
value = producer
return Result(value: producer)
}
}
guard let producer = result.value else {
fatalError("Unexpectedly found nil producer")
}
return producer
}
}
This also might a bit lengthy, but it should at least be a bit easier to follow. Feel free to ask any questions.
NOTE: I made this object start processing on its own, rather than return a SignalProducer
that the caller would start. Instead, I added a read-only property that listeners can observe without starting the processing.
I try to make my observers as passive as possible, thus making them more "reactive" than "proactive". This pattern should suit your needs, even though it's a bit different.
I tried to make this example include:
It's far from perfect, but should provide a solid pattern you can modify and expand.
struct MyStruct {}
final class MyClass {
// MARK: Shared Singleton
static let shared = MyClass()
// MARK: Initialization
private init() {}
// MARK: Public Stuff
@discardableResult
func getValue() -> Signal<MyStruct, NoError> {
if !self.isGettingValue {
print("Get value")
self.start()
} else {
print("Already getting value.")
}
return self.latestValue
.signal
.skipNil()
}
var latestValue: Property<MyStruct?> {
// By using a read-only property, the listener can:
// 1. Choose to take/ignore the previous value.
// 2. Choose to listen via Signal, SignalProducer, or binding operator '<~'
return Property(self.latestValueProperty)
}
// MARK: Private Stuff
private var latestValueProperty = MutableProperty<MyStruct?>(nil)
private var isGettingValue = false {
didSet { print("isGettingValue: changed from '\(oldValue)' to '\(self.isGettingValue)'") }
}
private func start() {
// Binding with `<~` automatically starts the SignalProducer with the binding target (our property) as its single listener.
self.latestValueProperty <~ self.newValueProducer()
// For testing, delay signal to mock processing time.
// TODO: Remove in actual implementation.
.delay(5, on: QueueScheduler.main)
// If `self` were not a Singleton, this would be very important.
// Best practice says that you should hold on to signals and producers only as long as you need them.
.take(duringLifetimeOf: self)
// In accordance with best practices, take only as many as you need.
.take(first: 1)
// Track status.
.on(
starting: { [unowned self] in
self.isGettingValue = true
},
event: { [unowned self] event in
switch event {
case .completed, .interrupted:
self.isGettingValue = false
default:
break
}
}
)
}
private func newValueProducer() -> SignalProducer<MyStruct?, NoError> {
return SignalProducer<MyStruct?, AnyError> { observer, lifetime in
// Get Struct with possible error
let val = MyStruct()
// Send and complete the signal.
observer.send(value: val)
observer.sendCompleted()
}
// Don't hold on to errors longer than you need to.
// I like to handle them as close to the source as I can.
.flatMapError { [unowned self] error in
// Deal with error
self.handle(error: error)
// Transform error type from `AnyError` to `NoError`, to signify that the error has been handled.
// `.empty` returns a producer that sends no values and completes immediately.
// If you wanted to, you could return a producer that sends a default or alternative value.
return SignalProducer<MyStruct?, NoError>.empty
}
}
private func handle(error: AnyError) {
}
}
TEST
// Test 1: Start processing and observe the results.
MyClass.shared
.getValue()
.take(first: 1)
.observeValues { _ in
print("Test 1 value received.")
}
// Test 2: Attempt to start (attempt ignored) and observe the same result from Test 1.
MyClass.shared
.getValue()
.take(first: 1)
.observeValues { _ in
print("Test 2 value received.")
}
// Test 3: Observe Value from Test 1 without attempting to restart.
MyClass.shared
.latestValue
.signal
.skipNil()
.take(first: 1)
.observeValues { _ in
print("Test 3 value received.")
}
// Test 4: Attempt to restart processing and discard signal
MyClass.shared.getValue()
Output:
Get value
isGettingValue: changed from 'false' to 'true'
Already getting value.
Already getting value.
(5 seconds later)
Test 1 value received.
Test 2 value received.
Test 3 value received.
isGettingValue: changed from 'true' to 'false'