Home > database >  What is the appropriate strategy for using @MainActor to update UI?
What is the appropriate strategy for using @MainActor to update UI?

Time:05-30

Suppose you have a method that executes asynchronously in a global context. Depending on the execution you need to update the UI.

private func fetchUser() async {
    do {
        let user = try await authService.fetchCurrentUser()
        view.setUser(user)
    } catch {
        if let error = error {
            view.showError(message: error.message)
        }
    }
}

Where is the correct place to switch to the main thread?

  1. Assign @MainActor to the fetchUser() method:
@MainActor
private func fetchUser() async { 
    ...
}
  1. Assign @MainActor to the setUser(_ user: User) and showError(message: String) view's methods:
class SomePresenter {

    private func fetchUser() async {
        do {
            let user = try await authService.fetchCurrentUser()
            await view.setUser(user)
        } catch {
            if let error = error {
                await view.showError(message: error.message)
            }
        }
    }

}

class SomeViewController: UIViewController {

    @MainActor
    func setUser(_ user: User) {
        ...
    }

    @MainActor
    func showError(message: String) {
        ...
    }

}
  1. Do not assign @MainActor. Use await MainActor.run or Task with @MainActor instead to run setUser(_ user: User) and showError(message: String) on the main thread (like DispatchQueue.main.async):
private func fetchUser() async {
    do {
        let user = try await authService.fetchCurrentUser()
        
        await MainActor.run {
            view.setUser(user)
        }
    } catch {
        if let error = error {
            await MainActor.run {
                view.showError(message: error.message)
            }
        }
    }
}

CodePudding user response:

Option 2 is logical, as you are letting functions that must run on the main queue, declare themselves as such. Then the compiler can warn you if you incorrectly call them. Even simpler, you can declare the class that has these functions to be @MainActor, itself, and then you don't have to declare the individual functions as such. E.g., because a well-designed view or view controller limits itself to just view-related code, it is safe for that whole class to be declared as @MainActor and be done with it.

Option 3 (in lieu of option 2) is brittle, requiring the app developer to have to remember to manually run them on the main actor. You lose compile-time warnings should you fail to do the right thing. Compile-time warnings are always good. But WWDC 2021 video Swift concurrency: Update a sample app points out that even if you adopt option 2, you might still use MainActor.run if you need to call a series of MainActor methods and you might not want to incur the overhead of awaiting one call after another, but rather wrap the group of main actor functions in a single MainActor.run block. (But you might still consider doing this in conjunction with option 2, not in lieu of it.)

In the abstract, option 1 is arguably a bit heavy-handed, designating a function that does not necessarily have to run on the main actor to do so. You should only use the main actor where it is explicitly needed/desired. That having been said, in practice, I have found that there is often utility in having presenters (or controllers or view models or whatever pattern you adopt) run on the main actor, too. This is especially true if you have, for example, synchronous UITableViewDataSource or UICollectionViewDataSource methods grabbing model data from the presenter. If you have the relevant presenter using a different actor, you cannot always return to the data source synchronously. So you might have your presenter methods running on the main actor, too. Again, this is best considered in conjunction with option 2, not in lieu of it.

So, in short, option 2 is prudent, but is often married with options 1 and 3 as appropriate. Routines that must run on the main actor should be designated as such, rather than placing that burden on the caller.


The aforementioned Swift concurrency: Update a sample app covers many of these practical considerations and is worth watching if you have not already.

  • Related