В предыдущей части мы разобрали преимущества работы с async/await по сравнению с GCD. В этой части мы более подробно рассмотрим ключевые слова async и await (и не только). Разберемся в том, как они работают, что означает "неблокирующее ожидание" и самое главное рассмотрим это все на примерах.
Статьи из серии
Swift async/await. Чем он лучше GCD?
Swift async/await на примерах
Оглавление
Что такое swift async/await
Примеры
Async/await. Http запрос
Async computed property. Загрузка изображения
Async let. Одновременная загрузка двух изображений
AsyncSequence. Отображение процента загрузки изображения
AsyncStream. Перенос логики загрузки изображения
Итоги
Полезные ссылки
Что такое swift async/await
Swift async/await - это новая возможность, добавленная в Swift 5.5, которая расширяет функциональность языка путем введения асинхронных функций. Основная особенность асинхронных функций заключается в том, что они могут приостанавливаться без блокировки текущего потока. Вместо блокировки функция передает управление системе, которая решает, чем далее занять поток(и). Таким образом, достигается неблокирующее ожидание.
Примеры
В примерах, которые будут приведены далее, я буду использовать сервис jsonplaceholder. Он предоставляет API для тестирования. С помощью этого сервиса можно отправлять различные запросы и получать шаблонные данные.
Async/await. Http запрос
Первым делом давайте выполним обычный http запрос:
// 1
struct Photo: Decodable {
let albumId: Int
let id: Int
let title: String
let url: URL
let thumbnailUrl: URL
}
// 2
func getPhotos(by albumId: Int) async throws -> [Photo] {
// 3
let url = URL(string: "https://jsonplaceholder.typicode.com/albums/\(albumId)/photos")!
let request = URLRequest(url: url)
// 4
let (data, _) = try await URLSession.shared.data(for: request)
// 5
let photos = try JSONDecoder().decode([Photo].self, from: data)
return photos
}
Создаем decodable структуру, в которую мы будем преобразовывать полученный JSON.
Создаем функцию, которая будет запрашивать массив объектов типа Photo по id альбома с сервера. Помечаем нашу функцию ключевым словом
async
. Этим мы сообщаем компилятору, что наша функция потенциально может иметь suspension points (точки приостановки). Грубо говоря, пометив функцию какasync
, мы можем вызывать внутри этой функции другиеasync
функции с помощьюawait
. Также помечаем нашу функцию ключевым словомthrows
, что позволит языковыми конструкциямиdo/try/catch
обрабатывать ошибки, вызывая нашу функцию.Формируем GET запрос к серверу.
Отправляем наш запрос с помощью нового асинхронного метода URLSession.data. Чтобы вызвать асинхронную функцию и получить результат в том же месте, воспользуемся ключевым словом
await
. Метод возвращает кортеж(Data, URLResponse)
. В идеале, стоило бы проверитьresponse
на наличие ошибок, но для компактности и простоты я пропущу этот шаг.Преобразуем полученный JSON в массив структур и вернем полученный результат из функции.
Остановимся чуть подробней на пунктах 2 и 4. Вызывая async
функцию у URLSession
, мы приостанавливаем выполнение нашей функции. Во время приостановки мы не блокируем текущий поток. Вместо этого система нагружает его другими полезными задачами. Система продолжит выполнение getPhotos
, когда метод у URLSession
завершит свое выполнение. Функция getPhotos
не может быть синхронной, так как она должна иметь возможность приостанавливаться и возобновляться. Из-за этого требуется помечать такие функции как async
. Благодаря этому ключевому слову компилятор знает, что функция работает с асинхронным контекстом.
Можно провести аналогию с throws
функциями. Если наша функция может выбросить ошибку, то ее нужно пометить как throws
. Вызывать throws
функции можно только с помощью try
. Это строгие правила языка. Такие же и у ключевых слов async/await
.
С помощью ключевого слова await
мы (и компилятор в нашем лице) разделяем нашу функцию на части (partials). Функция может быть приостановлена между этими partials. Сами по себе они выполняются без прерываний. Система сама планирует и выполняет эти части в определенном порядке.
func getPhotos(by albumId: Int) async throws -> [Photo] {
let url = URL(string: "https://jsonplaceholder.typicode.com/albums/\(albumId)/photos")!
let request = URLRequest(url: url)
let (data, _) = try await URLSession.shared.data(for: request)
// --------------
let photos = try JSONDecoder().decode([Photo].self, from: data)
return photos
}
Хоть визуально код до и после await располагается на соседних строчках, в действительности код после await
может быть вызван через длительное время, особенно при работе с сетевыми запросами. Кроме того, он может продолжить выполнение вообще на другом потоке. Об этом важно помнить при работе с UI.
Async computed property. Загрузка изображения
Ключевым словом async можно помечать так же замыкания, инициализаторы и вычисляемые свойства (computed property). В этом примере мы воспользуемся асинхронным вычисляемым свойством. На его основе реализуем простой класс для загрузки изображений.
class ImageLoader {
private let imageUrl: URL
// 1
init(imageUrl: URL) {
self.imageUrl = imageUrl
}
// 2
var image: UIImage? {
// 3
get async throws {
// 4
let (data, _) = try await URLSession.shared.data(from: imageUrl)
// 5
return UIImage(data: data)
}
}
}
В инициализаторе ожидаем URL по которому в дальнейшем будем загружать изображение.
Изображение будем загружать через вызов computed property.
Помечаем геттер ключевым словом
async
. Это позволит вызывать внутри другиеasync
функции. Еще одно новшество, не относящееся к async/await, заключается в том, что геттер теперь может быть throws (выкидывать ошибки).Вызываем уже знакомый нам метод у URLSession. Только теперь передаем ему не
URLRequest
, а просто URL.Преобразовываем
Data
вUIImage
и сразу возвращаем ее.
Стоит так же отметить, что сеттер не может быть async
, это работает только с геттером. При асинхронном геттере компилятор не даст создать даже обычный (не асинхронный) сеттер. Аналогичная ситуация возникает и с ключевым словом throws
. Если пометить им геттер, то сеттер создать вообще не получится.
Давайте напишем небольшой контроллер и воспользуемся функцией из нашего примера для загрузки данных для изображений и нашим новым объектом чтоб загрузить изображение.
class ViewController: UIViewController {
private let imageView: UIImageView = {
let view = UIImageView()
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
// 1
func getPhotos(by albumId: Int) async throws -> [Photo] {
let url = URL(string: "https://jsonplaceholder.typicode.com/albums/\(albumId)/photos")!
let request = URLRequest(url: url)
let (data, _) = try await URLSession.shared.data(for: request)
let photos = try JSONDecoder().decode([Photo].self, from: data)
return photos
}
override func viewDidLoad() {
super.viewDidLoad()
fillView()
// 2
Task {
do {
// 3
let photos = try await getPhotos(by: 1)
// 4
let imageLoader = ImageLoader(imageUrl: photos[0].url)
imageView.image = try await imageLoader.image
} catch {
// 5
print(error.localizedDescription)
}
}
}
private func fillView() {
view.addSubview(imageView)
NSLayoutConstraint.activate([
imageView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
imageView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
imageView.heightAnchor.constraint(equalToConstant: 248),
imageView.widthAnchor.constraint(equalToConstant: 248),
])
}
}
Функция из первого примера
Метод viewDidLoad - синхронный. Мы не можем использовать
await
для ожидания выполнения асинхронных функций внутри синхронного метода. Для этого нужно использовать новую сущность -Task
. Мы поговорим про нее подробнее в следующих частях. Сейчас же стоит знать, что если мы хотим вызватьasync
функцию из синхронной, то вызов нужно осуществлять внутриTask
.Получаем массив данных для фотографий с помощью нашего метода.
С помощью новой сущности и его async computed property
image
загружаем изображение и в этой же строчке добавляем его нашейimageView
.С помощью конструкции
do/try/catch
мы можем обработать сразу несколькоthrows
функций внутри одного блока. Если обработка ошибок не предусмотрена, то внутриTask
можно не использоватьdo/try/catch
, так как замыкание которое мы передаем вTask
может выбрасывать ошибки (помечено ключевым словомthrows
).
Внутри нашей таски мы вызываем асинхронный метод getPhotos
и с помощью await
ждем результата. Когда функция вернет массив [Photo]
, выполнение продолжится. После мы достаем URL первого изображения и создаем на его основе объект ImageLoader
. Далее так же ожидаем выполнения его асинхронного вычисляемого значения image
и в заключении присваиваем полученное изображение в imageView.image
.
Не забываем, что getPhotos
и ImageLoader
внутри себя так же вызывают асинхронные функции и приостанавливают свое выполнение ожидая результатов.
Async let. Одновременная загрузка двух изображений.
Немного видоизменим предыдущий пример. Будем загружать 2 разных изображения и рендерить из них одно. Поменяем код внутри Task
Task {
// 1
let photos = try await getPhotos(by: 1)
// 2
let firstImage = try await ImageLoader(imageUrl: photos[0].url).image
let secondImage = try await ImageLoader(imageUrl: photos[1].url).image
// 3
let size = firstImage?.size ?? .zero
let mergedImage = UIGraphicsImageRenderer(size: size).image { ctx in
let rect = CGRect(origin: .zero, size: size)
firstImage?.draw(in: rect)
secondImage?.draw(in: rect, blendMode: .normal, alpha: 0.5)
}
// 4
imageView.image = mergedImage
}
Как и в предыдущем примере, сначала получаем массив данных изображений.
Загружаем поочередно два изображения. Для этого просто беру url у первых двух элементов из массива.
Рендерим из них одно изображение.
Присваиваем полученный результат в
imageView
.
Все отлично работает, но есть один момент. Мы начинаем грузить первое изображение и ждем, пока оно загрузится с помощью await
. Только после того, как наше первое изображение загрузилось, мы начинаем загружать второе.
Можно ли загружать оба изображения одновременно? Как вы наверное уже догадались - да. Один из способов - воспользоваться новой конструкцией async let. Давайте поправим наш пример.
Task {
let photos = try await getPhotos(by: 1)
// 1
async let firstImageTask = ImageLoader(imageUrl: photos[0].url).image
async let secondImageTask = ImageLoader(imageUrl: photos[1].url).image
// 2
let firstImage = try await firstImageTask
let secondImage = try await secondImageTask
let size = firstImage?.size ?? .zero
let mergedImage = UIGraphicsImageRenderer(size: size).image { ctx in
let rect = CGRect(origin: .zero, size: size)
firstImage?.draw(in: rect)
secondImage?.draw(in: rect, blendMode: .normal, alpha: 0.5)
}
imageView.image = mergedImage
}
Теперь мы не await'им изображения, а с помощью
async let
создаем дочерние таски (про дочерние таски поговорим подробнее в одной из следующих частей). Дочерние задачи сразу отправляются на планирование в систему, и система запускает их при первой возможности. Наша функция не прерывается наasync let
, выполнение идет дальше после этой конструкции. Вызов асинхронной функции с помощью async let не является потенциальной точкой приостановки (suspension point).Ожидаем завершения задач, запущенных на первом шаге. В этом случае мы можем использовать
await
для ожидания их поочередно, так как вторая задача продолжает выполнение, пока мы ожидаем завершения первой.
Таска, созданная с помощью async let
, может завершиться до того, как мы решим получить из нее значение. В таком случае, при вызове await
, мы сразу же получим его.
Также здесь стоит упомянуть, что после ключевого слова await
в выражении можно указывать любое количество асинхронных функций. Например, ожидание загрузки двух изображений в нашем примере можно было записать одной строкой:
let (firstImage, secondImage) = try await (firstImageTask, secondImageTask)
Пользуясь конструкцией async let
можно столкнуться с некоторыми не совсем очевидными моментами:
Async let
нельзя захватывать вescaping
замыкания. Данное ограничение ввели по причине того, что структуры, с помощью которых под капотом реализуетсяasync let
, могут храниться на стеке. В таком случае логично предположить, чтоasync let
можно использовать в неescaping
замыканиях, но на момент написания статьи это делать тоже нельзя. Это баг компилятора, когда вы читаете эту статью он возможно уже поправлен, проверить можно тут.
func asyncFunction() async { ... }
func funcWithClosure(closure: () async -> Void) { ... }
func funcWithEscapingClosure(closure: @escaping () async -> Void) { ... }
async let task = asyncFunction()
// Такой вызов будет работать в одной из следующих версий языка
funcWithClosure {
await task // compile error: Capturing 'async let' variables is not supported
}
funcWithEscapingClosure {
await task // compile error: Capturing 'async let' variables is not supported
}
В
async
функции которые вызываются с помощьюasync let
нельзя передавать переменные (любых типов).
struct Person {
var age: Int
}
func printAge(for person: Person) async {
print(person.age)
}
var person = Person(age: 23)
async let increaseAgeTask = printAge(for: person) // compile error: Reference to captured var 'person' in concurrently-executing code
При вызове async let
переменная person
не копируется, а захватывается. Это происходит из-за того, что под капотом строка с async let
преобразуется в замыкание, в котором уже вызывается async
функция. Внутрь этого замыкания нельзя захватывать переменные. Этот запрет связан уже с предотвращением состояния гонки при работе в асинхронном контексте. Подробнее поговорим об этом в следующих частях. Для устранения ошибки компиляции в примере, достаточно заменить var
на let
.
AsyncSequence. Отображение процента загрузки изображения
AsyncSequence - это протокол, с помощью которого можно обрабатывать последовательности асинхронных элементов в цикле. Как реализовать свой объект, имплементирующий AsyncSequence
, поговорим чуть позже, а сейчас давайте воспользуемся объектом, который уже подписан на этот протокол.
Воспользуемся новой функцией URLSession.bytes (доступна только с 15 iOS), которая возвращает пару значений типа (URLSession.AsyncBytes, URLResponse)
. AsyncBytes
как раз и имплементирует протокол AsyncSequence
. С помощью этого объекта можно обрабатывать данные из запроса побайтово. Давайте дополним наш контроллер лейблом для отображения процента загрузки изображения и реализуем расчет этого процента с помощью новой функции.
class ViewController: UIViewController {
// 1
private let loadedPercentLabel: UILabel = {
let label = UILabel()
label.textColor = .white
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
private let imageView: UIImageView = {
let view = UIImageView()
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
func getPhotos(by albumId: Int) async throws -> [Photo] {
let url = URL(string: "https://jsonplaceholder.typicode.com/albums/\(albumId)/photos")!
let request = URLRequest(url: url)
let (data, _) = try await URLSession.shared.data(for: request)
let photos = try JSONDecoder().decode([Photo].self, from: data)
return photos
}
override func viewDidLoad() {
super.viewDidLoad()
fillView()
Task {
let photos = try await getPhotos(by: 1)
// 2
let (stream, response) = try await URLSession.shared.bytes(from: photos[0].url)
// 3
var bytes: [UInt8] = []
// 4
for try await byte in stream {
// 5
bytes.append(byte)
let currentPercent = Int(Double(bytes.count) / Double(response.expectedContentLength) * 100.0)
loadedPercentLabel.text = "\(currentPercent) %"
}
// 6
imageView.image = UIImage(data: Data(bytes))
}
}
private func fillView() {
view.backgroundColor = .black
view.addSubview(imageView)
view.addSubview(loadedPercentLabel)
NSLayoutConstraint.activate([
imageView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
imageView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
imageView.heightAnchor.constraint(equalToConstant: 248),
imageView.widthAnchor.constraint(equalToConstant: 248),
loadedPercentLabel.bottomAnchor.constraint(equalTo: imageView.topAnchor),
loadedPercentLabel.leadingAnchor.constraint(equalTo: imageView.leadingAnchor),
])
}
}
Пробежимся по всем изменениям контроллера:
Добавили лейбл в котором будем отображать процент загрузки.
Получаем пару значений типа
(URLSession.AsyncBytes, URLResponse)
с помощью нового асинхронного методаURLSession.bytes
.Создаем переменную, в которой будем хранить все полученные байты.
В цикле обрабатываем байты из
AsyncBytes
. Байты поступают по мере загрузки, поэтому приостанавливаемся каждый раз с помощьюawait
.При получении каждого последующего байта добавляем его в массив. Затем рассчитываем процент загруженных на данный момент байт относительно ожидаемого количества (которое приходит в
URLResponse.expectedContentLength
) и обновляем текст у лейбла.Из массива байт создаем
Data
, изData
создаемUIImage
и присваиваем вUIImageView.image
.
Каждая итерация for await _ in
цикла - это потенциальная точка приостановки (suspension point). Такой цикл можно представить как просто последовательный набор await
вызовов.
Чтобы загрузка не была моментальной, мы можем добавить небольшую задержку внутри цикла:
for try await byte in stream {
try await Task.sleep(nanoseconds: 10000)
bytes.append(byte)
let currentPercent = Int(Double(bytes.count) / Double(response.expectedContentLength) * 100.0)
loadedPercentLabel.text = "\(currentPercent) %"
}
И после этого можно будет увидеть следующий результат:
AsyncStream. Перенос логики загрузки изображения
В предыдущем примере мы реализовали загрузку изображения с отображением процента прямо в контроллере, хотя ранее мы инкапсулировали логику загрузки в специальный класс ImageLoader
. Давайте перенесем в этот класс логику загрузки с прогрессом, и заодно познакомимся с сущностью AsyncStream
.
С помощью AsyncStream
мы можем легко создавать собственные асинхронные последовательности, так как он подписывается под AsyncSequence
. Дополним наш класс ImageLoader
и параллельно разберемся, как он работает.
class ImageLoader {
// 1
enum LoadingState {
case loading(percent: Int)
case loaded(image: UIImage?)
}
private let imageUrl: URL
init(imageUrl: URL) {
self.imageUrl = imageUrl
}
var image: UIImage? {
get async throws {
let (data, _) = try await URLSession.shared.data(from: imageUrl)
return UIImage(data: data)
}
}
// 2
var loadingImageStream: AsyncStream<LoadingState> {
// 3
AsyncStream<LoadingState> { continuation in
// 4
Task { try await loadImageWithProgress(progressContinuation: continuation) }
}
}
// 5
private func loadImageWithProgress(progressContinuation: AsyncStream<LoadingState>.Continuation) async throws {
let (stream, response) = try await URLSession.shared.bytes(from: imageUrl)
var bytes: [UInt8] = []
for try await byte in stream {
// 6
try await Task.sleep(nanoseconds: 10000)
bytes.append(byte)
let currentPercent = Int(Double(bytes.count) / Double(response.expectedContentLength) * 100.0)
// 7
progressContinuation.yield(.loading(percent: currentPercent))
}
// 8
progressContinuation.yield(.loaded(image: UIImage(data: Data(bytes))))
progressContinuation.finish()
}
}
Создаем новый enum. C помощью него клиенты, использующие загрузку с прогрессом, будут определять текущее состояние. Всего реализуем 2 состояния.
case loading(percent: Int)
- загрузка еще в процессе, в этот кейс будем передавать текущий процент загрузки.case loaded(image: UIImage?)
- загрузка завершена, в этот кейс будем передавать загруженное изображение.Добавляем новое computed property типа
AsyncStream<LoadingState>
. С помощью него клиенты нашего класса смогут обрабатывать вfor await _ in
цикле асинхронную последовательность событий с типомLoadingState
.Создаем
AsyncStream
. Для инициализации требуется передать замыкание с типом(AsyncStream<Element>.Continuation) -> Void
. В continuation будем передавать наши стейты.Замыкание, которое мы передаем в инициализатор для
AsyncStream
, не асинхронное. Поэтому в нем нельзя await'ить. По этой причине заворачиваем вызов асинхронной функции вTask
.Асинхронная функция
loadImageWithProgress
включает в себя всю логику загрузки из предыдущего примера.AsyncStream
создаетcontinuation
(в который нужно передавать события). Мы передаем этотcontinuation
в функцию. Внутри функции мы передаем в него события с помощью методаyield
.Задержка для наглядности загрузки.
В процессе обработки байтов из стрима от
URLSession
передаем вcontinuation
события о загрузке с текущим процентом.После завершения стрима байтов от
URLSession
передаем вcontinuation
событие об окончании загрузки с итоговым изображением. Для завершения стрима нужно дернуть методfinish
уcontinuation
. Если это не сделать, то клиенты вfor await _ in
цикле будут бесконечно ожидать последующий событий, которых в нашем случае больше не будет.
Теперь будем использовать ImageLoader
в контроллере для загрузки изображения с процентом загрузки. Изменения коснуться только метода viewDidLoad
.
override func viewDidLoad() {
super.viewDidLoad()
fillView()
Task {
let photos = try await getPhotos(by: 1)
// 1
let loadingImageStream = ImageLoader(imageUrl: photos[0].url).loadingImageStream
// 2
for await event in loadingImageStream {
// 3
switch event {
case let .loading(percent):
loadedPercentLabel.text = "\(percent) %"
case let .loaded(image):
imageView.image = image
}
}
}
}
Создаем
ImageLoader
и сразу запрашиваем нашAsyncStream
.В
for await _ in
цикле обрабатываем события.В случае если событие
.loading
- отображаем новый процент. Если.loaded
- выставляем полученное изображение
Как видно из диаграммы, мы преобразуем каждый полученный байт в LoadingState
и передаем его в AsyncStream
. Этот стрим, в свою очередь, слушается (с помощью for await _ in
цикла) в контроллере, и на основе стейтов соответствующий пользовательский интерфейс.
Итоги
В этой статье мы рассмотрели несколько основных сущностей и функциональностей языка. И закрепили это все на приближенных к реальности примерах. Но это еще далеко не все, чем обзавелся Swift в версии 5.5. Для полного преисполнения асинхронностью нам еще предстоит разобраться с structurred cuncurrency и actors, которые включают внутри себя множество интересных деталей.
Полезные ссылки
Swift evolution. Async/await proposal
WWDC 2021. Use async/await with URLSession
WWDC 2021. Meet AsyncSequence
Swift evolution. Async let proposal