So I am making multiple API calls that I need to refresh my UI. I am using CombineLatest to wait until I have data from all of them before updating the UI:
let apiCall1 = repository1.fetchData().eraseToAnyPublisher()
let apiCall2 = repository2.fetchData().eraseToAnyPublisher()
let apiCall3 = repository3.fetchData().eraseToAnyPublisher()
let apiCall4 = repository4.fetchData().eraseToAnyPublisher()
Publishers.CombineLatest4(apiCall1, apiCall2, apiCall3, apiCall4)
.sink(receiveCompletion: { [weak self] result in
switch result {
case .finished: return
case .failure(_): handleError()
}
}, receiveValue: { [weak self] result1, result2, result3, result4 in
self.saveResults(result1, result2, result3, result4)
self.updateUI()
})
.store(in: &cancellables)
Three are vital, but apiCall4
should be optional. If it takes too long, I would like to avoid waiting for it. In other words, I don't want to block the UI until it comes in.
So I am trying to implement a stop gap timeout. Maybe return an empty array so that I can update the UI until I actually get the data.
I have played with adding a timeout to the publisher, something like this:
let apiCall4 = repository4.fetchData().eraseToAnyPublisher()
.timeout(5, scheduler: DispatchQueue.main, customError: {
return ResponseError.timeout
})
.replaceError(with: [])
But the problem, of course, is that this terminates the apiCall4
publisher. I won't get the data if the API call completes after the timeout.
Is there any way to send a placeholder value after x amount of time without actually terminating the apiCall4
publisher?
CodePudding user response:
My understanding is:
If
apiCall4
publishes its first output before the timeout, you only want the genuine outputs fromapiCall4
.If
apiCall4
doesn't publish its first output before the timeout, you want to publish a default value, but you want to allowapiCall4
to continue running, and use its output if it eventually publishes anything.
Here's one way to solve this.
We'll use a
Just
with adelay
to publish the default output after the timeout.We'll use
map
to tag the default output and the genuine outputs, so we can distinguish them downstream.We'll use
merge
to combine the tagged outputs ofapiCall4
and the delayedJust
into a single stream.We'll then use
scan
to keep some state about whether we've ever seen a genuine output, so if we get the default output after seeing a genuine output, we can discard the default output.Since
scan
publishes its entire state, and always publishes when its upstream publishes, we can't actually discard the default output inscan
, nor can we publish just the output without the extra state. So we'll usecompactMap
afterscan
to strip offscan
s extra state and actually discard if we get the default output after a genuine output.
Here's the type we'll use to tag each output with its genuine-ness:
fileprivate struct Wrapper<Output> {
var output: Output
var isGenuine: Bool
}
And here's the type we'll use to hold the extra scan
state:
fileprivate struct ScanState<Output> {
var output: Output?
var hasSeenGenuine: Bool = false
}
And here's how we assemble the pieces, as described above:
extension Publisher {
func defaulting<S: Scheduler>(
to defaultOutput: Output,
after timeout: S.SchedulerTimeType.Stride,
scheduler: S
) -> some Publisher<Output, Failure> {
let genuine = self
.map { Wrapper(output: $0, isGenuine: true) }
let defaulted = Just(Wrapper(
output: defaultOutput,
isGenuine: false
))
.setFailureType(to: Failure.self)
.delay(for: timeout, scheduler: scheduler)
return genuine.merge(with: defaulted)
.scan(ScanState<Output>()) { state, wrapper in
if state.hasSeenGenuine && !wrapper.isGenuine {
return ScanState(output: nil, hasSeenGenuine: true)
} else {
return ScanState(
output: wrapper.output,
hasSeenGenuine: wrapper.isGenuine || state.hasSeenGenuine
)
}
}
.compactMap { $0.output }
}
}
Here's a test function:
func runTest(call4Delay: DispatchQueue.SchedulerTimeType.Stride) -> AnyCancellable {
let apiCall1 = Just("answer1").delay(for: .milliseconds(300), scheduler: DispatchQueue.main)
let apiCall2 = Just("answer2").delay(for: .milliseconds(400), scheduler: DispatchQueue.main)
let apiCall3 = Just("answer3").delay(for: .milliseconds(500), scheduler: DispatchQueue.main)
let apiCall4 = Just("answer4").delay(for: call4Delay, scheduler: DispatchQueue.main)
let defaultedCall4 = apiCall4.defaulting(to: "default", after: .milliseconds(1000), scheduler: DispatchQueue.main)
let combo = apiCall1.combineLatest(apiCall2, apiCall3, defaultedCall4)
let ticket = combo.sink { print($0) }
return ticket
}
The timeout is one second. If I run a test with apiCall4
publishing after 900 milliseconds, I only see the genuine output:
("answer1", "answer2", "answer3", "answer4")
If I run a test with apiCall4
publishing after 1100 milliseconds, I see the default output followed by the genuine output:
("answer1", "answer2", "answer3", "default")
("answer1", "answer2", "answer3", "answer4")
CodePudding user response:
Three are vital, but apiCall4 should be optional. If it takes too long, I would like to avoid waiting for it. In other words, I don't want to block the UI until it comes in... Maybe return an empty array so that I can update the UI until I actually get the data.
The answer is simple, just prepend
an empty array to apiCall4. The UI will update when the other three values emit along with the empty array for apiCall4, then when apiCall4 comes in, the UI will update again with the additional, optional, information.
No need for a timeout at all.
Is there any way to send a placeholder value after x amount of time without actually terminating the apiCall4 publisher?
The best part about using prepend is that the UI will update as soon as the other three calls emit values. If apiCall4 has already emitted by then, the default won't show, if not, then it will.