Articles, podcasts and news about Swift development, by John Sundell.

Using the MainActor attribute to automatically dispatch UI updates on the main queue

Published on 13 Jun 2021
Discover page available: Concurrency

One challenge when it comes to concurrency on Apple’s platforms is that an app’s UI can, for the most part, only be updated on the main thread. So, whenever we’re performing any kind of work on a background thread (either directly or indirectly), then we always have to make sure to jump back to the main queue before accessing any property, method, or function that has to do with rendering our UI.

In theory, that might sound simple. In practice, it’s very common to accidentally perform UI updates on a background queue — which can cause glitches, or even put an app in an inconsistent or undefined state, which in turn might lead to crashes and other errors.

Manual main queue dispatching

So far, the most commonly used solution to this problem has been to wrap all UI-related updates in asynchronous calls to DispatchQueue.main, when there’s any chance that those updates will be triggered on a background queue — for example in situations like this:

class ProfileViewController: UIViewController {
    private let userID: User.ID
    private let loader: UserLoader
    private lazy var nameLabel = UILabel()
    private lazy var biographyLabel = UILabel()
    ...

    private func loadUser() {
        loader.loadUser { [weak self] result in
            DispatchQueue.main.async {
                switch result {
                case .success(let user):
                    self?.nameLabel.text = user.name
                    self?.biographyLabel.text = user.biography
                case .failure(let error):
                    self?.showError(error)
                }
            }
        }
    }
}

While the above pattern certainly works, always having to remember to perform those DispatchQueue.main calls is definitely not very convenient, and quite error-prone as well. That’s especially true in situations when it might not be completely obvious that a given piece of code could indeed be run on a background queue, such as when observing Combine publishers, or when implementing certain delegate methods.

The main actor

Thankfully, Swift 5.5 is introducing what will likely become a much more robust and almost entirely automatic solution to this very common problem — the main actor. While we’ll explore Swift’s implementation of the Actor pattern in much more detail in future articles, for this particular use case, all that we really need to know is that this new, built-in actor implementation ensures that all work that’s being run on it is always performed on the main queue.

So how exactly do we “run” our code on that main actor? The first thing that we have to do is to make our asynchronous code use the new async/await pattern, which is also being introduced as part of Swift 5.5’s suite of concurrency features. In this case, that could be done by creating a new, async/await-powered version of the loadUser method that our above view controller is calling — which simply involves wrapping a call to its default, completion handler-based version using Swift’s new continuation API:

extension UserLoader {
    func loadUser() async throws -> User {
        try await withCheckedThrowingContinuation { continuation in
            loadUser { result in
                switch result {
                case .success(let user):
                    continuation.resume(returning: user)
                case .failure(let error):
                    continuation.resume(throwing: error)
                }
            }
        }
    }
}

To learn more about the above pattern, check out my friend Vincent Pradeilles’ WWDC by Sundell & Friends article “Wrapping completion handlers into async APIs”.

With the above in place, we can now use an asynchronous Task, along with Swift’s standard do, try catch error handling mechanism, to call our loader’s new async/await-powered API from within our ProfileViewController:

class ProfileViewController: UIViewController {
    ...
    
    private func loadUser() {
        Task {
            do {
                let user = try await loader.loadUser()
                nameLabel.text = user.name
                biographyLabel.text = user.biography
            } catch {
                showError(error)
            }
        }
    }
}

But wait, how can the above work without any calls to DispatchQueue.main.async? If loadUser performs its work on a background queue, won’t that mean that our UI will now be incorrectly updated on a background queue as well?

That’s where the main actor comes in. If we take a look at the declarations for both UILabel and UIViewController, we can see that they’ve both been annotated with the new @MainActor attribute:

@MainActor class UILabel: UIView
@MainActor class UIViewController: UIResponder

What that means is that, when using Swift’s new concurrency system, all properties and methods on those classes (and any of their subclasses, including our ProfileViewController) will automatically be set, called, and accessed on the main queue. All those calls will automatically be routed through the system-provided MainActor, which always performs all of its work on the main thread — completely eliminating the need for us to manually call DispatchQueue.main.async. Really cool!

Custom UI-related classes

But what if we’re instead working on a completely custom type that we’d also like to gain the above kind of capability? For example, when implementing an ObservableObject that’s used within a SwiftUI view, we need to make sure to only assign its @Published-marked properties on the main queue, so wouldn’t it be great if we could also leverage the MainActor in those cases as well?

The good news is — we can! Just like how many of UIKit’s built-in classes are now annotated with @MainActor, we can apply that attribute to our own classes as well — giving them that same automatic main thread-dispatching behavior:

@MainActor class ListViewModel: ObservableObject {
    @Published private(set) var result: Result<[Item], Error>?
    private let loader: ItemLoader
    ...

    func load() {
        Task {
            do {
                let items = try await loader.loadItems()
                result = .success(items)
            } catch {
                result = .failure(error)
            }
        }
    }
}

One thing that’s very important to point out, though, is that all of this only works when we’re using Swift’s new concurrency system. So when using other concurrency patterns, for example completion handlers, then the @MainActor attribute has no effect — meaning that the following code will still cause our result property to be incorrectly assigned on a background queue:

@MainActor class ListViewModel: ObservableObject {
    ...

    func load() {
        loader.loadItems { [weak self] result in
    self?.result = result
}
    }
}

At first, that might seem like a strange limitation, but it does make sense when considering that actors can only be accessed asynchronously, using the new async/await system. So if we’re operating in a completely synchronous context (which our above completion handler actually is, even if it might be called on a background thread) there’s no way for the system to automatically dispatch that code onto the main actor.

Conclusion

Over time, once most of our asynchronous code has been migrated to Swift’s new concurrency system, the MainActor is hopefully going to more or less eliminate the need for us to manually dispatch our UI updates on the main queue. Of course, that doesn’t mean that we no longer have to consider threading and other concurrency issues when designing our APIs and our architecture — but at least that very common issue of accidentally performing UI updates on a background queue can, at some point, hopefully become a problem of the past.

For much more on Swift’s new concurrency system, make sure to listen to my podcast conversation with Doug Gregor from Apple, and stay tuned to Swift by Sundell for many more articles on async/await, actors, and structured concurrency within the coming weeks and months.

Thanks for reading!