An increasing number of developers are starting to enable strict concurrency checks in preparation for the arrival of Swift 6. Among the warnings and errors received, a portion relates to SwiftUI views, many of which stem from developers not correctly understanding or using @MainActor
. This article will discuss the meaning of @MainActor
, as well as tips and considerations for applying @MainActor
within SwiftUI views.
At WWDC 2024, Apple introduced a significant update to the SwiftUI
View
protocol: marking the entire protocol with@MainActor
while ensuring backward compatibility. This change has far-reaching implications, meaning that all types conforming to theView
protocol will now automatically receive the@MainActor
annotation. To help developers better understand and adapt to this change, we’ve added a dedicated section at the end of this article, detailing important considerations when using the newView
protocol in the Xcode 16 environment.
My PasteButton Stopped Working
Not long ago, in my Discord community, a friend reported that after enabling strict concurrency checks, the compiler threw the following error for PasteButton
:
Call to main actor-isolated initializer 'init(payloadType:onPast:)' in a synchronous nonisolated context
After examining the declaration of PasteButton
, I asked him whether he placed the view code outside of body
. Upon receiving a positive response, I advised him to add @MainActor
to the variable declaration of PasteButton
, and that solved the problem.
@MainActor public struct PasteButton : View {
@MainActor public init(supportedContentTypes: [UTType], payloadAction: @escaping ([NSItemProvider]) -> Void)
@MainActor public init<T>(payloadType: T.Type, onPaste: @escaping ([T]) -> Void) where T : Transferable
public typealias Body = some View
}
So, where was the issue initially? Why did adding @MainActor
resolve it?
What is @MainActor
In Swift’s concurrency model, actor
offers a safe and understandable way to write concurrent code. An actor
is similar to a class but is specifically designed to address data races and synchronization issues in a concurrent environment.
actor Counter {
private var value = 0
func increment() {
value += 1
}
func getValue() async -> Int {
return value
}
}
let counter = Counter()
Task {
await counter.increment()
}
The magic of actor
lies in its ability to serialize access to prevent data races, providing a clear and safe path for concurrent operations. However, this isolation is localized to specific actor
instances. Swift introduces the concept of GlobalActor
to extend isolation more broadly.
GlobalActor
allows us to annotate code across different modules to ensure that these operations are executed in the same serial queue, thereby maintaining the atomicity and consistency of operations.
@globalActor actor MyActor: GlobalActor {
static let shared = MyActor()
}
@MyActor
struct A {
var name: String = "fat"
}
class B {
var age: Int = 10
}
@MyActor
func printInfo() {
let a = A()
let b = B()
print(a.name, b.age)
}
@MainActor
is a special GlobalActor
defined by Swift. Its role is to ensure that all code annotated with @MainActor
executes in the same serial queue, and all this happens on the main thread.
@globalActor actor MainActor : GlobalActor {
static let shared: MainActor
}
This succinct and powerful feature of @MainActor
provides a type-safe and integrated way to handle operations that previously relied on DispatchQueue.main.async
within Swift’s concurrency model. It not only simplifies the code and reduces the error rate but also ensures, through compiler protection, that all operations marked with @MainActor
are safely executed on the main thread.
The View Protocol and @MainActor
In the world of SwiftUI, a view plays the role of declaratively presenting the application’s state on the screen. This naturally leads to the assumption that all code composing a view would execute on the main thread, given that views directly relate to the user interface’s presentation.
However, delving into the View protocol reveals a detail: only the body
property is explicitly marked with @MainActor
. This discovery means that types conforming to the View protocol are not guaranteed to run entirely on the main thread. Beyond body
, the compiler does not automatically ensure that other properties or methods execute on the main thread.
public protocol View {
associatedtype Body : View
@ViewBuilder @MainActor var body: Self.Body { get }
}
This insight is particularly crucial for understanding the use of SwiftUI’s official components like PasteButton
, which, unlike most other components, is explicitly marked with @MainActor
. This indicates that PasteButton
must be used within a context also marked with @MainActor
, or else the compiler will report an error, indicating that a call to a main actor-isolated initializer is not allowed in a synchronous nonisolated context:
struct PasteButtonDemo: View {
var body: some View {
VStack {
Text("Hello")
button
}
}
var button: some View {
PasteButton(payloadType: String.self) { str in // Call to main actor-isolated initializer 'init(payloadType:onPaste:)' in a synchronous nonisolated context
print(str)
}
}
}
To resolve this issue, simply marking the button
variable with @MainActor
can smoothly pass the compilation, as it ensures that button
is initialized and used within an appropriate context:
@MainActor
var button: some View {
PasteButton(payloadType: String.self) { str in
print(str)
}
}
Most SwiftUI components are value types and conform to the Sendable protocol, and they are not explicitly marked as
@MainActor
, thus they do not encounter the specific issues faced byPasteButton
.
This modification highlights the importance of using @MainActor
in SwiftUI views and also serves as a reminder to developers that not all code related to views is executed on the main thread by default.
Applying @MainActor to Views
Some readers might wonder, could directly annotating the PasteButtonDemo
view type with @MainActor
fundamentally solve the problem?
Indeed, annotating the entire PasteButtonDemo
view with @MainActor
can address the issue at hand. Once annotated with @MainActor
, the Swift compiler assumes that all properties and methods within the view execute on the main thread, thus obviating the need for a separate annotation for button
.
@MainActor
struct PasteButtonDemo: View {
var body: some View {
...
}
var button: some View {
PasteButton(payloadType: String.self) { str in
...
}
}
}
This approach also brings other benefits. For example, when building observable objects with the Observation
framework, to ensure their state updates occur on the main thread, one might annotate the observable objects themselves with @MainActor
:
@MainActor
@Observable
class Model {
var name = "fat"
var age = 10
}
However, attempting to declare this observable object instance in the view with @State
, as recommended by official documentation, would encounter a compiler warning; this is considered an error in Swift 6.
struct DemoView: View {
@State var model = Model() // Main actor-isolated default value in a nonisolated context; this is an error in Swift 6
var body: some View {
NameView(model: model)
}
}
struct NameView: View {
let model: Model
var body: some View {
Text(model.name)
}
}
This issue arises because, by default, a view’s implementation is not annotated with @MainActor
, thus it cannot directly declare types annotated with @MainActor
. Once DemoView
is annotated with @MainActor
, the aforementioned issue is resolved.
To further simplify the process, we could also define a protocol annotated with @MainActor
, allowing any view that conforms to this protocol to automatically inherit the main thread execution environment:
@MainActor
protocol MainActorView: View {}
Thus, any view that implements the MainActorView
protocol ensures that all its operations execute on the main thread:
struct AsyncDemoView: MainActorView {
var body: some View {
Text("abc")
.task {
await do something()
}
}
func doSomething() async {
print(Thread.isMainThread) // true
}
}
Although annotating view types with @MainActor
seems like a good solution, it requires all asynchronous methods declared within the view to execute on the main thread, which may not always be desirable. For example:
@MainActor
struct AsyncDemoView: View {
var body: some View {
Text("abc")
.task {
await doSomething()
}
}
func doSomething() async {
print(Thread.isMainThread) // true
}
}
If not annotated with @MainActor
, we could more flexibly annotate properties and methods as needed:
struct AsyncDemoView: View {
var body: some View {
Text("abc")
.task {
await doSomething()
}
}
func doSomething() async {
print(Thread.isMainThread) // false
}
}
Therefore, whether to annotate a view type with @MainActor
depends on the specific application scenario.
New Uses for @StateObject
With the Observation framework becoming the new standard, the traditional use of @StateObject
seems to become less prominent. However, it still possesses a special functionality that allows it to remain useful in the era of Observation. As we’ve discussed before, an @Observable
object marked with @MainActor
cannot be directly declared with @State
—unless the entire view is also annotated with @MainActor
. But, with @StateObject
, we can cleverly circumvent this limitation.
Consider the following example, where we can safely introduce an observable object marked with @MainActor
into the view without having to mark the entire view with @MainActor
:
@MainActor
@Observable
class Model: ObservableObject {
var name = "fat"
var age = 10
}
struct StateObjectDemo: View {
@StateObject var model = Model()
var body: some View {
VStack {
NameView(model: model)
AgeView(model: model)
Button("update age"){
model.age = Int.random(in: 0..<100)
}
}
}
}
The feasibility of this practice stems from the unique loading mechanism of @StateObject
. It calls the constructor’s closure on the main thread when the view is actually loaded. Moreover, the wrappedValue
of @StateObject
is annotated with @MainActor
, ensuring it can correctly initialize and use types conforming to the ObservableObject
protocol marked with @MainActor
.
@frozen @propertyWrapper public struct StateObject<ObjectType>: SwiftUI.DynamicProperty where ObjectType: Combine.ObservableObject {
@usableFromInline
@frozen internal enum Storage {
case initially(() -> ObjectType)
case object(SwiftUI.ObservedObject<ObjectType>)
}
@usableFromInline
internal var storage: SwiftUI.StateObject<ObjectType>.Storage
@inlinable public init(wrappedValue thunk: @autoclosure @escaping () -> ObjectType) {
storage = .initially(thunk)
}
@_Concurrency.MainActor(unsafe) public var wrappedValue: ObjectType {
get
}
@_Concurrency.MainActor(unsafe) public var projectedValue: SwiftUI.ObservedObject<ObjectType>.Wrapper {
get
}
public static func _makeProperty<V>(in buffer: inout SwiftUI._DynamicPropertyBuffer, container: SwiftUI._GraphValue<V>, fieldOffset: Swift.Int, inputs: inout SwiftUI._GraphInputs)
}
The main advantage of this method is that it ensures the safety of the observable object’s lifecycle while completely preserving its observation logic based on the Observation framework. This allows us to flexibly use observable types marked with @MainActor
without having to mark the entire view with @MainActor
. This offers a path that maintains the flexibility of the view without compromising data safety and response logic. Until Apple provides a more definitive solution for running @State
on the main thread or adjusting view declarations, this approach serves as a practical and effective temporary strategy.
In the current version of SwiftUI (prior to Swift 6), when developers declare state within a view using
@StateObject
, the Swift compiler implicitly treats the entire view as being annotated with@MainActor
. This implicit inference behavior can easily lead to misunderstandings among developers. With the official adoption of the SE-401 proposal, starting from Swift 6, such implicit inference will no longer be permitted.
Xcode 16: @MainActor Annotation for the View Protocol
In Xcode 16, Apple made a significant adjustment to the View
protocol in the SwiftUI framework. This change appears to address the potential confusion caused by annotating only the body
property with @MainActor
. Now, the entire View
protocol is annotated with @MainActor
, not just the body
property. Notably, this change is backward compatible.
The new View
protocol declaration is as follows:
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
@MainActor @preconcurrency public protocol View {
associatedtype Body : View
@ViewBuilder @MainActor @preconcurrency var body: Self.Body { get }
}
This change means that when we run existing code in Xcode 16, it will follow this new annotation approach. This may lead to some unexpected behavior changes. Consider the following example:
struct MainActorViewDemo: View {
var body: some View {
Text("Hello world")
.task {
await doSomethingNotInMainActor()
}
}
func doSomethingNotInMainActor() async {
print(Thread.isMainThread)
}
}
// Xcode 15 output: false (doSomethingNotInMainActor runs on a non-main thread)
// Xcode 16 output: true (doSomethingNotInMainActor runs on the main thread)
If you want to ensure that the doSomethingNotInMainActor
method continues to run on a non-main thread, you can use the nonisolated
keyword to isolate it from the @MainActor
context:
nonisolated func doSomethingNotInMainActor() async {
print(Thread.isMainThread)
}
// Xcode 15
false
// Xcode 16
false
With this adjustment, the method will execute on a non-main thread in both Xcode 15 and Xcode 16, maintaining consistent behavior.
This change highlights the importance of remaining vigilant when developing across Xcode versions, especially when dealing with concurrency and thread-related code. When migrating to a new version of Xcode, it’s advisable to carefully review and test code involving thread execution contexts to ensure that the application’s behavior meets expectations.
Conclusion
After enabling strict concurrency checks, many developers might feel confused and overwhelmed by a slew of warnings and errors. Some may resort to modifying code based on these prompts to eliminate errors. However, the fundamental purpose of introducing a new concurrency model into projects goes far beyond “deceiving” the compiler. In reality, developers should deeply understand Swift’s concurrency model and reassess their code on a more macro level to discover higher-quality, safer resolution strategies, rather than simply addressing symptoms. This approach not only enhances the quality and maintainability of the code but also helps developers navigate the world of Swift’s concurrency programming with greater confidence and ease.