tva
← Artigos

Concorrência no Swift 6: um guia prático de migração

O Swift 5 era tolerante com relação a condições de corrida de dados. O compilador avisava sobre algumas delas e ignorava outras, o que fazia com que as bases de código desenvolvessem hábitos — chamando DispatchQueue.main.async em alguns lugares, esquecendo em outros — que funcionavam até que paravam. O Swift 6 muda o contrato: todos os avisos sobre isolamento de atores e conformidade com Sendable se tornam erros que impedem a compilação, e o build se recusa a prosseguir até que o potencial de condição de corrida de dados seja eliminado ou explicitamente reconhecido.

Na realidade, a maioria dos aplicativos SwiftUI existentes não tem condições de corrida de dados catastróficas escondidas neles. O que eles têm é uma coleção de padrões que eram seguros na prática, mas tecnicamente incorretos de acordo com o modelo de concorrência do Swift. A migração tem menos a ver com corrigir bugs reais e mais com tornar as propriedades de segurança do código legíveis para o compilador — o que é um exercício valioso, mas requer entender o que os erros estão realmente dizendo.

Habilitando a verificação estrita de concorrência

Antes de atualizar para o modo de linguagem Swift 6, é útil visualizar os erros no nível de aviso. No Xcode, defina a configuração do compilador Swift SWIFT_STRICT_CONCURRENCY como complete para seu target. Isso mostra tudo o que o Swift 6 rejeitaria, mas não interrompe o build. Revise os avisos sistematicamente antes de mudar a versão da linguagem.

Para habilitar o modo de linguagem Swift 6 nas configurações de compilação (Build Settings) do seu target, defina SWIFT_VERSION como 6. Uma vez definido, a verificação estrita de concorrência complete está implícita e todos os avisos se tornam erros. É melhor fazer a atualização target por target em um projeto com múltiplos pacotes, começando pelos pacotes com menos dependências de interface — pacotes de lógica pura e de rede primeiro, pacotes de interface por último.

O problema do limite de isolamento do MainActor

O tipo de erro mais comum na migração de aplicativos SwiftUI se parece com este:

// 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
        }
    }
}

As propriedades @Published em um ObservableObject não são automaticamente isoladas em @MainActor, mas o SwiftUI as observa na thread principal. Quando uma Task {} é executada em um executor não especificado e tenta escrever em items, o Swift 6 detecta a possível condição de corrida. Para a classe exatamente como está escrita, o compilador do Swift 6 relata um risco de corrida de dados na closure de Task que captura self; a mensagem sobre isolamento no ator principal que aparece no comentário só é exibida quando items recebe a anotação @MainActor.

A correção mais limpa é anotar a classe inteira com @MainActor:

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

    func fetch() async {
        let result = await API.load()
        items = result  // ✓ — a classe inteira é isolada no MainActor
    }
}

Isso torna o isolamento da classe explícito. Chamadores que invocam fetch() em um contexto de segundo plano precisarão usar await, o que é correto — eles devem estar no ator principal para interagir com uma classe que controla atualizações da interface. Se você não puder anotar a classe completa, a alternativa é mudar explicitamente para o ator principal:

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

Ambas as abordagens são válidas. A anotação de classe @MainActor é mais limpa quando o propósito principal da classe é controlar as views do SwiftUI — que é quase sempre o caso para classes ObservableObject em um aplicativo bem estruturado.

PhotosPicker e isolamento de delegates

O PHPickerViewControllerDelegate é uma fonte comum de erros do Swift 6. Nos SDKs atuais, o próprio método do delegate é isolado no ator principal, mas o callback de carregamento do provedor de itens usado dentro dele é executado em uma fila de segundo plano, e o código desse callback normalmente precisa atualizar a interface. O padrão clássico do Swift 5:

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  // ← erro do Swift 6
        }
    }
}

No Swift 6, a closure passada para loadObject é @Sendable, o que significa que não pode capturar self se self não for Sendable. Um UIViewController atende a esse requisito porque classes isoladas no ator principal são implicitamente Sendable; o erro real é o acesso a imageView.image, uma propriedade isolada em @MainActor, a partir de um contexto de segundo plano.

A correção é mudar para o ator principal dentro da 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
    }
}

A sintaxe Task { @MainActor in } cria uma tarefa que está explicitamente isolada no ator principal. A captura de image está correta porque UIImage está em conformidade com Sendable. A captura de self via [weak self] está correta porque um controlador de visualização é isolado no ator principal e, portanto, é Sendable; weak apenas evita um ciclo de retenção e não tornaria segura a captura de um tipo que não seja Sendable.

Para o PhotosPicker do SwiftUI (a API mais recente), o padrão é mais limpo porque usa Transferable:

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

Como a chamada loadTransferable é async, você a usa com await, e a atribuição ocorre em no ator ao qual a função está isolada — que é @MainActor se o modelo de apresentação tiver essa anotação. Não é necessário mudar de ator manualmente.

Tipos Sendable e tipos que não são Sendable

Um tipo é Sendable se é seguro transferi-lo entre limites de isolamento de atores — grosso modo, se é um tipo de valor, ou uma classe que protege seu estado por meio de sincronização. O compilador impõe isso no Swift 6 sempre que um valor atravessa um limite de isolamento.

O padrão de erro que aparece com mais frequência:

// 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 capturado aqui
}

A correção geralmente é direta — adicionar conformidade com Sendable ao tipo de valor:

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

Para structs cujos membros são todos Sendable (o que inclui String, Data e todos os tipos de valor padrão), essa conformidade é trivialmente satisfeita. O compilador a infere implicitamente para structs não públicas; por isso, na prática, esse erro aparece em tipos public usados a partir de outro módulo, e uma declaração explícita : Sendable é verificada pelo compilador. Para classes, a história é mais complexa: o compilador só pode verificar a conformidade com Sendable de uma classe final cujas propriedades armazenadas sejam todas constantes let de tipos Sendable, ou de uma classe isolada em um ator global, como @MainActor. Uma classe que protege o estado mutável por meio de sua própria sincronização interna precisa de @unchecked Sendable, porque o compilador não pode verificar os bloqueios. Para um ObservableObject sempre acessado no ator principal, prefira @MainActor, que o torna automaticamente Sendable; trate @unchecked Sendable como último recurso, não como primeira resposta.

SharedDataProvider e estado compartilhado entre modelos de apresentação

Um padrão comum em aplicativos SwiftUI maiores é um provedor de dados compartilhado que é injetado em múltiplas views por meio do ambiente. No Swift 6, esse objeto precisa de isolamento de ator explícito porque é acessado no corpo das views, que é executado no ator principal:

@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
    }
}

A anotação @MainActor aqui tem um efeito concreto: garante que todo acesso a currentUser e sessionToken seja serializado pelo ator principal, que é o isolamento correto para propriedades que controlam as atualizações das views do SwiftUI. Tarefas em segundo plano que precisam escrever nessa classe devem usar await, o que é correto — elas devem esperar que o ator principal esteja disponível em vez de escrever concorrentemente.

A sutileza é que static let shared em uma classe @MainActor só pode ser acessada no ator principal. Se você tiver qualquer caminho de código que tente acessar SharedDataProvider.shared de um contexto de segundo plano, o Swift 6 o rejeitará. A correção é passar a instância explicitamente pela cadeia de funções (o acesso às suas propriedades fora do ator principal ainda exige await), ou envolver o acesso em await MainActor.run { ... }.

A saída de emergência @unchecked Sendable

Algumas classes não podem ser tornadas completamente Sendable na prática — elas podem envolver bibliotecas C, criar pontes para tipos Objective-C, ou ter estado interno gerenciado com segurança, mas de forma opaca. Para esses casos, @unchecked Sendable diz ao compilador “confie em mim, cuidei da sincronização”:

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 }
    }
}

Isso não é um atalho para evitar o trabalho de migração. É uma declaração explícita de que você pensou sobre a semântica de concorrência e está assumindo a responsabilidade por ela. Use para classes que realmente precisam de sincronização interna, e adicione um comentário explicando qual mecanismo fornece a garantia de segurança.

Uma ordem prática de migração

Habilite SWIFT_STRICT_CONCURRENCY = complete primeiro e trate todos os avisos antes de mudar para o modo de linguagem Swift 6. Comece pelos pacotes folha no seu grafo de dependências — os que não dependem de outros módulos internos. Corrija-os, atualize-os para o modo de linguagem Swift 6, depois passe para a próxima camada. Isso evita uma situação em que corrigir um pacote dependente requer mudanças simultâneas em um pacote do qual ele depende e que ainda está em modo de aviso.

A maior parte do trabalho de migração em uma base de código SwiftUI típica consiste em anotações @MainActor em classes ObservableObject, conformidade com Sendable em tipos de valor passados entre tarefas com limites de isolamento distintos, e mudanças explícitas de ator em callbacks de delegates. O volume de mudanças parece grande em um diff, mas cada mudança individual é mecânica. O compilador guia você para cada local que precisa de atenção — que é o objetivo da verificação estrita de concorrência.

Artigos relacionados

Leitura complementar