Контекст: почему это больно

Swift 6 вышел в сентябре 2024 года и принёс одно большое изменение которое коснулось всех — строгая проверка конкурентности теперь включена по умолчанию. Если раньше вы могли передавать @MainActor-изолированные значения в фоновый поток и компилятор только предупреждал, теперь это ошибка компиляции.

В нашем проекте при первом переключении на Swift 6 mode мы получили 847 ошибок. Это звучит страшно, но на деле большинство из них — одна и та же проблема в разных местах.

Предупреждение: Не пытайтесь мигрировать весь проект разом. Лучше делать это модуль за модулем, начиная с листовых зависимостей.

Таксономия ошибок

Почти все ошибки которые мы встретили делятся на три категории:

Разбираемся с Sendable

Самая частая ошибка выглядит вот так:

// Ошибка: 'MyViewModel' не соответствует Sendable class MyViewModel: ObservableObject { var items: [Item] = [] } func loadData() async { let vm = MyViewModel() await someBackgroundTask(vm) // ← ошибка здесь }

Решений несколько. Если класс действительно должен быть доступен из разных потоков — делаем его @MainActor:

@MainActor class MyViewModel: ObservableObject { var items: [Item] = [] }

Если же логика в нём не привязана к UI, переходим на актор:

actor DataStore { private var items: [Item] = [] func add(_ item: Item) { items.append(item) } func getAll() -> [Item] { return items } }

MainActor и completion handlers

Второй по частоте тип проблем — старый код с completion handlers который обращается к UI без явного переключения на главный поток:

// Старый код — работал, но был небезопасен func fetchUsers(completion: @escaping ([User]) -> Void) { URLSession.shared.dataTask(with: url) { data, _, _ in let users = parse(data) completion(users) // вызывается на background thread }.resume() } // Вызов в ViewController fetchUsers { users in self.tableView.reloadData() // ← Swift 6: ошибка }

Правильное решение — мигрировать на async/await и явно аннотировать контекст:

func fetchUsers() async throws -> [User] { let (data, _) = try await URLSession.shared.data(from: url) return try JSONDecoder().decode([User].self, from: data) } // В ViewController (@MainActor автоматически) func loadData() { Task { let users = try await fetchUsers() tableView.reloadData() // всё хорошо, мы на MainActor } }

Стратегия миграции

Мы использовали следующий подход:

  1. Включили Swift 6 mode только для одного модуля — самого маленького листового
  2. Исправили все ошибки в нём, не трогая остальные
  3. Поднялись по дереву зависимостей вверх
  4. На каждом шаге делали PR и ждали зелёного CI
Совет: Используйте @preconcurrency import для сторонних библиотек которые ещё не обновились под Swift 6. Это заглушит ошибки Sendable для конкретного модуля.

Итог

Миграция заняла около трёх спринтов. 847 ошибок → 0. Основной вывод: большинство проблем — это либо добавить @MainActor, либо сделать тип Sendable, либо переписать callback на async. Ничего принципиально сложного, просто много механической работы.

Зато теперь у нас нет целого класса багов связанных с гонками данных, и компилятор страхует нас от их появления в будущем.