How to asynchronously update UI-related @Published variables in ViewModel?

86 Views Asked by At

On the one hand var coins should be updated on MainThread (as a UI related variable). On the other hand cryptoService.fetchCoins() needs to be done on a background thread. I'm having hard times to connect these two worlds.
In the code below I did my best, but still have some compile warnings which I don't understand. Two warnings on two lines of the "do" block:

Capture of 'self' with non-sendable type 'MainPageViewModel' in a @Sendable closure

Passing argument of non-sendable type 'MainPageViewModel' into main actor-isolated context may introduce data races

class MainPageViewModel: ObservableObject {
    @Published var coins: [Coin] = []
    private let cryptoService = CryptoService()
    
    init() {
        fetchCoins()
    }
    
    private func fetchCoins() {
        Task {
            do {
                let coins = try await cryptoService.fetchCoins()
                await updateUI(with: coins)
            } catch {
                print("Error fetching coins: \(error)")
            }
        }
    }
    
    @MainActor
    private func updateUI(with coins: [Coin]) {
        self.coins = coins
    }
}

What I'm trying to do is some very basic things. There need to be a way to do it correctly. And it shouldn't be too sophisticated way.

P.S.
Please, don't recommend Combine. There also must be a way to do it without Combine.

Update.
Some UI part of the codebase:

@StateObject var viewModel = MainPageViewModel()
...
VStack(spacing: 12) {
    ForEach(viewModel.coins) { coin in
        CryptoCoinView(coin: coin)
    }
}
...

Update.
I've removed the mention of runtime warnings from the post, because it appeared they were related to completely another part of my application Sorry for that.
As for the compilation warnings...
Looks like in the default settings of a Xcode project there are no any of those warnings. The reason why I'm seeing them, is because in "Build Settings" of my project I choose "Complete" to the "Strict Concurrency Checking" setting (default was "Minimal").
So the main question of the post is still pretty much relevant. Why do we see those warnings in Complete Concurrency Checking mode? Does this mean that in the future versions of Swift we'll see the warnings in this situation even in default mode? Or will they eventually even become compilation errors?

2

There are 2 best solutions below

1
malhal On

When you upgrade to async/await you can simply remove the Combine ObservableObject/@Published and just use .task, e.g.

struct MainPage: View {
    @Environment(\.cryptoService) var cryptoService
    @State var coins: [Coin] = []

    var body: some View {
        ForEach(coins) { coin in
            CryptoCoinView(coin: coin)
        }
        .overlay {
            if coins.isEmpty {
                ContentUnavailableView {
                    Label("No Coins", systemImage: "tray.fill")
                } description: {
                    Text("Coins will appear here.")
                }
            }
        }
        .task {
            if coins.isEmpty { return }
            do {
                coins = try await cryptoService.fetchCoins()
            } catch {
                print("Error fetching coins: \(error)")
            }
        }
    }
}

As long as your struct CryptoService is not marked @MainActor then fetchCoins will run on a background thread.

0
Alex On

Tried your code. I only see one warning.

- Capture of 'self' with non-sendable type 'MainPageViewModel' in a @Sendable closure

This warning means there are some risks to get data race. When fixing the warning, the code is absolute thread safe.

This is my understanding, we can add a function to pass self to a closure. By using this closure, many threads can add coins concurrently. This is the risk. The complier cannot tell the difference between the existing code and this one. It shows warning for all the closures capturing self. This warning will become an error in a few year.

For this problem the fix is to mark the MainPageViewModel with @MainActor. It is a view model, @Published variables are on the main thread already. All the functions and variables will be isolated to the main actor. Now it is @Sendable. Further improvement could be changing CryptoService to actor so it is @Sendable as well.