Is it unnecessary to switch to the @MainActor from a function where a helper function is the one actually updating the UI?

In WWDC21's Swift Concurrency: Behind the Scenes video, toward the end of the video they share this code example:

// on database actor
func loadArticle(with id: ID) async throws -> Article 

@MainActor func updateUI(for article: Article) async

@MainActor func updateArticles(for ids: [ID]) async throws {
    for id in ids {
        let article = try await database.loadArticle(with: id)
        await updateUI(for: article)
    }
}

I wanted to first re-create the code snippet and then understand it. (I couldn't get it exactly right):

actor Database {
    func loadArticle(with id: Article.ID) async throws -> Article {  
        // ...
    }
}

actor HealthFeed {
    let database = Database()

    @MainActor func updateUI(for article: Article) async { /* ... */ }
    
    @MainActor func updateArticles(for ids: [Article.ID]) async throws { 
        for id in ids {
            let article = try await database.loadArticle(with: id)
            await updateUI(for: article)
        }
    }
}

struct Article: Identifiable {
    let id: Int
}

I need help in understanding the "behind the scenes" work going on, more specifically, why that @MainActor on updateArticles(for:)is required.

Since updateUI(for:) is declared with @MainActor, it is executed on the main thread. Which makes sense since it's updating the UI. However, updateArticles(for:) calls updateUI(for:), so why does updateArticles(for:) itself need to be declared with @MainActor? Seems like an unnecessary context switch to me.

If someone can make me understand why I'm wrong in thinking it's an unnecessary context switch please tell me. Additionally, if someone can better recreate the code snippet from the WWDC video please let me know.

You're probably right here. It definitely seems like updateArticles should be off the main actor so that you can process the articles without blocking and then call back to the UI. I'd even say you'd want to call updateUI without an await so your HealthFeed doesn't have to suspend between each article unnecessarily. Simply wrapping it in a Task would prevent that.

Most likely this is just to demonstrate the fact that you can isolate an actors methods to other actors if you need. It's not necessarily a representation of the most efficient way to do something. This is a fairly common in Apple's and Swift's sample code I wish they'd improve.

1 Like

if we were to wrap it in a Task, when the await database.loadArticle(with: id) is called in the next iteration, during the suspension, the task scheduled earlier would run. This way we await one thing but during that time, do some other useful tasks. Am I right?

Also, what about running updateUI on a detached task? Would that be worse or the same?

I think it’d make no difference because the system would be able to as efficiently schedule it to a different thread as it would schedule it on the current one if we made it run on the current actor