iOS
February 21

Почему async/await лучше completion handler?

Всем привет, дорогие друзья!

Сегодня хотелось бы поговорить о преимуществах async/await, в сравнении с привычными нами способами работы с асинхронным кодом. async/await был представлен Apple на сессии WWDC 21, но к сожалению, не во всех проектах решаются перейти на данную технологию. Давайте узнаем как сделать наш код более читаемым, безопасным и надежным.

Что такое async/await?

async/await – это современный способ работы с асинхронным кодом в Swift. Он делает код более читаемым, безопасным и удобным, заменяя сложные вложенные completion handlers или DispatchGroup.

Механизм async/await основан на принципе структурированного параллелизма. Это означает, что асинхронный код выполняется последовательно в рамках своей области видимости, в отличие от замыканий, которые могут разрывать поток выполнения и усложнять его отслеживание.

Пример асинхронного запроса через completion handler:

 // Service Layer
 		func fetchState(callback: @escaping (Result<GameShakeStateDataResponseModel, NetworkError>) -> Void) {
        let router = GameShakeRequestFactory.getState
        networkService.performRequest(router: router) {(result: Result<GameShakeStateDataResponseModel, NetworkError>) in
            switch result {
            case .success(let model):
                callback(.success(model))
            case .failure(let error):
                callback(.failure(error))
            }
        }
    }
    
  // Presenter Layer
    func fetchState() {
        service.fetchState() { [weak self] result in
            guard let self else { return }
            switch result {
            case let .success(response):
                updateView(with: response.data)
            case let .failure(error):
                view?.showErrorToast(with: error.title)
            }
        }
    }

Если переписать код на async/await:

 // Service Layer 
 		func fetchState() async throws -> GameShakeStateDataResponseModel {
        try await networkService.performRequestAsync(router: GameShakeRequestFactory.getState)
    }
        
  // Presenter Layer
    func fetchState() {
        Task {
            do {
                let offer = try await service.fetchState()
                // handle updateView
            } catch {
                // handle error
            }
        }
    }

Основные составляющие кода:

  • async — объявляет асинхронную функцию;
  • await — приостанавливает выполнение функций до завершения задачи;
  • throws — указывает, что функция может выбрасывать ошибки;
  • Task — запускает асинхронную операцию в синхронном коде.

Читаемость и простота кода

Что хорошо во вложенности в async/await- это то что ее нет.

В то время как в completion handler одна из главных проблем — глубокая вложенность, также известная как callback hell. Когда одна асинхронная операция зависит от другой, completion вложены друг в друга, усложняя чтение и поддержку.

Пример:

fetchData { result in
    switch result {
    case .success(let data):
        process(data) { processedData in
            save(processedData) { success in
                if success {
                    print("Data saved")
                }
            }
        }
    case .failure(let error):
        print("Error: \\(error)")
    }
}

С async/await код остается линейным и чистым:

  • Отсутствует вложенность;
  • do-catch охватывает все ошибки.
Task {
    do {
        let data = try await fetchData()
        let processedData = try await process(data)
        try await save(processedData)
        print("Data saved")
    } catch {
        print("Error: \\(error)")
    }
}

В данном примере наглядно виден принцип структурированного параллелизма. Код выполняется последовательно, строка за строкой. Мы можем ожидать асинхронную функцию до тех пор, пока она не вернет результат (или не выдаст ошибку) и не передаст его следующей функции.

Безопасность потоков

При использовании async/await, большинство асинхронных операций (сетевые запросы, вычисления и т.д) выполняются в фоновом потоке. Однако при необходимости обновления UI, вам нужно явно указать переход на главный поток. Иначе это может привести к сбою приложения. Так как вы пытаетесь изменить интерфейс из фонового потока.

  	func fetchState() async throws -> GameShakeStateDataResponseModel {
        try await networkService.performRequestAsync(router: GameShakeRequestFactory.getState)
    }
        
    func fetchState() {
        Task {
            do {
                let offer = try await service.fetchState()
                self.view?.updateUI(with: offer) // CRASH!!! (Background thread)
            } catch {
                // handle error
            }
        }
    }

Использование @MainActor гарантирует безопасность при работе с асинхронным кодом. Что также помогает вам избежать проблем с потоками.

func fetchState() {
        Task {
            do {
                let offer = try await service.fetchState()
                await MainActor.run {
                   view?.updateUI(with: offer)
                }
            } catch {
                // handle error
            }
        }
 }

Обработка ошибок

В completion handler ошибки передаются через Result<T, Error> или отдельный параметр Error?. Однако, если забыть вызвать completion, вызывающий код может зависнуть. Это приведет к бесконечной загрузке счетчика или неопределенному состоянию пользовательского интерфейса.

В async/await ошибки всегда требуют обработки:

  • Если функция объявлена как throws, компилятор не позволит пропустить обработку;
  • Исключение сразу указывает на источник проблемы.

Параллельность и производительность

async/await в Swift работает быстрее, чем старые подходы (замыкания, DispatchGroup, OperationQueue), потому как эффективнее управляет потоками и использует кооперативную многозадачность.

  • Нет необходимости вручную управлять потоками или задачами, что предотвращает создание лишних потоков;
  • Обеспечена параллельная работа задач без блокировки главного потока. Например, когда операция ожидает завершения при запросах в сети или в работе с диском, остальные операции могут продолжать выполняться;
  • Минимизированы накладные расходы на переключение контекста.

Говоря о параллельном выполнений задач, async/await предлагает ряд инструментов (async let, TaskGroup,Task.detached). В данной статье мы остановимся на async let , в сравнений с DispatchGroup.

Вспомним как выглядит код с DispatchGroup:

    func fetchOffers() {
        let dispatchGroup = DispatchGroup()
        dispatchGroup.enter()
        forYouService.fetch() { [weak self] result in
            // ...
            dispatchGroup.leave()
        }
        dispatchGroup.enter()
        loyaltyStateService.fetchLoyaltyState() { [weak self] result in
            // ...
            dispatchGroup.leave()
        }
        
        dispatchGroup.enter()
        gameShakeService.fetchGameRules { [weak self] result in
             // ...
		         dispatchGroup.leave()
        }
        
        dispatchGroup.notify(queue: .main) { [weak self] in
            // handle result
        }
    }

DispatchGroup требует:

  • Явного вызова enter() и leave(), что потенциально повышает вероятность ошибок (например, забыв вызвать leave());
  • Отслеживания завершения через notify(), что добавляет дополнительные элементы синхронизации;
  • Ручной обработки ошибок в каждом замыкании.

async let — это способ параллельного выполнения асинхронных операций.

Он позволяет запустить несколько независимых задач одновременно и затем дождаться их завершения без блокировки. Пример кода:

 Task {
	 async let forYouData = fetchForYou()
   async let loyaltyStateData = fetchLoyaltyState()
   async let gameRulesData = fetchGameRules()

   let (forYouResponse, loyaltyStateResponse, gameRulesResponse) = await (forYouData, loyaltyStateData, gameRulesData)
   // handle result
 }

async let упрощает работу:

  • Синхронизация скрывается за механизмами асинхронных задач;
  • Система сама управляет потоками;
  • Ошибки можно обрабатывать централизованно через do-catch;
  • Код остается лаконичным и удобным для поддержки.

Итоги

Подводя итоги, хочется отметить следующие преимущества:

  • Мы получаем простой и линейный код, где отсутствуют вложенность;
  • Мы получаем асинхронный код, который выглядит как синхронный, за счет чего упрощается понимание и дебагинг кода;
  • У нас есть четкое разделение между потоками, что снижает количество возникаемых проблем с многопоточностью;
  • У нас есть надежная отладка ошибок;
  • Также async/await дает современные способы работы с параллельными задачами.

В результате мы получаем чистый, надежный и безопасный код.

Полезные ссылки:


Telegram-канал Теплица

Обратная связь