
Ранее я писал статью о работе оффлайн с веб-контентом. С того времени команда Apple выпустила Xcode 13.2 и Swift 5.5. Прочитав книгу о современной модели многопоточности в Swift, я понял, что это лучшее время для обновления моих примеров с async/await!
Перед прочтением моей статьи очень рекомендую прочитать материал о многопоточности в Swift Language Guide.
Заметка: Примеры кода написаны на Swift 5.5 и протестированы на iOS 15.0 с Xcode 13.2.
Подготовка
Давайте пробежимся по имплементации
WebDataManager, которая позволяет получить данные для веб контента по URL:import WebKit final class WebDataManager: NSObject { enum DataError: Error { case noImageData } // 1 enum DataType: String, CaseIterable { case snapshot = "Snapshot" case pdf = "PDF" case webArchive = "Web Archive" } // 2 private var type: DataType = .webArchive // 3 private lazy var webView: WKWebView = { let webView = WKWebView() webView.navigationDelegate = self return webView }() private var completionHandler: ((Result<Data, Error>) -> Void)? // 4 func createData(url: URL, type: DataType, completionHandler: @escaping (Result<Data, Error>) -> Void) { self.type = type self.completionHandler = completionHandler webView.load(.init(url: url)) } }
У нас есть:
- Перечисляемый тип
DataTypeдля разных форматов данных; - Свойство
typeс дефолтным значением, чтобы избежать опционального значения; - Свойство
webViewдля загрузки данных; - Функция
createDataдля обработки dataType, completionHandler и загрузки веб-контента для переданного URL.
Чего здесь не хватает? Конечно, имплементации
WKNavigationDelegate:extension WebDataManager: WKNavigationDelegate { func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { switch type { case .snapshot: let config = WKSnapshotConfiguration() config.rect = .init(origin: .zero, size: webView.scrollView.contentSize) webView.takeSnapshot(with: config) { [weak self] image, error in if let error = error { self?.completionHandler?(.failure(error)) return } guard let pngData = image?.pngData() else { self?.completionHandler?(.failure(DataError.noImageData)) return } self?.completionHandler?(.success(pngData)) } case .pdf: let config = WKPDFConfiguration() config.rect = .init(origin: .zero, size: webView.scrollView.contentSize) webView.createPDF(configuration: config) { [weak self] result in self?.completionHandler?(result) } case .webArchive: webView.createWebArchiveData { [weak self] result in self?.completionHandler?(result) } } } func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { completionHandler?(.failure(error)) } }
Итого: у нас 6 вызовов
completionHandler и слабые ссылки на self для избежания циклов удержания. Можем ли мы усовершенствовать этот код c async/await? Давайте попробуем!Добавление асинхронного кода
Мы начинаем рефакторить функцию
createData в асинхронном стиле:func createData(url: URL, type: DataType) async throws -> Data
Перед тем, как начать работу с веб-контентом, мы должны убедиться, что навигация в webview завершена. Мы можем обработать ее в функции
webView(_:didFinish:) у WKNavigationDelegate. Мы будем использовать функцию withCheckedThrowingContinuation, чтобы сделать эту логику совместимой с async\await.Давайте напишем функцию для асинхронной загрузки веб-контента через URL:
private var continuation: CheckedContinuation<Void, Error>? private func load(_ url: URL) async throws { return try await withCheckedThrowingContinuation { continuation in self.continuation = continuation self.webView.load(.init(url: url)) } }
Мы храним
continuation, чтобы использовать его в функциях делегата. Мы добавляем использование continuation, чтобы обработать обновления навигации:extension WebDataManager: WKNavigationDelegate { func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { continuation?.resume(returning: ()) } func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { continuation?.resume(throwing: error) } }
Но если вы запустите этот код, вы получите ошибку:
Call to main actor-isolated instance method 'load' in a synchronous nonisolated context
Мы добавляем атрибут
MainActor, чтобы починить это:@MainActor private func load(_ url: URL) async throws { // implementation }
MainActor – это глобальный актор, позволяющий выполнять код в основной очереди. Все
UIView (а значит, и WKWebView) объявляются с этим атрибутом и используются в основной очереди.Теперь мы можем вызвать функцию
load:@MainActor func createData(url: URL, type: DataType) async throws -> Data { try await load(url) // To be implemented return Data() }
Мы помечаем функцию
createData с помощью атрибута MainActor, потому что функция load должна вызываться в основной очереди. Более того, мы можем добавить этот атрибут в класс WebDataManager вместо всех функций:@MainActor final class WebDataManager: NSObject { // implementation }
Работа с системными API с помощью async/await
Теперь мы гото��ы переписать создание данных веб-контента. Приведу старый пример генерации PDF:
let config = WKPDFConfiguration() config.rect = .init(origin: .zero, size: webView.scrollView.contentSize) webView.createPDF(configuration: config) { [weak self] result in self?.completionHandler?(result) }
К счастью, команда Apple добавила async/await аналоги для множества существующих функций с коллбеками:
let config = WKPDFConfiguration() config.rect = .init(origin: .zero, size: webView.scrollView.contentSize) return try await webView.pdf(configuration: config)
Оно также работает для генерации картинки, однако создание web-архива по-прежнему доступно только с коллбеком. Здесь пригодится функция
withCheckedThrowingContinuation:import WebKit extension WKWebView { func webArchiveData() async throws -> Data { try await withCheckedThrowingContinuation { continuation in createWebArchiveData { result in continuation.resume(with: result) } } } }
Обратите внимание, что
continuation может автоматически обрабатывать значения Result и его связанных значений.Финальная версия функции
createData выглядит лучше:func createData(url: URL, type: DataType) async throws -> Data { try await load(url) switch type { case .snapshot: let config = WKSnapshotConfiguration() config.rect = .init(origin: .zero, size: webView.scrollView.contentSize) let image = try await webView.takeSnapshot(configuration: config) guard let pngData = image.pngData() else { throw DataError.noImageData } return pngData case .pdf: let config = WKPDFConfiguration() config.rect = .init(origin: .zero, size: webView.scrollView.contentSize) return try await webView.pdf(configuration: config) case .webArchive: return try await webView.webArchiveData() } }
Мы обрабатываем все ошибки в одном месте и уменьшаем места захвата
self в замыканиях.Использование новых асинхронных функций
Ура, мы сделали это! Погодите, но как использовать новые асинхронные функции из синхронного контекста? С созданием объекта
Task мы можем выполнять асинхронные задачи:Task { do { let url = URL(string: "https://www.artemnovichkov.com")! let data = try await webDataManager.createData(url: url, type: .pdf) print(data) } catch { print(error) } }
Финальный результат находится в проекте OfflineDataAsyncExample на Github.
Заключение
На первый взгляд новая модель многопоточности выглядит как синтаксический сахар. Однако, его использование приводит к более безопасному и структурированному коду. Мы легко можем избежать захвата
self в замыканиях и улучшить обработку ошибок. Я продолжаю «играть» с async/await и собирать полезные ресурсы в репозитории awesome-swift-async-await. Буду рад, если вы поделитесь своими любимыми материалами по этой теме!