В предыдущей части мы разобрали преимущества работы с async/await по сравнению с GCD. В этой части мы более подробно рассмотрим ключевые слова async и await (и не только). Разберемся в том, как они работают, что означает "неблокирующее ожидание" и самое главное рассмотрим это все на примерах.

Статьи из серии

  1. Swift async/await. Чем он лучше GCD?

  2. 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
}
  1. Создаем decodable структуру, в которую мы будем преобразовывать полученный JSON.

  2. Создаем функцию, которая будет запрашивать массив объектов типа Photo по id альбома с сервера. Помечаем нашу функцию ключевым словом async. Этим мы сообщаем компилятору, что наша функция потенциально может иметь suspension points (точки приостановки). Грубо говоря, пометив функцию как async, мы можем вызывать внутри этой функции другие async функции с помощью await. Также помечаем нашу функцию ключевым словом throws, что позволит языковыми конструкциями do/try/catch обрабатывать ошибки, вызывая нашу функцию.

  3. Формируем GET запрос к серверу.

  4. Отправляем наш запрос с помощью нового асинхронного метода URLSession.data. Чтобы вызвать асинхронную функцию и получить результат в том же месте, воспользуемся ключевым словом await. Метод возвращает кортеж (Data, URLResponse). В идеале, стоило бы проверить response на наличие ошибок, но для компактности и простоты я пропущу этот шаг.

  5. Преобразуем полученный 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.

Flow функции getPhotos
Flow функции getPhotos

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)
    }
  }
}
  1. В инициализаторе ожидаем URL по которому в дальнейшем будем загружать изображение.

  2. Изображение будем загружать через вызов computed property.

  3. Помечаем геттер ключевым словом async. Это позволит вызывать внутри другие async функции. Еще одно новшество, не относящееся к async/await, заключается в том, что геттер теперь может быть throws (выкидывать ошибки).

  4. Вызываем уже знакомый нам метод у URLSession. Только теперь передаем ему не URLRequest, а просто URL.

  5. Преобразовываем 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),
    ])
  }
}
  1. Функция из первого примера

  2. Метод viewDidLoad - синхронный. Мы не можем использовать await для ожидания выполнения асинхронных функций внутри синхронного метода. Для этого нужно использовать новую сущность - Task. Мы поговорим про нее подробнее в следующих частях. Сейчас же стоит знать, что если мы хотим вызвать async функцию из синхронной, то вызов нужно осуществлять внутри Task.

  3. Получаем массив данных для фотографий с помощью нашего метода.

  4. С помощью новой сущности и его async computed property image загружаем изображение и в этой же строчке добавляем его нашей imageView.

  5. С помощью конструкции do/try/catch мы можем обработать сразу несколько throws функций внутри одного блока. Если обработка ошибок не предусмотрена, то внутри Task можно не использовать do/try/catch, так как замыкание которое мы передаем в Task может выбрасывать ошибки (помечено ключевым словом throws).

Внутри нашей таски мы вызываем асинхронный метод getPhotos и с помощью await ждем результата. Когда функция вернет массив [Photo], выполнение продолжится. После мы достаем URL первого изображения и создаем на его основе объект ImageLoader. Далее так же ожидаем выполнения его асинхронного вычисляемого значения image и в заключении присваиваем полученное изображение в imageView.image.

Flow для загрузки изображения
Flow для загрузки изображения

Не забываем, что 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
}
  1. Как и в предыдущем примере, сначала получаем массив данных изображений.

  2. Загружаем поочередно два изображения. Для этого просто беру url у первых двух элементов из массива.

  3. Рендерим из них одно изображение.

  4. Присваиваем полученный результат в imageView.

Все отлично работает, но есть один момент. Мы начинаем грузить первое изображение и ждем, пока оно загрузится с помощью await. Только после того, как наше первое изображение загрузилось, мы начинаем загружать второе.

Загрузка изображений с помощью await
Загрузка изображений с помощью 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
}
  1. Теперь мы не await'им изображения, а с помощью async let создаем дочерние таски (про дочерние таски поговорим подробнее в одной из следующих частей). Дочерние задачи сразу отправляются на планирование в систему, и система запускает их при первой возможности. Наша функция не прерывается на async let, выполнение идет дальше после этой конструкции. Вызов асинхронной функции с помощью async let не является потенциальной точкой приостановки (suspension point).

  2. Ожидаем завершения задач, запущенных на первом шаге. В этом случае мы можем использовать await для ожидания их поочередно, так как вторая задача продолжает выполнение, пока мы ожидаем завершения первой.

Таска, созданная с помощью async let, может завершиться до того, как мы решим получить из нее значение. В таком случае, при вызове await, мы сразу же получим его.

Также здесь стоит упомянуть, что после ключевого слова await в выражении можно указывать любое количество асинхронных функций. Например, ожидание загрузки двух изображений в нашем примере можно было записать одной строкой:

let (firstImage, secondImage) = try await (firstImageTask, secondImageTask)

Пользуясь конструкцией async let можно столкнуться с некоторыми не совсем очевидными моментами:

  1. 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
}
  1. В 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),
    ])
  }
}

Пробежимся по всем изменениям контроллера:

  1. Добавили лейбл в котором будем отображать процент загрузки.

  2. Получаем пару значений типа (URLSession.AsyncBytes, URLResponse) с помощью нового асинхронного метода URLSession.bytes.

  3. Создаем переменную, в которой будем хранить все полученные байты.

  4. В цикле обрабатываем байты из AsyncBytes. Байты поступают по мере загрузки, поэтому приостанавливаемся каждый раз с помощью await.

  5. При получении каждого последующего байта добавляем его в массив. Затем рассчитываем процент загруженных на данный момент байт относительно ожидаемого количества (которое приходит в URLResponse.expectedContentLength) и обновляем текст у лейбла.

  6. Из массива байт создаем 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()
  }
}
  1. Создаем новый enum. C помощью него клиенты, использующие загрузку с прогрессом, будут определять текущее состояние. Всего реализуем 2 состояния. case loading(percent: Int) - загрузка еще в процессе, в этот кейс будем передавать текущий процент загрузки. case loaded(image: UIImage?) - загрузка завершена, в этот кейс будем передавать загруженное изображение.

  2. Добавляем новое computed property типа AsyncStream<LoadingState>. С помощью него клиенты нашего класса смогут обрабатывать в for await _ in цикле асинхронную последовательность событий с типом LoadingState.

  3. Создаем AsyncStream. Для инициализации требуется передать замыкание с типом (AsyncStream<Element>.Continuation) -> Void. В continuation будем передавать наши стейты.

  4. Замыкание, которое мы передаем в инициализатор для AsyncStream, не асинхронное. Поэтому в нем нельзя await'ить. По этой причине заворачиваем вызов асинхронной функции в Task.

  5. Асинхронная функция loadImageWithProgress включает в себя всю логику загрузки из предыдущего примера. AsyncStream создает continuation (в который нужно передавать события). Мы передаем этот continuation в функцию. Внутри функции мы передаем в него события с помощью метода yield.

  6. Задержка для наглядности загрузки.

  7. В процессе обработки байтов из стрима от URLSession передаем в continuation события о загрузке с текущим процентом.

  8. После завершения стрима байтов от 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
      }
    }
  }
}
  1. Создаем ImageLoader и сразу запрашиваем наш AsyncStream.

  2. В for await _ in цикле обрабатываем события.

  3. В случае если событие .loading - отображаем новый процент. Если .loaded - выставляем полученное изображение

Как видно из диаграммы, мы преобразуем каждый полученный байт в LoadingState и передаем его в AsyncStream. Этот стрим, в свою очередь, слушается (с помощью for await _ in цикла) в контроллере, и на основе стейтов соответствующий пользовательский интерфейс.

Итоги

В этой статье мы рассмотрели несколько основных сущностей и функциональностей языка. И закрепили это все на приближенных к реальности примерах. Но это еще далеко не все, чем обзавелся Swift в версии 5.5. Для полного преисполнения асинхронностью нам еще предстоит разобраться с structurred cuncurrency и actors, которые включают внутри себя множество интересных деталей.

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

Комментарии (5)