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
- Criando um aplicativo de preparação para exames com milhares de perguntas — A arquitetura SPM com múltiplos pacotes em que esses limites de isolamento se tornam mais relevantes.
- Do conceito em SwiftUI à App Store em semanas — Quando adiar uma migração para o Swift 6 durante um ciclo de desenvolvimento de recursos acelerado.
- Corrigindo rejeições de ícones na App Store — O outro tipo de dificuldade antes do envio que surpreende as equipes na verificação final de qualidade.