0

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 to loadTransactions 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?

Marcin Kapusta
  • 5,076
  • 3
  • 38
  • 55
  • 1
    every `ObservableObject` has to be wrapped with something. `Published` is not capable of knowing when `TransactionViewModel` changes. SwiftUI is very strict, you have to ensure that you understand `DynamicProperty` so you know how to trigger UI updates. Demystify SwiftUI can give you a good start. – lorem ipsum Aug 09 '23 at 13:40
  • 1
    Make sure `state` is updated on main thread (or make the entire `TransactionContainerViewModel` a `@MainActor` - even better) – timbre timbre Aug 09 '23 at 14:10

0 Answers0