DEV Community

Cover image for SwiftUI Views and @MainActor
Fatbobman( 东坡肘子 )
Fatbobman( 东坡肘子 )

Posted on

SwiftUI Views and @MainActor

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.


Don’t miss out on the latest updates and excellent articles about Swift, SwiftUI, Core Data, and SwiftData. Subscribe to Fatbobman’s Swift Weekly and receive weekly insights and valuable content directly to your inbox.


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
Enter fullscreen mode Exit fullscreen mode

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.

@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
}
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

@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
}
Enter fullscreen mode Exit fullscreen mode

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 }
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

@MainActor
struct PasteButtonDemo: View {
  var body: some View {
    ...
  }

  var button: some View {
    PasteButton(payloadType: String.self) { str in
      ...
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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.

The original article was published on my blog Fatbobman's Blog.

Top comments (0)