Concurrence Swift 6 : guide de migration pratique
Swift 5 était tolérant avec les courses aux données. Le compilateur en avertissait certaines et en ignorait d'autres, ce qui signifie que les bases de code ont développé des habitudes – appeler DispatchQueue.main.async dans certains endroits, l'oublier dans d'autres – qui fonctionnaient jusqu'à ce qu'elles ne fonctionnent plus. Swift 6 change le contrat : tous les avertissements concernant l'isolation des acteurs et la conformité Sendable deviennent des erreurs hard, et le build refuse de continuer jusqu'à ce que le potentiel de course aux données soit éliminé ou explicitement reconnu.
En réalité, la plupart des applications SwiftUI existantes n'ont pas de courses aux données catastrophiques cachées en elles. Ce qu'elles ont, c'est une collection de modèles qui étaient sûrs en pratique mais techniquement non conformes selon le modèle de concurrence Swift. La migration consiste moins à corriger de vrais bugs qu'à rendre les propriétés de sécurité du code lisibles par le compilateur – ce qui est un exercice valable, mais qui nécessite de comprendre ce que les erreurs vous disent réellement.
Activer la vérification stricte de la concurrence
Avant de passer au mode de langage Swift 6, il est utile de prévisualiser les erreurs au niveau d'avertissement. Dans Xcode, définissez le paramètre du compilateur Swift SWIFT_STRICT_CONCURRENCY à complete pour votre cible. Cela vous montre tout ce que Swift 6 rejetterait, mais ne fait pas échouer le build. Parcourez les avertissements de manière systématique avant de changer la version du langage.
Pour activer le mode de langage Swift 6 dans les paramètres de build de votre cible, définissez SWIFT_VERSION à 6. Une fois défini, la vérification stricte de concurrence complete est implicite et tous les avertissements deviennent des erreurs. La mise à niveau est mieux effectuée cible par cible dans un projet multi-packages, en commençant par les packages qui ont le moins de dépendances UI – d'abord les packages de logique pure et de réseau, les packages UI en dernier.
Le problème de frontière MainActor
La classe d'erreur la plus courante dans la migration SwiftUI ressemble à ceci :
// Erreur : La propriété isolée sur le MainActor 'items' ne peut pas
// être mutée depuis un contexte non isolé
class DataStore: ObservableObject {
@Published var items: [Item] = []
func fetch() {
Task {
let result = await API.load()
self.items = result // ← erreur
}
}
}
Les propriétés @Published sur un ObservableObject ne sont pas automatiquement isolées sur @MainActor, mais SwiftUI les observe sur le thread principal. Lorsqu'un Task {} s'exécute sur un exécuteur non spécifié et tente d'écrire dans items, Swift 6 détecte la course potentielle.
La correction la plus propre est d'annoter toute la classe avec @MainActor :
@MainActor
class DataStore: ObservableObject {
@Published var items: [Item] = []
func fetch() async {
let result = await API.load()
items = result // ✓ — la classe entière est isolée sur le MainActor
}
}
Cela rend l'isolation de la classe explicite. Les appelants qui invoquent fetch() depuis un contexte en arrière-plan devront l'await, ce qui est correct – ils devraient être sur le MainActor pour interagir avec une classe qui pilote les mises à jour UI. Si vous ne pouvez pas annoter la classe complète, l'alternative est de sauter explicitement :
func fetch() {
Task {
let result = await API.load()
await MainActor.run {
self.items = result
}
}
}
Les deux approches sont valides. L'annotation @MainActor de classe est plus propre lorsque l'objectif principal de la classe est de piloter les vues SwiftUI – ce qui est presque toujours le cas pour les classes ObservableObject dans une application bien structurée.
PhotosPicker et isolation des délégués
PHPickerViewControllerDelegate est une source courante d'erreurs Swift 6 car son rappel arrive sur un thread en arrière-plan mais le code dans le rappel doit généralement mettre à jour l'UI. Le modèle classique 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 // ← erreur Swift 6
}
}
}
Dans Swift 6, la fermeture passée à loadObject est @Sendable, ce qui signifie qu'elle ne peut pas capturer self si self n'est pas Sendable. Et imageView.image est une propriété isolée sur @MainActor accessible depuis un contexte en arrière-plan.
La correction consiste à sauter vers le MainActor à l'intérieur de la fermeture :
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
}
}
La syntaxe Task { @MainActor in } crée une tâche qui est explicitement isolée sur le MainActor. La capture d'image est correcte car UIImage est conforme à Sendable. La capture de self via [weak self] est correcte car c'est une référence optionnelle faible, pas un transfert d'état mutable partagé.
Pour le PhotosPicker de SwiftUI (la nouvelle API), le modèle est plus propre car il utilise Transferable :
// Dans un modèle de vue @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)
}
}
Comme l'appel loadTransferable est async, vous l'await, et l'affectation se produit sur l'acteur sur lequel la fonction est isolée – qui est @MainActor si le modèle de vue porte cette annotation. Aucun saut d'acteur manuel requis.
Types Sendable et non-Sendable
Un type est Sendable s'il est sûr de le transférer à travers les frontières d'isolation des acteurs – approximativement, s'il s'agit d'un type valeur, ou d'une classe qui protège son état via une synchronisation. Le compilateur l'impose dans Swift 6 partout où une valeur franchit une frontière d'isolation.
Le modèle d'erreur qui apparaît le plus souvent :
// Erreur : Capture de 'config' avec le type non-sendable 'RequestConfig'
// dans une fermeture '@Sendable'
struct RequestConfig {
var endpoint: String
var headers: [String: String]
var body: Data?
}
Task {
let result = await API.fetch(config) // config capturé ici
}
La correction est généralement simple – ajouter la conformité Sendable au type valeur :
struct RequestConfig: Sendable {
var endpoint: String
var headers: [String: String]
var body: Data?
}
Pour les structs dont tous les membres sont Sendable (ce qui inclut String, Data, et tous les types valeur standard), cette conformité est trivialement satisfaite et le compilateur la synthétisera. Pour les classes, l'histoire est plus complexe : une classe est Sendable uniquement si elle est immuable (final avec toutes les propriétés let) ou synchronisée en interne. Une classe ObservableObject peut être marquée @unchecked Sendable si vous savez que sa synchronisation est gérée – généralement parce qu'elle est toujours accédée sur le MainActor – mais utilisez cela comme dernier recours, pas comme première réponse.
SharedDataProvider et état partagé entre modèles de vue
Un modèle courant dans les applications SwiftUI plus grandes est un fournisseur de données partagé qui est injecté dans plusieurs vues via l'environnement. Dans Swift 6, cet objet a besoin d'une isolation d'acteur explicite car il est accédé depuis les corps de vue, qui s'exécutent sur le MainActor :
@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
}
}
L'annotation @MainActor ici fait un vrai travail : elle garantit que tous les accès à currentUser et sessionToken sont sérialisés via le MainActor, ce qui est l'isolation correcte pour les propriétés qui pilotent les mises à jour de vues SwiftUI. Les tâches en arrière-plan qui doivent écrire dans cette classe doivent utiliser await, ce qui est correct – elles devraient attendre que le MainActor soit disponible plutôt que d'écrire de manière concurrente.
La subtilité est que static let shared sur une classe @MainActor ne peut être accédé que depuis le MainActor. Si vous avez un chemin de code qui essaie d'accéder à SharedDataProvider.shared depuis un contexte en arrière-plan, Swift 6 le rejettera. La correction consiste soit à passer l'instance explicitement à travers la chaîne de fonctions, soit à envelopper l'accès dans await MainActor.run { ... }.
La porte de sortie @unchecked Sendable
Certaines classes ne peuvent pas être rendues entièrement Sendable en pratique – elles peuvent envelopper des bibliothèques C, établir des ponts vers des types Objective-C, ou avoir un état interne qui est géré de manière sûre mais opaque. Pour ces cas, @unchecked Sendable dit au compilateur “faites-moi confiance, j'ai géré la synchronisation” :
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 }
}
}
Ce n'est pas un raccourci pour éviter le travail de migration. C'est une déclaration explicite que vous avez réfléchi à la sémantique de concurrence et que vous en prenez la responsabilité. Utilisez-le pour les classes qui ont genuinement besoin d'une synchronisation interne, et ajoutez un commentaire expliquant quel mécanisme fournit la garantie de sécurité.
Un ordre de migration pratique
Activez d'abord SWIFT_STRICT_CONCURRENCY = complete et adressez tous les avertissements avant de passer au mode de langage Swift 6. Commencez par les packages feuilles dans votre graphe de dépendances – ceux qui n'ont pas de dépendances sur d'autres modules internes. Corrigez-les, mettez-les à jour vers le mode de langage Swift 6, puis passez à la couche suivante. Cela évite une situation où la correction d'un package en aval nécessite des modifications simultanées d'un package en amont qui est encore en mode avertissement.
La majeure partie du travail de migration dans une base de code SwiftUI typique est constituée d'annotations @MainActor sur les classes ObservableObject, de conformités Sendable sur les types valeur passés à travers les frontières de tâches, et de sauts d'acteur explicites dans les rappels de délégués. Le volume de changements semble grand dans un diff mais chaque changement individuel est mécanique. Le compilateur vous guide vers chaque emplacement qui nécessite de l'attention – c'est précisément le but de la vérification stricte de la concurrence.
Ressources connexes
- Créer une application de préparation aux examens avec des milliers de questions – L'architecture SPM multi-packages où ces frontières d'isolation deviennent les plus pertinentes.
- De la conception SwiftUI à l'App Store en quelques semaines – Quand retarder une migration Swift 6 pendant un cycle de développement de fonctionnalités rapide.
- Corriger les rejets d'icônes App Store – L'autre catégorie de friction pré-soumission qui surprend les équipes lors du QA final.