Async/await для существующих iOS-приложений
Ранее я писал статью о работе оффлайн с веб-контентом. С того времени команда 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) -> Void)?
// 4
func createData(url: URL, type: DataType, completionHandler: @escaping (Result) -> 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?
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. Буду рад, если вы поделитесь своими любимыми материалами по этой теме!