Swift 6 et la concurrence stricte : on a migré 23 apps
Quand Apple a annoncé Swift 6 avec le mode strict concurrency activé par défaut, on a eu deux réactions. D'abord : "enfin, les data races c'est fini". Ensuite : "putain, on a 23 apps Titan's Grip à migrer".
Spoiler : ça nous a pris 3 semaines. Et oui, on referait tout pareil. Voici pourquoi.
Le contexte : 23 apps de coaching sportif
Titan's Grip, c'est notre suite de 23 apps de coaching sportif avec IA. Chaque app cible un sport (boxe, judo, MMA, powerlifting...) et utilise la caméra pour analyser les mouvements en temps réel. Autant dire qu'on a de la concurrence partout : capture vidéo sur un thread, analyse IA sur un autre, UI sur le main thread, et sync iCloud en arrière-plan.
Avant Swift 6, on avait des @Sendable closures un peu partout, quelques actors pour les trucs critiques, et beaucoup de "ça marche, on touche pas". Le compilateur nous prévenait avec des warnings, on les ignorait royalement. Classique.
Ce que Swift 6 strict concurrency change vraiment
En deux mots : tous les warnings deviennent des erreurs. Le compilateur refuse de compiler si une valeur non-Sendable traverse une frontière d'isolation. Concrètement :
- Les classes mutables ne peuvent plus être passées entre actors sans être marquées
@Sendableou wrappées dans unactor - Les closures capturant du state mutable doivent explicitement être
@Sendable - Les propriétés globales mutables doivent vivre dans un actor ou être
nonisolated(unsafe) - @MainActor devient votre meilleur ami (et votre pire ennemi)
Dans nos 23 apps, on avait environ 1 200 warnings de concurrence. En activant strict mode, ça faisait 1 200 erreurs. Sympa.
Notre stratégie de migration
On a pas tout migré d'un coup. On a procédé en 4 phases :
Phase 1 : Le core partagé (AuraKit) — 3 jours
AuraKit, c'est notre framework interne partagé par les 23 apps. Il gère la capture vidéo, l'analyse IA (via CoreML), la persistence SwiftData, et la sync iCloud. C'est là qu'on avait le plus de concurrence, donc le plus de casse.
Le plus gros chantier : transformer notre VideoAnalyzer class en actor. Avant :
class VideoAnalyzer {
var currentFrame: CMSampleBuffer?
var isProcessing = false
func analyze(_ buffer: CMSampleBuffer) async throws -> [Pose] {
isProcessing = true
// ... data race potentielle
}
}
Après :
actor VideoAnalyzer {
private var currentFrame: CMSampleBuffer?
private var isProcessing = false
func analyze(_ buffer: CMSampleBuffer) async throws -> [Pose] {
isProcessing = true
// ... plus de data race possible
}
}
Simple en apparence, mais ça cascade : tout code qui appelait analyzer.isProcessing sans await devait être refactoré.
Phase 2 : Les ViewModels — 4 jours
Nos ViewModels utilisent le pattern @Observable @MainActor. La plupart étaient déjà corrects, mais on avait des cas où un ViewModel accédait à un service non-isolé. La solution : marquer les services critiques comme @MainActor quand ils ne font que du UI-related work, ou les transformer en actors quand ils font du background work.
Le piège classique qu'on a rencontré 47 fois :
// ERREUR Swift 6 : Capture of non-Sendable 'self'
@MainActor
class TrainingViewModel: Observable {
func startSession() {
Task {
let result = await analyzer.process() // analyzer pas Sendable !
}
}
}
Phase 3 : Les 23 apps individuelles — 8 jours
Chaque app a ses spécificités. L'app de boxe utilise des timers pour les rounds, l'app de powerlifting track les sets/reps avec haptics, l'app de bouldering gère du ARKit. On a traité chaque app individuellement, en commençant par celles qui avaient le moins de warnings.
Astuce qui nous a sauvé la vie : le flag -strict-concurrency=targeted permet de migrer module par module au lieu de tout activer d'un coup. On l'a utilisé pendant la transition avant de passer en complete.
Phase 4 : Les tests — 6 jours
C'est là qu'on a découvert le plus de vrais bugs. Swift 6 nous a forcé à repenser nos tests async, et dans le process on a trouvé 3 vraies data races qu'on avait en production depuis des mois. Dont une qui causait un crash intermittent sur l'app de MMA que nos utilisateurs reportaient depuis novembre.
Les patterns qui marchent
Après 23 migrations, voici les patterns qu'on recommande :
1. @MainActor par défaut pour tout ce qui touche l'UI
Marquez vos ViewModels, vos Views, et vos UI services comme @MainActor. C'est le chemin de moindre résistance et c'est ce qu'Apple recommande dans les sessions WWDC 2025.
2. Actors pour les services avec état mutable partagé
Si votre service a du state mutable et qu'il est accédé depuis plusieurs contextes, c'est un actor. Point. Pas une classe avec un lock, pas une queue, un actor.
3. Structs Sendable pour les données qui transitent
Vos modèles de données qui passent entre actors doivent être des structs (donc automatiquement Sendable) ou des classes immutables marquées Sendable. On a converti 80% de nos classes de données en structs pendant la migration.
4. nonisolated(unsafe) uniquement pour les singletons legacy
On a quelques singletons legacy (analytics, logging) qu'on a pas pu migrer proprement. nonisolated(unsafe) c'est OK pour ces cas, mais si vous avez plus de 5 occurrences dans votre projet, vous faites probablement un truc mal.
Les chiffres
Voici le bilan concret de notre migration :
- 1 247 erreurs de concurrence corrigées au total
- 3 data races réelles découvertes et corrigées (dont 1 crash en prod)
- 23 apps migrées en 21 jours ouvrés
- 0 régression sur TestFlight après migration
- -15% de crashes sur les 2 semaines suivant le déploiement
- 47 occurrences du pattern "capture of non-Sendable self"
Le temps investi est largement rentabilisé par la stabilité gagnée. Nos apps de coaching sportif tournent en temps réel avec caméra + IA + UI, et depuis la migration on a zéro crash lié à la concurrence.
Ce qu'on aurait fait différemment
Avec le recul :
- Migrer AuraKit en premier était le bon call. Ça a résolu 60% des erreurs dans les apps d'un coup.
- On aurait dû activer les warnings plus tôt. On les ignorait depuis Swift 5.10, grosse erreur. Si vous êtes encore en Swift 5, activez
-strict-concurrency=targetedmaintenant. - Les tests auraient dû être la phase 1, pas la phase 4. C'est dans les tests qu'on a trouvé les vrais bugs.
Faut-il migrer maintenant ?
Oui. Sans hésiter. Même si c'est douloureux. Swift 6 ne va pas devenir optionnel — au contraire, Apple va continuer à resserrer la vis. Plus vous attendez, plus vous accumulez de dette technique.
Et surtout : les data races, c'est le genre de bug qui apparaît en prod mais jamais en test. Swift 6 les rend impossible à compiler. C'est un filet de sécurité que vous ne pouvez pas vous permettre d'ignorer.
Si vous avez des questions sur notre process de migration, on est sur Twitter/X. Et si vous voulez voir le résultat, nos apps Titan's Grip sont sur l'App Store.