Search code examples
iosswiftbackground-thread

Running API on background thread in SwiftUI


So, for this scenario, the code follows a model, view, view-model architecture, where @Observable class DiscoverAPIViewModel is a View-Model that searches for some data by making an API call.

@Observable class DiscoverAPIViewModel {

private (set) var contentRecsVideosByKeyword: [VideoData] = [] // note: A

func contentRecsVideosByKeywordApi(accessToken: String, username: String, keyword: String, count: Int = 5)
{
    // ....
    // fetch data via an api call
    // on success
    DispatchQueue.main.async { // note: B
        strongSelf.contentRecsVideosByKeyword = [] // try force-refresh (by design ids are unique, so re-assigning may not always reset the list)
        strongSelf.contentRecsVideosByKeyword = result.data!
    }

Please note the following above:

A. contentRecsVideosByKeyword is stateful because the class uses @Observable macro

B. When data is retrieved successfully, DispatchQueue.main.async is used to set contentRecsVideosByKeyword as it is used in the view part (seen later)

Model is just a simple struct with id, name, etc. primitive values. The View has warning

Main actor-isolated property 'discoverApiViewModel' can not be referenced from a Sendable closure; this is an error in Swift 6

because of the following code

struct ApiView: View {

    @State private var discoverApiViewModel = DiscoverAPIViewModel()
    private let keyword: String
    private let username: String
    private let accessToken: String
    private let videoCountPerPage = 7
    // ...
    // other properties like durationMilliseconds, and methods

    init(username: String, accessToken: String, keyword: String) {
        // ... init values for one time use
    }

    var body: some View {
        List(discoverApiViewModel.contentRecsVideosByKeyword) { item in
            Text(item.name) // displaying data in the view
        }
        .task {
            do {
                try? await Task.sleep(for: durationMilliseconds)
                DispatchQueue.global(qos: .background).async {
                    discoverApiViewModel // Main actor-isolated property 'discoverApiViewModel'...
                        .contentRecsVideosByKeywordApi(accessToken: accessToken, username: username, keyword: keyword, count: videoCountPerPage)
                }
            }
        }
    }
}

Please, can someone suggest how to properly call an API in the background and then update the view (that uses some stateful data), using SwiftUI for View and Observable for View-Model? Currently, most of the code is based on MVVM architecture, and modifying/refactoring it causes related main actor exception at runtime (in some cases, for e.g. when app becomes active from a background state, but it seems highly related).

loadingView
    .onAppear {
        apiTask = createApiTask(viewModel: discoverApiViewModel)
    }

where createApiTask is

func createApiTask(viewModel: DiscoverAPIViewModel) -> Task<Void,Error> {
    Task(priority: .background) {
        try await Task.sleep(for: durationMilliseconds)
        DispatchQueue.global(qos: .background).async {
            viewModel.contentRecsVideosByKeywordApi(accessToken: accessToken, username: username, keyword: keyword, count: videoCountPerPage)
        }
    }
}

Solution

  • The new Concurrency (async/await) replaces the "old" DispatchQueue approaches and they shouldn't be mixed. The "Meet async/await" video from WWDC is quite informative.

    Ideally, you will have a "Service" or "Manager" that handles the background work in a custom globalActor or an actor.

    The View and ViewModel are meant to stay in the MainActor because they deal with the UI.

    The flow should be something like

    • MainActor triggers a request >
    • Request is performed somewhere else >
    • Request returns to the MainActor >
    • UI is updated on the MainActor.

    Your code mixing the request and the UI.

    Here is one approach...

    import SwiftUI
    // Isolated to Main Actor by default in iOS 18+ 
    struct IsolatedSampleView: View {
        @State private var model: IsolatedSampleViewModel = .init()
        @State private var isProcessing: Bool = false
        var body: some View {
            VStack {
                if isProcessing {
                    ProgressView()
                } else {
                    Text(model.contentRecsVideosByKeyword.description)
                }
            }
                .task { //trigger
                    isProcessing = true
                    await model.contentRecsVideosByKeywordApi(accessToken: "", username: "", keyword: "", count: 7)
                    isProcessing = false
                }
        }
    }
    
    #Preview {
        IsolatedSampleView()
    }
    @MainActor //Update UI on MainActor
    @Observable
    class IsolatedSampleViewModel {
        var contentRecsVideosByKeyword: [String] = []
        func contentRecsVideosByKeywordApi(accessToken: String, username: String, keyword: String, count: Int) async {
            //MainActor update           //Detach from MainActor
            contentRecsVideosByKeyword = await Task.detached(operation: {
                // MainActor.assertIsolated("Not isolated!!") // Will cause a crash because we are no longer isolated to Main
    
                try? await Task.sleep(for: .seconds(3))
                //Return to the MainActor
                return [accessToken, username, keyword, count.description]
            }).value
        }
    }
    

    https://developer.apple.com/documentation/swiftui/state

    Note as I said above Task.detached can easily be replaced by an actor or globalActor isolated context or another custom Sendable type.

    There are many ways to leave the MainActor but the approach should be the same as I listed above, do the work somewhere else and return the data to the main.

    Some people would suggest something like this

    @MainActor
    @Observable
    class IsolatedSampleViewModel {
        var contentRecsVideosByKeyword: [String] = []
        func contentRecsVideosByKeywordApi(accessToken: String, username: String, keyword: String, count: Int) async {
            //Detach from MainActor
            await Task.detached(operation: { [weak self] in
                // MainActor.assertIsolated("Not isolated!!") // Will cause a crash because we are no londer isolated to Main
    
                try? await Task.sleep(for: .seconds(3))
                guard let self else {return}
                //Update on the MainActor
                await MainActor.run {
                    self.contentRecsVideosByKeyword =  [accessToken, username, keyword, count.description]
                }
            }).value
        }
    }
    

    Which mimics the DispatchQueue approach but I find it unsightly and anti-pattern.