Async/await для существующих iOS-приложений

image-loader.svg

Ранее я писал статью о работе оффлайн с веб-контентом. С того времени команда 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. Буду рад, если вы поделитесь своими любимыми материалами по этой теме!

© Habrahabr.ru