SwiftUI Views and @MainActor

Published on

Get weekly handpicked updates on Swift and SwiftUI!

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 modified the SwiftUI View protocol, marking it as @MainActor and implementing backward compatibility. This means that all types conforming to the View protocol will automatically carry the @MainActor annotation.

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

pasteButton-MainActor-error-2024-03-13

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.

Swift
@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.

Swift
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.

Swift
@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.

Swift
@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.

Swift
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:

Swift
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:

Swift
@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 by PasteButton.

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.

Swift
@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:

Swift
@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.

Swift
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:

Swift
@MainActor
protocol MainActorView: View {}

Thus, any view that implements the MainActorView protocol ensures that all its operations execute on the main thread:

Swift
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:

Swift
@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:

Swift
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:

Swift
@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.

Swift
@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.

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.

Explore weekly Swift highlights with developers worldwide

Buy me a if you found this article helpful