Swift 6 Concurrency: Ein praktischer Migrationsleitfaden
Swift 5 ging nachsichtig mit Data Races um. Der Compiler warnte vor einigen und ignorierte andere. Dadurch etablierten sich in Codebasen Gewohnheiten – etwa DispatchQueue.main.async an manchen Stellen aufzurufen und an anderen zu vergessen –, die funktionierten, bis sie es nicht mehr taten. Swift 6 ändert die Regeln: Alle Warnungen zu Actor-Isolation und Sendable-Konformität werden zu Fehlern. Der Build wird erst fortgesetzt, wenn das Risiko eines Data Races beseitigt oder ausdrücklich anerkannt wurde.
Tatsächlich verbergen sich in den meisten bestehenden SwiftUI-Apps keine katastrophalen Data Races. Vielmehr verwenden sie Muster, die sich in der Praxis als sicher erwiesen haben, nach dem Concurrency-Modell von Swift aber technisch nicht korrekt sind. Bei der Migration geht es weniger darum, tatsächliche Fehler zu beheben, als darum, die Sicherheitseigenschaften des Codes für den Compiler nachvollziehbar zu machen. Das ist sinnvoll, setzt aber voraus, dass Sie verstehen, was die Fehlermeldungen tatsächlich aussagen.
Strikte Concurrency-Prüfung aktivieren
Bevor Sie auf den Swift-6-Sprachmodus umstellen, sollten Sie sich die Fehler zunächst als Warnungen anzeigen lassen. Setzen Sie dazu in Xcode die Swift-Compiler-Einstellung SWIFT_STRICT_CONCURRENCY für Ihr Target auf complete. So sehen Sie alles, was Swift 6 ablehnen würde, ohne dass der Build fehlschlägt. Arbeiten Sie die Warnungen systematisch ab, bevor Sie die Sprachversion ändern.
Um den Swift-6-Sprachmodus in den Build Settings Ihres Targets zu aktivieren, setzen Sie SWIFT_VERSION auf 6. Damit gilt automatisch die strikte Concurrency-Prüfung mit complete, und alle entsprechenden Warnungen werden zu Fehlern. In einem Projekt mit mehreren Paketen erfolgt die Umstellung am besten Target für Target, beginnend mit den Paketen mit den wenigsten UI-Abhängigkeiten: zuerst reine Logik- und Netzwerkpakete, zuletzt UI-Pakete.
Das Problem der MainActor-Isolationsgrenze
Die häufigste Fehlerklasse bei der Migration von SwiftUI-Code sieht so aus:
// 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-Eigenschaften eines ObservableObject sind nicht automatisch durch @MainActor isoliert, werden von SwiftUI aber auf dem Hauptthread beobachtet. Wenn ein Task {} auf einem nicht festgelegten Executor läuft und versucht, in items zu schreiben, erkennt Swift 6 das mögliche Data Race. Bei der Klasse genau in der gezeigten Form meldet der Swift-6-Compiler ein Data-Race-Risiko für die Task-Closure, die self erfasst; die Formulierung im Kommentar zur Main-Actor-Isolierung erscheint erst, wenn items selbst mit @MainActor markiert ist.
Die sauberste Lösung besteht darin, die gesamte Klasse mit @MainActor zu annotieren:
@MainActor
class DataStore: ObservableObject {
@Published var items: [Item] = []
func fetch() async {
let result = await API.load()
items = result // ✓ — entire class is MainActor-isolated
}
}
Dadurch wird die Isolation der Klasse explizit. Aufrufer, die fetch() aus einem Hintergrundkontext aufrufen, müssen await verwenden. Das ist korrekt: Um mit einer Klasse zu interagieren, die UI-Aktualisierungen steuert, sollten sie auf dem Main Actor laufen. Wenn Sie nicht die gesamte Klasse annotieren können, wechseln Sie stattdessen ausdrücklich zum Main Actor:
func fetch() {
Task {
let result = await API.load()
await MainActor.run {
self.items = result
}
}
}
Beide Ansätze sind gültig. Die Klassenannotation @MainActor ist übersichtlicher, wenn die Klasse hauptsächlich SwiftUI-Views steuert – was in einer gut strukturierten App bei ObservableObject-Klassen fast immer der Fall ist.
PhotosPicker und Delegate-Isolation
PHPickerViewControllerDelegate ist eine häufige Ursache für Swift-6-Fehler. Die Delegate-Methode selbst ist in aktuellen SDKs auf den Main Actor isoliert, doch der darin verwendete Lade-Callback des Item Providers wird auf einer Hintergrundwarteschlange ausgeführt, und der Code in diesem Callback muss meist die UI aktualisieren. Das klassische Swift-5-Muster:
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 ist die an loadObject übergebene Closure @Sendable. Sie darf daher self nicht erfassen, wenn self nicht Sendable ist. Ein UIViewController erfüllt diese Anforderung, da auf den Main Actor isolierte Klassen implizit Sendable sind; der eigentliche Fehler ist der Zugriff auf imageView.image, eine durch @MainActor isolierte Eigenschaft, aus einem Hintergrundkontext.
Die Lösung besteht darin, innerhalb der Closure zum Main Actor zu wechseln:
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
}
}
Die Syntax Task { @MainActor in } erzeugt einen Task, der ausdrücklich auf den Main Actor isoliert ist. Das Erfassen von image ist zulässig, weil UIImage dem Protokoll Sendable entspricht. Auch das Erfassen von self über [weak self] ist zulässig, da ein View Controller auf den Main Actor isoliert und damit Sendable ist; weak vermeidet lediglich einen Referenzzyklus und würde das Erfassen eines nicht Sendable-konformen Typs nicht sicher machen.
Bei SwiftUIs PhotosPicker, der neueren API, ist das Muster übersichtlicher, weil sie Transferable verwendet:
// 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)
}
}
Da der Aufruf von loadTransferable async ist, warten Sie mit await darauf. Die Zuweisung erfolgt auf dem Actor, auf den die Funktion isoliert ist – also auf @MainActor, wenn das View Model diese Annotation trägt. Ein manueller Actor-Wechsel ist nicht nötig.
Sendable-Typen und nicht sendbare Typen
Ein Typ ist Sendable, wenn er sicher über Actor-Isolationsgrenzen hinweg übertragen werden kann – vereinfacht gesagt, wenn er ein Werttyp ist oder eine Klasse, die ihren Zustand durch Synchronisierung schützt. Swift 6 prüft dies überall dort, wo ein Wert eine Isolationsgrenze überschreitet.
Das häufigste Fehlermuster:
// 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
}
Die Lösung ist meist unkompliziert: Lassen Sie den Werttyp dem Protokoll Sendable entsprechen:
struct RequestConfig: Sendable {
var endpoint: String
var headers: [String: String]
var body: Data?
}
Bei Structs, deren Mitglieder alle Sendable sind – darunter String, Data und alle Standardwerttypen –, ist diese Anforderung ohne Weiteres erfüllt. Der Compiler leitet die Konformität für nicht öffentliche Structs implizit ab. In der Praxis tritt dieser Fehler daher bei public-Typen auf, die aus einem anderen Modul verwendet werden; eine explizite Deklaration mit : Sendable wird vom Compiler geprüft. Bei Klassen ist es komplexer: Der Compiler kann die Sendable-Konformität nur für eine final-Klasse prüfen, deren gespeicherte Eigenschaften ausschließlich let-Konstanten von Sendable-Typen sind, oder für eine Klasse, die auf einen globalen Actor wie @MainActor isoliert ist. Eine Klasse, die veränderbaren Zustand durch eigene interne Synchronisierung schützt, benötigt @unchecked Sendable, da der Compiler die Sperrmechanismen nicht überprüfen kann. Verwenden Sie für ein ObservableObject, auf das stets über den Main Actor zugegriffen wird, vorzugsweise @MainActor: Dadurch wird es automatisch Sendable. Betrachten Sie @unchecked Sendable als letztes Mittel, nicht als erste Reaktion.
SharedDataProvider und gemeinsamer Zustand mehrerer View Models
Ein verbreitetes Muster in größeren SwiftUI-Apps ist ein gemeinsam genutzter Datenanbieter, der über das Environment in mehrere Views eingebunden wird. In Swift 6 benötigt dieses Objekt eine explizite Actor-Isolation, weil View-Bodies darauf zugreifen und auf dem Main Actor ausgeführt werden:
@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
}
}
Die Annotation @MainActor erfüllt hier eine konkrete Aufgabe: Sie stellt sicher, dass alle Zugriffe auf currentUser und sessionToken über den Main Actor serialisiert werden. Das ist die richtige Isolation für Eigenschaften, die SwiftUI-View-Aktualisierungen steuern. Hintergrundtasks, die in diese Klasse schreiben müssen, verwenden await. Auch das ist korrekt: Sie sollten warten, bis der Main Actor verfügbar ist, statt gleichzeitig zu schreiben.
Die Besonderheit ist, dass auf static let shared in einer @MainActor-Klasse nur vom Main Actor aus zugegriffen werden kann. Versucht ein Codepfad, aus einem Hintergrundkontext auf SharedDataProvider.shared zuzugreifen, lehnt Swift 6 dies ab. Die Lösung ist entweder, die Instanz explizit entlang der Aufrufkette weiterzureichen (für den Zugriff auf ihre Eigenschaften ist außerhalb des Main Actors weiterhin await erforderlich), oder den Zugriff in await MainActor.run { ... } einzuschließen.
Der Ausweg mit @unchecked Sendable
Manche Klassen lassen sich in der Praxis nicht vollständig Sendable machen: Sie kapseln möglicherweise C-Bibliotheken, binden Objective-C-Typen an oder verwalten internen Zustand sicher, aber für den Compiler nicht nachvollziehbar. In solchen Fällen sagt @unchecked Sendable dem Compiler: „Vertraue mir, ich habe die Synchronisierung sichergestellt“:
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 }
}
}
Dies ist keine Abkürzung, um die Migration zu umgehen. Es ist eine ausdrückliche Erklärung, dass Sie die Concurrency-Semantik geprüft haben und die Verantwortung dafür übernehmen. Verwenden Sie die Annotation für Klassen, die tatsächlich interne Synchronisierung benötigen, und erläutern Sie in einem Kommentar, welcher Mechanismus die Sicherheit gewährleistet.
Eine praktische Migrationsreihenfolge
Aktivieren Sie zunächst SWIFT_STRICT_CONCURRENCY = complete und beheben Sie alle Warnungen, bevor Sie auf den Swift-6-Sprachmodus umstellen. Beginnen Sie mit den Blattpaketen im Abhängigkeitsgraphen – also den Paketen ohne Abhängigkeiten von anderen eigenen Modulen. Korrigieren Sie diese, stellen Sie sie auf den Swift-6-Sprachmodus um und gehen Sie dann zur nächsten Ebene über. So vermeiden Sie, dass die Korrektur eines abhängigen Pakets gleichzeitig Änderungen an einem vorgelagerten Paket erfordert, das noch im Warnungsmodus läuft.
Der größte Teil der Migration einer typischen SwiftUI-Codebasis besteht aus @MainActor-Annotationen für ObservableObject-Klassen, Sendable-Konformität für Werttypen, die über Task-Grenzen hinweg übergeben werden, und expliziten Actor-Wechseln in Delegate-Callbacks. Im Diff wirkt der Umfang groß, doch jede einzelne Änderung folgt einem klaren Muster. Der Compiler führt Sie zu jeder Stelle, die Aufmerksamkeit benötigt – genau das ist der Zweck der strikten Concurrency-Prüfung.
Weiterführende Beiträge
- Eine App zur Prüfungsvorbereitung mit Tausenden von Fragen entwickeln – Die SPM-Architektur mit mehreren Paketen, in der diese Isolationsgrenzen besonders relevant werden.
- Vom SwiftUI-Konzept zum App Store in wenigen Wochen – Wann Sie die Swift-6-Migration während einer zügigen Funktionsentwicklung verschieben sollten.
- App-Store-Ablehnungen wegen des App-Icons beheben – Die andere Art von Hürden vor der Einreichung, die Teams bei der abschließenden Qualitätssicherung aufhält.