I think I'm doing something wrong here. Problem is that when this example app is running and when I'm trying to Reload
it can stuck randomly on loading screen and thats it. View didn't respond to any update of state again. It just displays ProgressView and that's it. I don't know even how to diagnose the problem because when I run debugger this behavior can not be replicated. Looks like some timing issues maybe related to RunLoop ticks and how/when SwiftUI update it's view.
If somebody can guide me where is the problem in my code...
Here is a ViewModel:
import Foundation
import Combine
public final class TransactionContainerViewModel: ObservableObject {
public enum ViewState<T> {
case loading
case loaded(T)
case error(message: String)
}
@Published public var state: ViewState<[TransactionViewModel]> = .loading
private var disposable: AnyCancellable?
public typealias TransactionsPublisher = AnyPublisher<[TransactionItem], Error>
private let loader: TransactionsPublisher
public init(loader: TransactionsPublisher) {
self.loader = loader
}
public func loadTransactions() {
disposable?.cancel()
state = .loading // 1
disposable = loader.sink { [weak self] result in
switch result {
case .failure:
self?.state = .error(message: "Some error") // 2
case .finished: break
}
} receiveValue: { [weak self] items in
let sortedTransactions = items.sorted { $0.bookingDate > $1.bookingDate }
let transactionsVM = sortedTransactions.map { item in
TransactionViewModel(transaction: item)
}
self?.state = .loaded(transactionsVM) // 3
}
}
}
There are three places where state
property can update/mutate.
Loader that is injected in init is built like that:
let upstreamQueue = DispatchQueue.global()
let client = createMockedHTTPClient()
let mockedLoader: AnyPublisher<[TransactionItem], Error> = client.getPublisher(url: url)
.subscribe(on: upstreamQueue)
.delay(for: .seconds(1), tolerance: .milliseconds(500), scheduler: upstreamQueue)
.tryMap(TransactionsMapper.map)
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
The View that from time to time is not updating according to vm.state
Published property.
import SwiftUI
struct TransactionContainerView: View {
@ObservedObject private var vm: TransactionContainerViewModel
init(viewModel: TransactionContainerViewModel) {
self.vm = viewModel
}
var body: some View {
VStack {
switch vm.state {
case .loading:
ProgressView()
case let .loaded(transactions):
Text("Loaded: \(transactions.count) transactions")
Button("Reload") {
vm.loadTransactions()
}
case let .error(message):
Text(message)
}
}.onAppear {
vm.loadTransactions()
}
}
}
Can somebody explain what can cause this to stuck at ProgressView
? This is totally random behavior. Sometimes very time consuming to replicate and sometimes very fast. I suspect some threading issues or missing some SwiftUI ticks but I'm just learning this stuff and any help could be great.
- switching to
.loading
state always works and view updates as expected - switching to
.error
or.loaded
state works most of the time but it can not work from time to time. Switching to those states are async because call toloadTransactions
was ended before.
UPDATE:
I found the reason what caused the problem. Loader was created with let upstreamQueue = DispatchQueue.global()
and global()
returns concurrent queue.
Now I know WHAT but WHY is another story. Somehow concurrent queue was problematic. I updated code that is handling subscription to loader like this just to find reason. I was thinking maybe something is calling cancel on my disposable or it is deallocated. Updated code to understand better what was going on...
public func loadTransactions() {
print("loadTransactions")
dispatchPrecondition(condition: .onQueue(.main))
disposable?.cancel()
state = .loading
var stateDidSet = false
disposable = loader
.handleEvents(receiveSubscription: { s in
print(" receiveSubscription")
}, receiveOutput: { output in
print(" receiveOutput")
}, receiveCompletion: { completion in
print(" receiveCompletion: \(completion)")
}, receiveCancel: {
print(" receiveCancel")
}, receiveRequest: { demand in
print(" receiveRequest: \(demand)")
})
.sink { [weak self] result in
switch result {
case .failure:
self?.state = .error(message: Self.errorMessage)
stateDidSet = true
print(" completed with error")
case .finished:
if !stateDidSet {
print("WARNING stateDidSet is false and we received completion")
self?.state = .error(message: "Unexpected error")
}
print(" completed")
}
} receiveValue: { [weak self] items in
let sortedTransactions = items.sorted { $0.bookingDate > $1.bookingDate }
let transactionsVM = sortedTransactions.map { item in
TransactionViewModel(transaction: item)
}
self?.state = .loaded(transactionsVM)
stateDidSet = true
print(" loaded")
}
}
I noticed that I'm not covering one case with setting state. It was completion without error state. But I was not expecting this can complete before outputing value but I give it a try. I created stateDidSet
variable so I can make if statement and put breakpoint there. Finally I catched this case. Completed is called but without emitting any output value. Why? I don't know but changing queue to serial fixed the problem. Does somebody know WHY?