Swift 6 вышел чуть больше года назад, однако iOS-сообщество не спешит поднимать версию в коммерческих проектах. Да и среди энтузиастов на Хабре не так много тех, кто готов поделиться практическим опытом миграции. Поэтому остаётся пробовать самому. Проверим, насколько сложно будет поднять версию для простого приложения на SwiftUI.
1. Идея
Перед тем как мы начнем стучать по клавишам и разбирать ошибки, нужно понять, какую основную проблему пытались решить ребята в Apple, выпуская Swift 6.
Я понял следующим образом. Теперь, если компилятор видит возможность возникновения data race — ситуации, когда разные потоки могут получить доступ к одному и тому же участку памяти одновременно, — он сообщает об ошибке и не позволяет запустить проект. Таким образом, разработчики Swift решили лишить нас возможности ловить ошибки, связанные с некорректным состоянием данных, в рантайме.
Swift 6 - это попытка решить проблему небезопасной конкурентности и скрытых data race'ов на уровне компилятора, а не в рантайме.
2. Отправная точка
Внизу статьи есть ссылка на репозиторий с кодом небольшого приложения. Предлагаю скачать и действовать параллельно статье. У репозитория 3 ветки, начнем с ветки main.
Что видит пользователь?
Приложение с одним экраном, на котором отображается кнопка и текст с анекдотом на английском языке. При нажатии на кнопку показывается следующий анекдот. Когда текущая подборка анекдотов заканчивается, приложение загружает новую.
Что видим мы?
MVVM-подход, SwiftUI, Published-свойства во вьюмодели, пара сервисов для обработки запроса (NetworkService реализует запрос с помощью URLSession, JokeService отвечает за парсинг JSON-ответов).
Ничего особенного в этом коде не вижу, поэтому если у вас возникнут вопросы по текущей реализации, напишите в комментарии. Едем дальше.
P.S. Надеюсь, вы уже запустили проект на своем маке и удивились чувству юмора британцев, как это сделал я.
3. Навстречу трендам, добавляем async/await
Изначально я хотел сразу поднять версию языка до Swift 6, но позже пришел к решению полностью довериться Xcode и умным коллегам из Apple, поэтому взялся писать актуальный код. Да и желание попробовать async/await на реальном (хоть и пет-проекте) возникло довольно давно.
Начнем с сервиса, который отвечает за запросы. Сейчас сигнатура метода вызова запроса выглядит следующим образом:
protocol INetwork {
func fetch(url: URL, handler: @escaping (Result<Data, Error>) -> Void)
}
Перепишем ее с учетом async/await:
protocol INetwork {
func fetch(url: URL) async throws -> Data
}
Как можно заметить, мы отказываемся от замыкания. Несмотря на то, что передача @escaping замыканий в параметрах асинхронных методов - база iOS-разработки, async/await предлагает приятную альтернативу с наиболее читаемой сигнатурой. Достаточно указать перед возвращаемым типом два ключевых слова:
async- метод асинхронныйthrows- метод может выбросить ошибку
Обновляем реализацию в в самом сервисе.
Было:
struct NetworkService: INetwork {
func fetch(url: URL, handler: @escaping (Result<Data, Error>) -> Void) {
let request = URLRequest(url: url)
let task = URLSession.shared.dataTask(with: request) { data, response, error in
if let error {
handler(.failure(error))
return
}
if let response = response as? HTTPURLResponse,
response.statusCode < 200 || response.statusCode >= 300 {
handler(.failure(NetworkError.responseError(response.statusCode)))
return
}
guard let data else { return }
handler(.success(data))
}
task.resume()
}
}
Стало:
struct NetworkService: INetwork {
func fetch(url: URL) async throws -> Data {
let (data, response) = try await URLSession.shared.data(from: url)
if let response = response as? HTTPURLResponse,
!(200..<300).contains(response.statusCode) {
throw NetworkError.responseError(response.statusCode)
}
return data
}
}
Вместе с async/await Apple добавила и асинхронные версии методов URLSession. Конструкция try await читается почти как обычный английский язык — «попробуй дождаться данных». При этом она сразу учитывает возможность ошибки: если что-то пойдёт не так, ошибка автоматически пробрасывается дальше по цепочке вызовов. Отдельно передавать её не требуется — обработка выполняется на том уровне, где это действительно имеет смысл. Красота.
Перейдем к сервису JokesService, который обрабатывает запросы и создает модели для отображения, и сделаем с ним примерно те же изменения. Начнем с метода getJoke.
P.S. Если вы давно хотели попробовать async/await, не спешите смотреть обновлённую реализацию — попробуйте сначала реализовать её самостоятельно.
Было:
func getJoke(handler: @escaping (Result<JokeModel, Error>) -> Void) {
networkService.fetch(url: jokeUrl) { result in
switch result {
case .success(let data):
if let decodedJoke = try? JSONDecoder().decode(JokeResponse.self, from: data) {
handler(.success(decodedJoke.asJokeModel()))
}
if let decodedJoke = try? JSONDecoder().decode(ComplexJokeResponse.self, from: data) {
handler(.success(decodedJoke.asJokeModel()))
}
case .failure:
handler(.failure(JokesError.noJokesAvailable))
}
}
}
Стало:
func getJoke() async throws -> JokeModel {
let data = try await networkService.fetch(url: jokeUrl)
if let decodedJoke = try? JSONDecoder().decode(JokeResponse.self, from: data) {
return decodedJoke.asJokeModel()
} else if let decodedJoke = try? JSONDecoder().decode(ComplexJokeResponse.self, from: data) {
return decodedJoke.asJokeModel()
} else {
throw JokesError.noJokesAvailable
}
}
Как и в теле метода fetch, вызов асинхронного метода требует прописать перед ним await. Затем мы пытаемся декодировать ответ и возвращаем готовую модель, если нас постигает неудача - пробрасываем ошибку декодирования.
Теперь перейдем к методу getJokes.
Было:
func getJokes(count: Int, handler: @escaping (Result<[JokeModel], Error>) -> Void) {
var jokes: [JokeModel] = []
let group = DispatchGroup()
for _ in 0..<count {
group.enter()
getJoke { result in
switch result {
case let .success(joke):
jokes.append(joke)
group.leave()
case .failure:
handler(.failure(JokesError.noJokesAvailable))
group.leave()
}
}
}
group.notify(queue: .main) {
handler(.success(jokes))
}
}
Стало:
func getJokes(count: Int) async throws -> [JokeModel] {
var jokes: [JokeModel] = []
for _ in 0..<count {
let joke = try await getJoke()
jokes.append(joke)
}
return jokes
}
Чтобы проект собрался, осталось совсем немного.
Во всех местах вызова асинхронных методов перед ними нужно проставить await.
Методы, которые имеют внутри себя асинхронный вызов, требуют проставить после круглых скобок
async.Пришло время обработать ошибку в методе
setJokes.
func setJokes() async {
do {
jokes = try await jokesService.getJokes(count: 5)
currentJoke = jokes.first
} catch {
self.error = error
}
}
И последнее: .onAppear и action у Button принимают только синхронные замыкания. Из-за этого прямой вызов acинхронного кода приводит к ошибке:
Invalid conversion from 'async' function of type '() async -> ()' to synchronous function type '() -> Void'
Чтобы обойти её, необходимо создать асинхронный контекст. Для этого достаточно обернуть вызов в Task (здесь подробнее о тасках), который позволяет безопасно запускать acинхронный код из синхронного UI-контекста.
.onAppear {
Task {
await viewModel.setJokes()
}
}
Теперь ваш проект должен собраться, если нет - сверьтесь с веткой dev.
4. Затягиваем гайки (поднимаем требования до Swift 6)
Немного углубимся в настройки.
Для этого найдите блок "Swift Complier - Concurrency" в Xcode - Targets - Build Settings.

Этот блок настроек отвечает за строгость проверки кода с точки зрения модели Swift Concurrency, которая окончательно закрепляется в Swift 6. Apple даёт возможность провести постепенный переход и повышать требования к безопасности кода поэтапно. Пройдемся по каждому пункту отдельно.
Approachable Concurrency. В режиме Yes принимается мягкий подход к ошибкам, часть проблем, связанных с конкурентностью могут отображаться как предупреждения и не ломать сборку проекта. В режиме No такие допущения не позволительны, компилятор выполняет строгие проверки и показывает ошибки там, где были предупреждения.
Default Actor Isolation. Эта настройка определяет, к какому актору по умолчанию относится код без явных аннотаций. В режиме MainActor весь неаннотированный код считается изолированным к MainActor, что упрощает работу с UI, но может скрывать реальные проблемы конкурентного доступа. В режиме nonisolated код не привязывается ни к одному актору, и компилятор строже проверяет возможные data races и нарушения изоляции.
Strict Concurrency Checking. Здесь настраивается уровень строгости проверки конкурентности.
Minimal - проверяются только основные ошибки
Targeted - дополнительно анализируется корректность использования async/await. Complete - включаются все доступные проверки, включая требования Sendable, actor isolation и корректность переходов между конкурентными контекстами.
Включаем проверки на полную мощность (вы можете попробовать разные конфигурации этих настроек).

Теперь, чтобы все это заработало нужно включить Swift 6 в Swift Complier - Language.

P.S. В коммерческих проектах задавать сразу строгие настройки и пытаться все исправить будет сомнительной затеей. Как сказано выше, Apple добавила эти настройки, чтобы мы могли мигрировать постепенно, поэтому будьте терпеливы при решении переводить объемные проекты на Swift 6.
5. Доводим до уровня
На самом деле, в нашем коде будет немного ошибок. Мы уже подготовили почву, используя async/await, и не заводили кучу зависимостей, как это бывает в настоящих проектах. Но все же разберемся с тем, что получили.
Первая ошибка будет в сцене.
.onAppear {
Task {
await viewModel.setJokes()
// Error: Sending 'self.viewModel' risks causing data races
}
}
Что же говорит нам компилятор?
Вызов viewModel.setJokes() потенциально может приводить к data race. В отличие от самой вью, которая работает в главном контексте, код внутри Task не гарантирует безопасный доступ к данным. Поэтому при обращении к ObservableObject из такой задачи Swift 6 не может доказать, что данные не будут изменяться параллельно в другом месте.
Решение простое — изолировать ViewModel с помощью @MainActor.
@MainActor
final class JokesViewModel: ObservableObject {
...
}
Похожая проблема возникает теперь в самой вьюмодели в методе setJokes().
func setJokes() async {
do {
jokes = try await jokesService.getJokes(count: 5)
currentJoke = jokes.first
} catch {
self.error = error
}
}
Здесь можно поспешить и попытаться решить проблему тем же способом, но это будет некорректно. Мы можем обозначать все классы @MainActor, чтобы избежать ошибок, и это будет работать. Но нужно помнить, что @MainActor в первую очередь создавался для работы с данными отображения. Поэтому обычно он используется только с вьюмоделями. Для остальных случаев коллеги из Apple ожидали увидеть использование actor'ов. Поэтому меняем final class на actor.
Заключение
Поздравляю!
В результате у вас получился полноценный проект, соответствующий стандартам Swift 6.
В рамках работы мы лишь слегка коснулись идей и принципов, лежащих в основе стандартов Swift 6, но этого уже достаточно, чтобы понять общее направление и подход к их реализации.
Если в процессе что-то пойдёт не так, вы всегда можете свериться с веткой future в этом же репозитории.
Дополнительно
В первоначальной реализации метода getJokes есть место ошибке. Значения после получения модели добавляются в массив небезопасно, то же касается и первеменной с ошибкой. Вы можете самостоятельно подумать, как избежать data race.
Обновленная реализация метода
getJokesна самом деле не идентична в своей механике первой. Методы в цикле вызываются последовательно, поскольку await блокирует поток пока не получит ответ от первого запроса. Чтобы реализовать параллельный вызов, используйтеwithThrowingTaskGroup(of:).БогИИ вам в помощь.Предлагаю попробовать изменить настройку Default Actor Isolation на MainActor на ветке
futureи самостоятельно справиться с проблемой. Один из вариантов решения даст возможность познакомиться с понятиемnonisolated.