Содержание

Код для скачивания изображения с
typealias Completion = (Result) -> Void
func loadImage(for url: URL, completion: @escaping Completion) {
let urlRequest = URLRequest(url: url)
let task = URLSession.shared.dataTask(
with: urlRequest,
completionHandler: { (data, response, error) in
if let error = error {
completion(.failure(error))
return
}
guard let response = response as? HTTPURLResponse else {
completion(.failure(URLError(.badServerResponse)))
return
}
guard response.statusCode == 200 else {
completion(.failure(URLError(.badServerResponse)))
return
}
guard let data = data, let image = UIImage(data: data) else {
completion(.failure(URLError(.cannotDecodeContentData)))
return
}
completion(.success(image))
}
)
task.resume()
}
Удобная обёртка выглядит так:
extension UIImageView {
func setImage(url: URL) {
loadImage(for: url, completion: { [weak self] result in
DispatchQueue.main.async { [weak self] in
switch result {
case .success(let image):
self?.image = image
case .failure(let error):
self?.image = nil
print(error.localizedDescription)
}
}
})
}
}
Что держим в уме:
-
- Не забываем переключаться на главный поток. Появляются конструкции
- Сложно отменить операцию загрузки, если мы работаем с ячейкой таблицы.
Напишем новую функцию с
func data(for request: URLRequest) async throws -> (Data, URLResponse)
Ключевое слово
func loadImage(for url: URL) async throws -> UIImage {
let urlRequest = URLRequest(url: url)
let (data, response) = try await URLSession.shared.data(for: urlRequest)
guard let response = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
guard response.statusCode == 200 else {
throw URLError(.badServerResponse)
}
guard let image = UIImage(data: data) else {
throw URLError(.cannotDecodeContentData)
}
return image
}
Функцию вызываем с помощью
extension UIImageView {
func setImage(url: URL) {
Task {
do {
let image = try await loadImage(for: url)
self.image = image
} catch {
print(error.localizedDescription)
self.image = nil
}
}
}
}
Теперь взглянем на схему для функции

и

Когда выполнение дойдёт до
Вот так получился читаемый и безопасный код. Не нужно помнить про поток или беспокоиться о возможной утечке памяти из-за ошибок захвата
Если система увидит, что приоритетнее задач нет, жёлтая задача
Напишем
func loadImage(for url: URL) async throws -> UIImage {
try await withCheckedThrowingContinuation { continuation in
loadImage(for: url) { (result: Result) in
continuation.resume(with: result)
}
}
}
Используйте функцию для явного переключения на другой поток. Вызвать
func loadUserPage(id: String) async throws -> (UIImage, CertificateModel) {
let user = try await loadUser(for: id)
async let avatarImage = loadImage(user.avatarURL)
async let certificates = loadCertificates(for: user)
return (try await avatarImage, try await certificates)
}
Функции
struct Task where Success : Sendable, Failure : Error
Результатом может быть значение или ошибка конкретного типа. Тип ошибки
С помощью экземпляра задачи можно получать результат асинхронно, отменять и проверять отмену задачи:
let downloadFileTask = Task {
try await Task.sleep(nanoseconds: 1_000_000)
return Data()
}
// ...
if downloadFileTask.isCancelled {
print("Загрузка была уже отменена")
} else {
downloadFileTask.cancel()
// Помечаем задачу как cancel
print("Загрузка отменяется...")
}
Вызов
Из задачи можно вызывать другую задачу и организовывать сложные цепочки. Вызываем во
Task {
let cardsTask = Task<[CardModel], Error>(priority: .userInitiated) {
/* запрос на модели карт пользователя */
return []
}
let userInfoTask = Task(priority: .userInitiated) {
/* запрос на модель о пользователе */
return UserInfo()
}
do {
let cards = try await cardsTask.value
let userInfo = try await userInfoTask.value
updateUI(with: userInfo, and: cards)
Task(priority: .background) {
await saveUserInfoIntoCache(userInfo: userInfo)
}
} catch {
showErrorInUI(error: error)
}
}
Аналогия на GCD для этого кода, которая описывает, что происходит:
DispatchQueue.main.async {
var cardsResult: Result<[CardModel], Error>?
var userInfoResult: Result?
let dispatchGroup = DispatchGroup()
dispatchGroup.enter()
DispatchQueue.main.async {
cardsResult = .success([/* запрос на карты */])
dispatchGroup.leave()
}
dispatchGroup.enter()
DispatchQueue.main.async {
/* запрос на модель о пользователе */
userInfoResult = .success(UserInfo())
dispatchGroup.leave()
}
dispatchGroup.notify(queue: .main, execute: { in
if case let .success(cards) = cardsResult,
case let .success(userInfo) = userInfoResult {
self.updateUI(with: cards, and: userInfo)
// да! не DispatchQueue.global(qos: .background)
DispatchQueue.main.async { in
self.saveUserInfoIntoCache(userInfo: userInfo)
}
} else if case let .failure(error) = cardsResult { in
self.showErrorInUI(error: error)
} else if case let .failure(error) = userInfoResult { in
self.showErrorInUI(error: error)
}
})
}
final class MyViewController: UIViewController {
private var loadingTask: Task?
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if notDataYet {
loadingTask = Task {
// ...
}
}
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
loadingTask?.cancel()
}
}
loadingTask = Task {
let cardsTask = Task<[CardModel], Error>(priority: .userInitiated) {
/* запрос на модели карт пользователя */
return []
}
let userInfoTask = Task(priority: .userInitiated) {
/* запрос на модель о пользователе */
return UserInfo()
}
do {
let cards = try await cardsTask.value
guard !Task.isCancelled else { return }
let userInfo = try await userInfoTask.value
guard !Task.isCancelled else { return }
updateUI(with: userInfo, and: cards)
Task(priority: .background) {
guard !Task.isCancelled else { return }
await saveUserInfoIntoCache(userInfo: userInfo)
}
} catch {
showErrorInUI(error: error)
}
}
Чтобы задача не наследовала ни контекст, ни приоритет, используйте
Task.detached(priority: .background) {
await saveUserInfoIntoCache(userInfo: userInfo)
await cleanupInCache()
}
Полезно применять, когда задача не зависит от родительской. Вот пример сохранения в кеш от WWDC:
func storeImageInDisk(image: UIImage) async {
guard
let imageData = image.pngData(),
let cachesUrl = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else {
return
}
let imageUrl = cachesUrl.appendingPathComponent(UUID().uuidString)
try? imageData.write(to: imageUrl)
}
func downloadImageAndMetadata(imageNumber: Int) async throws -> DetailedImage {
let image = try await downloadImage(imageNumber: imageNumber)
Task.detached(priority: .background) {
await storeImageInDisk(image: image)
}
let metadata = try await downloadMetadata(for: imageNumber)
return DetailedImage(image: image, metadata: metadata)
}
Отмена
Если нужно запустить массив операций (например, загрузить список изображений по массиву URL), используйте
func loadUserImages(for id: String) async throws -> [UIImage] {
let user = try await loadUser(for: id)
let userImages: [UIImage] = try await withThrowingTaskGroup(of: UIImage.self) { group -> [UIImage] in
for url in user.imageURLs {
group.addTask {
return try await loadImage(for: url)
}
}
var images: [UIImage] = []
for try await image in group {
images.append(image)
}
return images
}
return userImages
}
actor ImageDownloader {
var cache: [String: UIImage] = [:]
}
let imageDownloader = ImageDownloader()
imageDownloader.cache["image"] = UIImage() // ошибка компиляции
// error: actor-isolated property 'cache' can only be referenced from inside the actor
Чтобы использовать
actor ImageDownloader {
var cache: [String: UIImage] = [:]
func setImage(for key: String, image: UIImage) {
cache[key] = image
}
}
let imageDownloader = ImageDownloader()
Task {
await imageDownloader.setImage(for: "image", image: UIImage())
}
По свойствам
Система асинхронности построена так, чтобы мы перестали думать потоками.
public protocol Actor: AnyObject, Sendable {
nonisolated var unownedExecutor: UnownedSerialExecutor { get }
}
final class ImageDownloader: Actor {
// ...
}
Полезно знать:
-
-
-
У
let imageDownloader = ImageDownloader()
Task {
await imageDownloader.setImage(for: "image", image: UIImage())
}
А потом семантически происходит следующее:
let imageDownloader = ImageDownloader()
Task {
imageDownloader.unownedExecutor.enqueue {
setImage(for: "image", image: UIImage())
}
}
По умолчанию Swift генерирует стандартный
extension MainActor {
func runOnMain() {
// напечатается что-то вроде:
// <_NSMainThread: 0x600003cf04c0>{number = 1, name = main}
print(Thread.current)
}
}
Task(priority: .background) {
await MainActor.shared.runOnMain()
}
Когда писали акторы, мы создавали новый инстанс. Однако Swift позволяет создавать глобальные акторы через
@MainActor func updateUI() {
// job
}
Task(priority: .background) {
await runOnMain()
}
По аналогии с
@globalActor actor ImageDownloader {
static let shared = ImageDownloader()
// ...
}
@ImageDownloader func action() {
// ...
}
Можно помечать функции и классы - тогда у методов по умолчанию будут атрибуты.
Напишем инструмент для поиска приложений в App Store. Он будет показывать позицию сервиса для поиска приложений:
GET https://itunes.apple.com/search?entity=software?term=<запрос>
{
trackName: "Имя приложения"
trackId: 42
bundleId: "com.apple.developer"
trackViewUrl: "ссылка на приложение"
artworkUrl512: "ссылка на иконку приложения"
artistName: "название приложения"
screenshotUrls: ["ссылка на первый скриншот", "на второй"],
formattedPrice: "отформатированная цена приложения",
averageUserRating: 0.45,
// ещё куча другой информации, но мы это опустим.
}
Модель данных:
struct ITunesResultsEntry: Decodable {
let results: [ITunesResultEntry]
}
struct ITunesResultEntry: Decodable {
let trackName: String
let trackId: Int
let bundleId: String
let trackViewUrl: String
let artworkUrl512: String
let artistName: String
let screenshotUrls: [String]
let formattedPrice: String
let averageUserRating: Double
}
C такими структурами работать неудобно, да и не хочется зависеть от модельки сервера. Добавим прослойку:
struct AppEnity {
let id: Int
let bundleId: String
let position: Int
let name: String
let developer: String
let rating: Double
let appStoreURL: URL
let iconURL: URL
let screenshotsURLs: [URL]
}
Создадим сервис через
actor AppsSearchService {
func search(with query: String) async throws -> [AppEnity] {
let url = buildSearchRequest(for: query)
let urlRequest = URLRequest(url: url)
let (data, response) = try await URLSession.shared.data(for: urlRequest)
guard let response = response as? HTTPURLResponse, response.statusCode == 200 else {
throw URLError(.badServerResponse)
}
let results = try JSONDecoder().decode(ITunesResultsEntry.self, from: data)
let entities = results.results.enumerated().compactMap { item -> AppEnity? in
let (position, entry) = item
return convert(entry: entry, position: position)
}
return entities
}
}
Для построения
extension AppsSearchService {
private static let baseURLString: String = "https://itunes.apple.com"
private func buildSearchRequest(for query: String) -> URL {
var components = URLComponents(string: Self.baseURLString)
components?.path = "/search"
components?.queryItems = [
URLQueryItem(name: "entity", value: "software"),
URLQueryItem(name: "term", value: query),
]
guard let url = components?.url else {
fatalError("developer error: cannot build url for search request: query=\"(query)\"")
}
return url
}
}
Конвертируем модель данных с сервера в локальную:
extension AppsSearchService {
private func convert(entry: ITunesResultEntry, position: Int) -> AppEnity? {
guard let appStoreURL = URL(string: entry.trackViewUrl) else {
return nil
}
guard let iconURL = URL(string: entry.artworkUrl512) else {
return nil
}
return AppEnity(
id: entry.trackId,
bundleId: entry.bundleId,
position: position,
name: entry.trackName,
developer: entry.artistName,
rating: entry.averageUserRating,
appStoreURL: appStoreURL,
iconURL: iconURL,
screenshotsURLs: entry.screenshotUrls.compactMap { URL(string: $0) }
)
}
}
Приходят URL от изображений.
Ячейка таблица конфигурируется при скролле. Чтобы не качать иконку каждый раз, сохраним в кеш. Программисты скидывают логику на библиотеки типа Nuke, но с
actor ImageLoaderService {
private var cache = NSCache()
init(cacheCountLimit: Int) {
cache.countLimit = cacheCountLimit
}
func loadImage(for url: URL) async throws -> UIImage {
if let image = lookupCache(for: url) {
return image
}
let image = try await doLoadImage(for: url)
updateCache(image: image, and: url)
return lookupCache(for: url) ?? image
}
private func doLoadImage(for url: URL) async throws -> UIImage {
let urlRequest = URLRequest(url: url)
let (data, response) = try await URLSession.shared.data(for: urlRequest)
guard let response = response as? HTTPURLResponse, response.statusCode == 200 else {
throw URLError(.badServerResponse)
}
guard let image = UIImage(data: data) else {
throw URLError(.cannotDecodeContentData)
}
return image
}
private func lookupCache(for url: URL) -> UIImage? {
return cache.object(forKey: url as NSURL)
}
private func updateCache(image: UIImage, and url: URL) {
if cache.object(forKey: url as NSURL) == nil {
cache.setObject(image, forKey: url as NSURL)
}
}
}
Сделаем удобнее:
extension UIImageView {
private static let imageLoader = ImageLoaderService(cacheCountLimit: 500)
@MainActor
func setImage(by url: URL) async throws {
let image = try await Self.imageLoader.loadImage(for: url)
if !Task.isCancelled {
self.image = image
}
}
}
Кеширование готово. Сделаем отмену. Глянем на реализацию ячейки (layout пропускаю):
final class AppSearchCell: UITableViewCell {
private var loadImageTask: Task?
func configure(with appEntity: AppEnity) {
appNameLabel.text = appEntity.position.formatted() + ". " + appEntity.name
developerLabel.text = appEntity.developer
ratingLabel.text = appEntity.rating.formatted(.number.precision(.significantDigits(3))) + " rating"
configureIcon(for: appEntity.iconURL)
}
private func configureIcon(for url: URL) {
loadImageTask?.cancel()
loadImageTask = Task { [weak self] in
self?.iconApp.image = nil
self?.activityIndicatorView.startAnimating()
do {
try await self?.iconApp.setImage(by: url)
self?.iconApp.contentMode = .scaleAspectFit
} catch {
self?.iconApp.image = UIImage(systemName: "exclamationmark.icloud")
self?.iconApp.contentMode = .center
}
self?.activityIndicatorView.stopAnimating()
}
}
}
Если иконка отсутствует в кеше, она будет загружаться из сети, а в процессе загрузки на экране будет отображаться loading стейт. Если загрузка не закончилась, а пользователь проскроллил и картинка больше не нужна, загрузка отменится.
Подготовим
final class AppSearchViewController: UIViewController {
enum State {
case initial
case loading
case empty
case data([AppEnity])
case error(Error)
}
private var searchingTask: Task?
private lazy var searchService = AppsSearchService()
private var state: State = .initial {
didSet { updateState() }
}
func updateState() {
switch state {
case .initial:
tableView.isHidden = false
activityIndicatorView.stopAnimating()
statusLabel.text = "Input your request"
case .loading:
tableView.isHidden = true
activityIndicatorView.startAnimating()
statusLabel.text = "Loading..."
case .empty:
tableView.isHidden = true
activityIndicatorView.stopAnimating()
statusLabel.text = "No apps found"
case .data(let apps):
tableView.isHidden = false
activityIndicatorView.stopAnimating()
statusLabel.text = nil
var snapshot = Snapshot()
snapshot.appendSections([.main])
snapshot.appendItems(apps.map { .app($0) }, toSection: .main)
dataSource.apply(snapshot)
case .error(let error):
tableView.isHidden = true
activityIndicatorView.stopAnimating()
statusLabel.text = "Error: (error.localizedDescription)"
}
}
}
Опишу делегат, чтобы реагировать на поиск:
extension AppSearchViewController: UISearchControllerDelegate, UISearchBarDelegate {
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
guard let query = searchBar.text else {
return
}
searchingTask?.cancel()
searchingTask = Task { [weak self] in
self?.state = .loading
do {
let apps = try await searchService.search(with: query)
if Task.isCancelled { return }
if apps.isEmpty {
self?.state = .empty
} else {
self?.state = .data(apps)
}
} catch {
if Task.isCancelled { return }
self?.state = .error(error)
}
}
}
}
Нажимаем «Search» - отменяем предыдущий поиск, запускаем новый. В задаче
Работает iOS 13 из-за того, что фича требует нового рантайма.
Apple принесла асинхронный API в HealthKit с iOS 13, CoreData c iOS 15, а новый StoreKit 2 предлагает только асинхронный интерфейс. Код сохранения тренировки стал проще:
struct RunWorkout {
let startDate: Date
let endDate: Date
let route: [CLLocation]
let heartRateSamples: [HKSample]
}
func saveWorkoutToHealthKit(runWorkout: RunWorkout, completion: @escaping (Result) -> Void) {
let store = HKHealthStore()
let routeBuilder = HKWorkoutRouteBuilder(healthStore: store, device: .local())
let workout = HKWorkout(activityType: .running, start: runWorkout.startDate, end: runWorkout.endDate)
store.save(workout, withCompletion: { (status: Bool, error: Error?) -> Void in
if let error = error {
completion(.failure(error))
return
}
store.add(runWorkout.heartRateSamples, to: workout, completion: { (status: Bool, error: Error?) -> Void in
if let error = error {
completion(.failure(error))
return
}
if !runWorkout.route.isEmpty {
routeBuilder.insertRouteData(runWorkout.route, completion: { (status: Bool, error: Error?) -> Void in
if let error = error {
completion(.failure(error))
return
}
routeBuilder.finishRoute(
with: workout,
metadata: nil,
completion: { (route: HKWorkoutRoute?, error: Error?) -> Void in
if let error = error {
completion(.failure(error))
return
}
completion(.success(Void()))
}
)
})
} else {
completion(.success(Void()))
}
})
})
}
На
func saveWorkoutToHealthKitAsync(runWorkout: RunWorkout) async throws {
let store = HKHealthStore()
let routeBuilder = HKWorkoutRouteBuilder(
healthStore: store,
device: .local()
)
let workout = HKWorkout(
activityType: .running,
start: runWorkout.startDate,
end: runWorkout.endDate
)
try await store.save(workout)
try await store.addSamples(runWorkout.heartRateSamples, to: workout)
if !runWorkout.route.isEmpty {
try await routeBuilder.insertRouteData(runWorkout.route)
try await routeBuilder.finishRoute(with: workout, metadata: nil)
}
}
Попрактикуйтесь, добавив новый экран детали страницы App Store, решите проблему с загрузкой скриншотов и правильной отменой, если пользователь быстро закрыл страницу
Множество примеров использования async/await. Например, раскрыта тема
Если хотите больше узнать о реализации акторов под капотом
Если хотите познать истину, то обратитесь к коду
WWDC-сессии:
Видео-туториал от Apple об actor. Рассказывают, какие проблемы он решает и как им пользоваться.
Видео-туториал от Apple о структурном параллелизме, в частности, о
Видео-туториал от Apple о том, как работает async/await. Есть наглядные схемы.
Другие туториалы
Знакомимся с модификатором
Как добавить отступ между картинкой и заголовком в кнопке. Как поместить иконку справа от заголовка.
Делаем прототип вью в SwiftUI. Скелет интерфейса, пока контент загружается.
Как устроен ProgressView. Как настроить внешний вид: спиннер и прогресс-бар.
В telegram-канале приходят уведомления о новых туториалах. В чате для iOS разработчиков ответят на вопросы.