tva
← Insights

Concorrenza in Swift 6: Una Guida Pratica alla Migrazione

Swift 5 was lenient about data races. The compiler warned about some of them and ignored others, which meant that codebases developed habits – calling DispatchQueue.main.async in some places, forgetting it in others – that worked until they didn’t. Swift 6 changes the contract: all warnings about actor isolation and Sendable conformance become hard errors, and the build refuses to proceed until the data race potential is eliminated or explicitly acknowledged.

In reality, most existing SwiftUI apps do not have catastrophic data races hidden in them. What they have is a collection of patterns that were safe in practice but technically unsound according to the Swift concurrency model. The migration is less about fixing real bugs and more about making the code’s safety properties legible to the compiler – which is a worthwhile exercise, but it requires understanding what the errors are actually telling you.

Enabling Strict Concurrency Checking

Before upgrading to Swift 6 language mode, it is useful to preview the errors at the warning level. In Xcode, set the Swift compiler setting SWIFT_STRICT_CONCURRENCY to complete for your target. This shows you everything Swift 6 would reject, but it does not fail the build. Run through the warnings systematically before flipping the language version.

To enable Swift 6 language mode in your target’s Build Settings, set SWIFT_VERSION to 6. Once set, complete strict concurrency checking is implied and all the warnings become errors. The upgrade is best done target-by-target in a multi-package project, starting with the packages that have the fewest UI dependencies – pure logic and networking packages first, UI packages last.

The MainActor Boundary Problem

The most common class of error in SwiftUI migration looks like this:

// Error: Main actor-isolated property 'items' can not be
// mutated from a nonisolated context
class DataStore: ObservableObject {
    @Published var items: [Item] = []

    func fetch() {
        Task {
            let result = await API.load()
            self.items = result  // ← error
        }
    }
}

@Published properties on an ObservableObject are not automatically @MainActor-isolated, but SwiftUI observes them on the main thread. When a Task {} runs on an unspecified executor and tries to write to items, Swift 6 catches the potential race.

The cleanest fix is to annotate the entire class @MainActor:

@MainActor
class DataStore: ObservableObject {
    @Published var items: [Item] = []

    func fetch() async {
        let result = await API.load()
        items = result  // ✓ — entire class is MainActor-isolated
    }
}

This makes the class’s isolation explicit. Callers that invoke fetch() from a background context will need to await it, which is correct – they should be on the main actor to interact with a class that drives UI updates. If you cannot annotate the full class, the alternative is to hop explicitly:

func fetch() {
    Task {
        let result = await API.load()
        await MainActor.run {
            self.items = result
        }
    }
}

Both approaches are valid. The @MainActor class annotation is cleaner when the class’s primary purpose is to drive SwiftUI views – which is almost always the case for ObservableObject classes in a well-structured app.

PhotosPicker and Delegate Isolation

PHPickerViewControllerDelegate is a common source of Swift 6 errors because its callback arrives on a background thread but the code in the callback typically needs to update UI. The classic Swift 5 pattern:

func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
    dismiss(animated: true)
    guard let provider = results.first?.itemProvider else { return }

    provider.loadObject(ofClass: UIImage.self) { [weak self] object, error in
        if let image = object as? UIImage {
            self?.imageView.image = image  // ← Swift 6 error
        }
    }
}

In Swift 6, the closure passed to loadObject is @Sendable, which means it cannot capture self if self is not Sendable. And imageView.image is a @MainActor-isolated property being accessed from a background context.

The fix is to hop to the main actor inside the closure:

provider.loadObject(ofClass: UIImage.self) { [weak self] object, error in
    guard let image = object as? UIImage else { return }
    Task { @MainActor [weak self] in
        self?.imageView.image = image
    }
}

The Task { @MainActor in } syntax creates a task that is explicitly isolated to the main actor. The capture of image is fine because UIImage conforms to Sendable. The capture of self via [weak self] is fine because it’s a weak optional reference, not a shared mutable state transfer.

For SwiftUI’s PhotosPicker (the newer API), the pattern is cleaner because it uses Transferable:

// In a @MainActor view model:
func handlePickerSelection(_ item: PhotosPickerItem?) async {
    guard let item else { return }
    if let data = try? await item.loadTransferable(type: Data.self) {
        selectedImage = UIImage(data: data)
    }
}

Because the loadTransferable call is async, you await it, and the assignment happens on whatever actor the function is isolated to – which is @MainActor if the view model carries that annotation. No manual actor hopping required.

Sendable and Non-Sendable Types

A type is Sendable if it is safe to transfer across actor isolation boundaries – roughly, if it is a value type, or a class that protects its state through synchronization. The compiler enforces this in Swift 6 wherever a value crosses an isolation boundary.

The error pattern that appears most often:

// Error: Capture of 'config' with non-sendable type 'RequestConfig'
// in a '@Sendable' closure
struct RequestConfig {
    var endpoint: String
    var headers: [String: String]
    var body: Data?
}

Task {
    let result = await API.fetch(config)  // config captured here
}

The fix is usually straightforward – add Sendable conformance to the value type:

struct RequestConfig: Sendable {
    var endpoint: String
    var headers: [String: String]
    var body: Data?
}

For structs whose members are all Sendable (which includes String, Data, and all the standard value types), this conformance is trivially satisfied and the compiler will synthesize it. For classes, the story is more complex: a class is Sendable only if it is either immutable (final with all properties let) or internally synchronized. An ObservableObject class can be marked @unchecked Sendable if you know its synchronization is handled – typically because it’s always accessed on the main actor – but use this as a last resort, not a first response.

SharedDataProvider and Cross-View Model State

A common pattern in larger SwiftUI apps is a shared data provider that is injected into multiple views via the environment. In Swift 6, this object needs explicit actor isolation because it is accessed from view bodies, which run on the main actor:

@MainActor
class SharedDataProvider: ObservableObject {
    static let shared = SharedDataProvider()

    @Published var currentUser: User?
    @Published var sessionToken: String?

    func refreshSession() async {
        let token = await AuthClient.refresh()
        sessionToken = token
    }
}

The @MainActor annotation here is doing real work: it ensures that all access to currentUser and sessionToken is serialized through the main actor, which is the correct isolation for properties that drive SwiftUI view updates. Background tasks that need to write to this class must use await, which is correct – they should wait for the main actor to be available rather than writing concurrently.

The subtlety is that static let shared on a @MainActor class can only be accessed from the main actor. If you have any code path that tries to access SharedDataProvider.shared from a background context, Swift 6 will reject it. The fix is either to pass the instance explicitly through the function chain, or to wrap the access in await MainActor.run { ... }.

The @unchecked Sendable Escape Hatch

Some classes cannot be made fully Sendable in practice – they may wrap C libraries, bridge to Objective-C types, or have internal state that is managed safely but opaquely. For these cases, @unchecked Sendable tells the compiler “trust me, I have handled the synchronization”:

final class AudioPlayer: @unchecked Sendable {
    private let queue = DispatchQueue(label: "audio.player")
    private var _isPlaying = false

    var isPlaying: Bool {
        queue.sync { _isPlaying }
    }

    func play() {
        queue.async { self._isPlaying = true }
    }
}

This is not a shortcut to avoid migration work. It is an explicit declaration that you have thought about the concurrency semantics and are taking responsibility for them. Use it for classes that genuinely need internal synchronization, and add a comment explaining what mechanism provides the safety guarantee.

A Practical Migration Order

Enable SWIFT_STRICT_CONCURRENCY = complete first and address all warnings before switching to Swift 6 language mode. Start with the leaf packages in your dependency graph – the ones with no dependencies on other in-house modules. Fix those, update them to Swift 6 language mode, then move to the next layer. This prevents a situation where fixing a downstream package requires simultaneous changes to an upstream package that is still in warning mode.

The majority of the migration work in a typical SwiftUI codebase is @MainActor annotations on ObservableObject classes, Sendable conformance on value types passed across task boundaries, and explicit actor hops in delegate callbacks. The volume of changes looks large in a diff but each individual change is mechanical. The compiler guides you to every location that needs attention – which is the point of strict concurrency checking.

Approfondimenti Correlati

Articoli correlati