GO FURTHER, FASTER: Try the Swift Career Accelerator today! >>

Observing progress of asynchronous work

Forums > Swift

Hi,

Wonder if anyone has any insights. I've a sync process in my app, consisting of a half doezen steps, all of which need to call our to a network API and do some work.

They're running on background tasks, to not lock up the UI (some can take a while to finish).

I'd like to report the progress back to the UI -- things like; how many files actually uploaded, how many failed etc.. I'm really struggling to see an easy way to do this without having data-race warning.

Actor seems the logical choice.. but then the state is immutable (as far as I understand).. which is the opposite of what I need. An @Observable class sounds good too, but then it's @MainActor isolated and I can't fire updates from the running tasks.

Can anyone suggest an approach here?

Back in my C# days, I used IProgress<T> for this kinda thing.. I noticed their is an older Progress/NSProgress API.. but that seems more like a remnant than an option.

   

Hello,

It wasn't an easy question... interesting! 🤔

I'm trying to learn more about the latest advances in SwiftUI and Swift concurrency. I have tried several solutions and understand that it is really clever... not easy.

Does a simple example work in your case? Or do you have a more sophisticated sync solution? I'm attaching my simple solution, so the forum has an alternative to discuss:

import SwiftUI

// In this case we need @MainActor
//If you’re only using @State for UI updates, and the ViewModel doesn’t directly manipulate the UI state on a background thread, you may not need @MainActor. However, if you’re planning to use background work in the ViewModel (such as network calls or background processing), marking the ViewModel or its relevant methods with @MainActor ensures thread-safety when updating UI-related state.
@Observable
@MainActor
class SyncProgress {
    var filesUploaded: Int = 0
    var filesFailed: Int = 0

    func startSync() async {
        for _ in 1...10 {
            // Simulate network call
            /// Sleep function doesn't block the underlying thread.
            try? await Task.sleep(nanoseconds: 500_000_000)
            // Now we are back to the MainActor (and MainTread)
            filesUploaded += 1
        }
    }
}

struct ContentView: View {
    @State var progress = SyncProgress()

    var body: some View {
        VStack {
            Text("Files Uploaded: \(progress.filesUploaded)")
            Text("Files Failed: \(progress.filesFailed)")
        }
        .task {
            await progress.startSync()
        }
    }

}

#Preview {
    ContentView()
}

If your synchronization process is straightforward and doesn’t involve complex state updates, error handling, or the need for cancellation.

(Options: Actors, Combine, or AsyncStream to handle more complex synchronization scenarios and avoid concurrency issues.)

I'm sharing what I've been working on today, and am curious to follow your question. /Martin

   

Hello,

Now I have read more about nonisolated and it is easy to use. I have made an example with class and @MainActor, as well as the nonisolated on the network function. Now we have a solution that runs the network call on a background thread and the rest on the main thread. (Without the extra code and functionality that actor implies).

Tip: Set a breakpoint in Xcode on simulateNetworkCall and one in ContentView to see how it switches between threads.

import SwiftUI

@MainActor
class SyncProgress: ObservableObject {
    // Published properties to update the UI
    @Published var filesUploaded: Int = 0
    @Published var filesFailed: Int = 0
    @Published var syncIsRunning: Bool = false

    // Resets the progress counters
    func resetProgress() {
        filesUploaded = 0
        filesFailed = 0
        syncIsRunning = false
    }

    // Starts the synchronization process
    func startSync() async throws {
        syncIsRunning = true
        defer {
            // Ensure syncIsRunning is set to false when the sync finishes
            syncIsRunning = false
        }
        var totalErrors = 0
        for i in 1...10 {
            do {
                // Simulate a network call on a background thread
                try await simulateNetworkCall(for: i)
                // Update filesUploaded on the main thread
                filesUploaded += 1
            } catch {
                // Update filesFailed on the main thread
                filesFailed += 1
                totalErrors += 1
            }
        }
        // Throw an error if any files failed to transfer
        if totalErrors > 0 {
            throw SyncError.filesFailed(totalErrors)
        }
    }

    // This method runs on a background thread
    nonisolated func simulateNetworkCall(for index: Int) async throws {
        // Simulate failure at certain iterations
        if index % 3 == 0 {
            throw URLError(.cannotConnectToHost)
        }
        // Simulate a network call (heavy operation)
        try await Task.sleep(for: .seconds(0.5))
    }
}

enum SyncError: Error, LocalizedError {
    case filesFailed(Int)

    var errorDescription: String? {
        switch self {
        case .filesFailed(let count):
            return "\(count) files failed to transfer."
        }
    }
}

struct ContentView: View {
    @StateObject var progress = SyncProgress()
    @State private var errorMessage: String? = nil

    var body: some View {
        NavigationStack {
            Form {
                Text("Files Transferred: \(progress.filesUploaded)")
                Text("Files Failed: \(progress.filesFailed)")
                if let errorMessage = errorMessage {
                    Text("Error: \(errorMessage)")
                        .foregroundStyle(.red)
                }
            }
            .toolbar {
                Button("Start Sync") { startSync() }
                    .disabled(progress.syncIsRunning)
            }
        }
    }

    func startSync() {
        Task {
            do {
                errorMessage = nil
                progress.resetProgress()
                try await progress.startSync()
            } catch {
                errorMessage = error.localizedDescription
            }
        }
    }
}

#Preview {
    ContentView()
}

   

Hello,

I have read Paul's book on "Swift Concurrency by Example" and gained quite a new insight. (It will be interesting to read the new version that will be released shortly.)

I have edited my previous post about actors as it did not follow Paul's advice:

Do not use actors for your SwiftUI data models. You should use a class that conforms to the ObservableObject protocol instead. If needed, you can optionally also mark that class with @MainActor to ensure it does any UI work safely, but keep in mind that using @StateObject or @ObservedObject automatically makes a view's code run on the main actor. If you desperately need to be able to carve off some async work safely, you can create a sibling actor – a separate actor that does not use @MainActor, but does not directly update the UI.

Read more: Important: Do not use an actor for your SwiftUI data models

Sharing my new actor example:

import SwiftUI

// Define the progress update struct
struct ProgressUpdate {
    let filesUploaded: Int
    let filesFailed: Int
}

// Define the actor
actor SyncProgressActor {
    func startStream() -> AsyncThrowingStream<ProgressUpdate, Error> {
        AsyncThrowingStream { continuation in
            Task {
                var filesUploaded = 0
                var filesFailed = 0
                for i in 1...10 {
                    do {
                        // Simulate failure at certain iterations
                        if i % 3 == 0 {
                            throw URLError(.cannotConnectToHost)
                        }
                        // Simulate network calls
                        try await Task.sleep(for: .seconds(0.5))
                        filesUploaded += 1
                        continuation.yield(ProgressUpdate(filesUploaded: filesUploaded, filesFailed: filesFailed))
                    } catch {
                        filesFailed += 1
                        continuation.yield(ProgressUpdate(filesUploaded: filesUploaded, filesFailed: filesFailed))
                    }
                }
                if filesFailed > 0 {
                    continuation.finish(throwing: SyncError.filesFailed(filesFailed))
                } else {
                    continuation.finish()
                }
            }
        }
    }
}

// Error enum
enum SyncError: Error, LocalizedError {
    case filesFailed(Int)

    var errorDescription: String? {
        switch self {
        case .filesFailed(let count):
            return "\(count) files failed to transfer."
        }
    }
}

// ViewModel
@MainActor
class ViewModel: ObservableObject {
    @Published var filesUploaded: Int = 0
    @Published var filesFailed: Int = 0
    @Published var syncIsRunning: Bool = false

    private let syncProgressActor = SyncProgressActor()

    func resetProgress() {
        filesUploaded = 0
        filesFailed = 0
        syncIsRunning = false
    }

    func startSync() async throws {
        syncIsRunning = true
        defer {
            syncIsRunning = false
        }

        let progressStream = await syncProgressActor.startStream()

        for try await progressUpdate in progressStream {
            self.filesUploaded = progressUpdate.filesUploaded
            self.filesFailed = progressUpdate.filesFailed
        }
    }
}

struct ContentView: View {
    @StateObject var progress = ViewModel()
    @State private var errorMessage: String? = nil

    var body: some View {
        NavigationStack {
            Form {
                Text("Files Transferred: \(progress.filesUploaded)")
                Text("Files Failed: \(progress.filesFailed)")
                if let errorMessage = errorMessage {
                    Text("Error: \(errorMessage)")
                        .foregroundStyle(.red)
                }
            }
            .toolbar {
                Button("Start Sync") { startSync() }
                    .disabled(progress.syncIsRunning)
            }
        }
    }

    func startSync() {
        Task {
            do {
                errorMessage = nil
                progress.resetProgress()
                try await progress.startSync()
            } catch {
                errorMessage = error.localizedDescription
            }
        }
    }
}

#Preview {
    ContentView()
}

   

Hi,

Have you progressed in your question How to: "Observing progress of asynchronous work"?

I didn't give many explanations to my previous posts, sorry!

Paul writes in What's new in Swift 6.0: "Swift concurrency remains a bit of a moving target, but if you'd like to know more I highly recommend Matt Massicotte's blog – I don't think anyone is doing more to educate Swift developers about effective adoption of Swift concurrency."

Can Matt's article "Concurrency Step-by-Step: A Network Request" help you further in your question?

   

Hacking with Swift is sponsored by try! Swift Tokyo.

SPONSORED Ready to dive into the world of Swift? try! Swift Tokyo is the premier iOS developer conference will be happened in April 9th-11th, where you can learn from industry experts, connect with fellow developers, and explore the latest in Swift and iOS development. Don’t miss out on this opportunity to level up your skills and be part of the Swift community!

Get your ticket here

Sponsor Hacking with Swift and reach the world's largest Swift community!

Reply to this topic…

You need to create an account or log in to reply.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.