swiftuistructured-concurrency

Inlining awaits from different threads in SwiftUI


Here is a code snippet from my SwiftUI ViewModel:

@Published var data: Data
@Published var showingAlert = false
    
func fetchData() {
    Task {
        let result = await NetworkManager.fetchData(),
        await updateUI(with: result)
    }
}
    
@MainActor
private func updateUI(with result: Result<Data, Error>) {
    do {
        data = try result.get()
    } catch {
        showingAlert = true
    }
}

I'm not sure if this is the idiomatic approach (I'm relatively new to SwiftUI) but it works great.

My question is:
Can we get rid of one "await" by merging the lines into a single one?

await updateUI(with: NetworkManager.fetchData())

Is this code equivalent to the previous one?
Most importantly, I need the network call to run on a background thread while the UI updates are performed on the main thread. Is this still the case here?

How can it be proven? (you know, in GPT era everything should be fact checked 😂)


Solution

  • Yes, these are equivalent. From the Swift Evolution proposal:

    Consider the following example:

    // func redirectURL(for url: URL) async -> URL { ... }
    // func dataTask(with: URL) async throws -> (Data, URLResponse) { ... }
    
    let newURL = await server.redirectURL(for: url)
    let (data, response) = try await session.dataTask(with: newURL)
    

    In this code example, a task suspension may happen during the calls to redirectURL(for:) and dataTask(with:) because they are async functions. Thus, both call expressions must be contained within some await expression, because each call contains a potential suspension point. An await operand may contain more than one potential suspension point. For example, we can use one await to cover both potential suspension points from our example by rewriting it as:

    let (data, response) = try await session.dataTask(with: server.redirectURL(for: url))
    

    The await has no additional semantics; like try, it merely marks that an asynchronous call is being made.

    As long as NetworkManager.fetchData() is not isolated to the main actor, it will run on a non-main thread, by the cooperative thread pool. Even if it is isolated to the main actor, you are probably using URLSession APIs in its implementation, which are not isolated.

    You can call MainActor.shared.assertIsolated() in NetworkManager.fetchData() to "prove" this. If this method crashes, then you are not on the main actor.


    It seems like you are using an ObservableObject for this. There is no need for an ObservableObject just for this async operation. You can move everything into your View.

    @State var data: Data?
    @State var showingAlert = false
        
    // @MainActor // in iOS 18, the 'View' protocol is entirely isolated to the MainActor, so this will be redundant
    private func updateUI(with result: Result<Data, Error>) {
        do {
            data = try result.get()
        } catch {
            showingAlert = true
        }
    }
    
    func fetchData() async {
        let result = await NetworkManager.fetchData(),
        // similarly for the 'await' here. You don't need it in iOS 18
        /*await*/ updateUI(with: result)
    }
    

    You can then call this in a .task { ... } or task(id: something) { ... } modifier, which cancels the task if the view disappears. Right now your ObservableObject does not handle cancellation of the top level Task you created.