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?
My understanding is:
If apiCall4
publishes its first output before the timeout, you only want the genuine outputs from apiCall4
.
If apiCall4
doesn't publish its first output before the timeout, you want to publish a default value, but you want to allow apiCall4
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 a delay
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 of apiCall4
and the delayed Just
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 in scan
, nor can we publish just the output without the extra state. So we'll use compactMap
after scan
to strip off scan
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")