rzaripov.kz

Асинхронное программирование в Swift на практике

Асинхронное программирование в Swift стало значительно понятнее после появления async/await. Вместо цепочек замыканий разработчик описывает последовательность действий почти так же, как синхронный алгоритм: получить данные, преобразовать ответ, обновить экран. При этом поток интерфейса не блокируется, а система может эффективно использовать сетевые операции, файловый ввод-вывод и фоновые вычисления.

Подход особенно полезен в мобильных приложениях, где задержка сети или медленная база данных не должны приводить к зависанию интерфейса. В этой статье разберём практическую модель concurrency в Swift: объявление асинхронных функций, параллельный запуск задач, обработку ошибок, отмену операций, работу с MainActor и переход от старого API на completion handlers.

Как работает async/await

Асинхронная функция объявляется с ключевым словом async, а операция, которая может завершиться ошибкой, дополнительно получает throws:

func loadProfile() async throws -> Profile {
    let request = URLRequest(url: URL(string: "https://example.com/profile")!)
    let (data, _) = try await URLSession.shared.data(for: request)
    return try JSONDecoder().decode(Profile.self, from: data)
}

await означает точку возможной приостановки. Он не создаёт новый поток и не замораживает текущий поток до получения результата. Swift приостанавливает текущую задачу, освобождая исполнитель для другой работы, а затем возобновляет задачу после завершения операции.

Вызвать такую функцию можно из другой асинхронной функции или внутри Task:

Task {
    do {
        let profile = try await loadProfile()
        print(profile.name)
    } catch {
        print("Ошибка: \(error)")
    }
}

Важно различать асинхронность и параллельность. Асинхронная функция может выполнять операции последовательно с паузами, а параллельность означает одновременное выполнение нескольких независимых задач. async/await предоставляет удобный синтаксис для первого случая, а structured concurrency помогает безопасно организовать второй.

Сетевые запросы через URLSession

Современный URLSession поддерживает async-методы, поэтому для обычного HTTP-запроса больше не требуется вручную создавать CheckedContinuation. Например, сетевой слой может возвращать уже декодированную модель:

struct Article: Decodable {
    let id: Int
    let title: String
}

enum NetworkError: Error {
    case invalidResponse
}

func fetchArticles() async throws -> [Article] {
    let url = URL(string: "https://example.com/articles")!
    let (data, response) = try await URLSession.shared.data(from: url)

    guard let http = response as? HTTPURLResponse,
          200..<300 ~= http.statusCode else {
        throw NetworkError.invalidResponse
    }

    return try JSONDecoder().decode([Article].self, from: data)
}

Проверка HTTP-статуса остаётся обязанностью приложения. URLSession может успешно вернуть данные на уровне транспорта даже при ответе 404 или 500, поэтому код должен отдельно проверять HTTPURLResponse. Для production-приложения также стоит учитывать тайм-ауты, кэширование, повторные попытки и ограничение размера ответа.

Асинхронный метод удобно вызывать из ViewModel. Она может хранить состояние загрузки, данные и ошибку, а представление будет реагировать на изменения. Такой слой отделяет сетевую реализацию от SwiftUI или UIKit и упрощает тестирование.

Параллельная загрузка данных

Если экрану требуются независимые ресурсы, последовательный код увеличивает время ожидания:

let user = try await fetchUser()
let settings = try await fetchSettings()

Здесь второй запрос начнётся только после завершения первого. При независимых операциях лучше использовать async let:

async let user = fetchUser()
async let settings = fetchSettings()

let profile = try await user
let preferences = try await settings

Обе задачи запускаются конкурентно, а ожидание результата происходит в момент обращения к переменной. Такой вариант подходит для фиксированного небольшого количества операций. Если количество задач формируется динамически, применяют withTaskGroup или withThrowingTaskGroup.

Группа задач автоматически связывает дочерние операции с родительской. При отмене родительской задачи дочерние задачи получают сигнал отмены, а их жизненный цикл не теряется в приложении. Это безопаснее, чем запускать множество независимых Task и хранить их идентификаторы вручную.

Сценарий Подход Особенность
Один сетевой запрос async/await Последовательный и читаемый код
Два независимых запроса async let Параллельное получение фиксированных ресурсов
Динамический список задач TaskGroup Управление набором дочерних операций
Обновление интерфейса MainActor Выполнение UI-изменений на главном исполнителе
Прерывание операции Task.isCancelled и checkCancellation() Контроль лишней работы

Акторы и обновление интерфейса

Swift Concurrency использует акторы для защиты изменяемого состояния от конкурентного доступа. Актор изолирует свои свойства и методы, поэтому обращаться к ним из другой конкурентной области нужно через await:

actor ImageCache {
    private var storage: [URL: Data] = [:]

    func value(for url: URL) -> Data? {
        storage[url]
    }

    func insert(_ data: Data, for url: URL) {
        storage[url] = data
    }
}

Такой кэш не требует ручной блокировки с помощью NSLock. Актор гарантирует, что его изолированное состояние изменяется безопасно. При этом он не делает каждую операцию параллельной: методы выполняются последовательно внутри изолированного контекста.

Для интерфейса SwiftUI или UIKit используется MainActor. ViewModel можно объявить так:

@MainActor
final class ArticlesViewModel: ObservableObject {
    @Published private(set) var articles: [Article] = []
    @Published private(set) var isLoading = false

    func load() async {
        isLoading = true
        defer { isLoading = false }

        do {
            articles = try await fetchArticles()
        } catch {
            articles = []
        }
    }
}

Сетевой запрос при этом не блокирует главный исполнитель. После получения результата присваивание articles и изменение isLoading выполняются в безопасном для UI контексте. Аннотацию @MainActor лучше применять к объектам, которые действительно управляют состоянием представления, а не ко всему приложению без необходимости.

Ошибки, отмена и жизненный цикл

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

func process(items: [Item]) async throws -> [ResultItem] {
    var result: [ResultItem] = []

    for item in items {
        try Task.checkCancellation()
        result.append(transform(item))
    }

    return result
}

Вызов Task.checkCancellation() выбрасывает CancellationError. Для необязательной проверки можно использовать Task.isCancelled и самостоятельно завершить вычисление. Это особенно важно для поиска, фильтрации больших массивов, обработки изображений и синхронизации данных.

В SwiftUI задачу, связанную с жизненным циклом представления, удобно запускать через .task:

.task {
    await viewModel.load()
}

Когда экран исчезает, SwiftUI может отменить связанную задачу. Если внутри выполняется URLSession, отмена обычно передаётся сетевому запросу. Для ручного Task нужно сохранить ссылку и отменить её в нужный момент, например при закрытии экрана или смене параметров поиска.

Переход от completion handlers

Старый стиль часто выглядит так:

func load(completion: @escaping (Result<[Article], Error>) -> Void) {
    // сетевой запрос и вызов completion
}

При нескольких зависимых операциях появляются вложенные замыкания, сложнее становится обработка ошибок и легко забыть вызвать completion на нужной ветке. async/await переносит результат в возвращаемое значение, а ошибку — в стандартный механизм throws.

Для собственного callback API можно временно использовать withCheckedThrowingContinuation:

func loadAsync() async throws -> [Article] {
    try await withCheckedThrowingContinuation { continuation in
        load { result in
            continuation.resume(with: result)
        }
    }
}

Continuation должна быть возобновлена ровно один раз. Двойной вызов resume приводит к ошибке, а отсутствие вызова оставляет задачу навсегда приостановленной. Поэтому такой адаптер следует покрывать тестами и постепенно заменять асинхронным API там, где это возможно.

При миграции полезно двигаться по слоям: сначала адаптировать сетевой клиент, затем репозитории и ViewModel, а UI менять последним. Для старых версий iOS можно оставить callback-методы и добавить обёртки с доступностью через @available, чтобы новая реализация не нарушала поддержку существующих устройств.

Архитектура асинхронного приложения

Хорошая архитектура разделяет ответственность. Сетевой клиент формирует запросы и декодирует ответы, репозиторий решает, брать ли данные из сети или локального хранилища, а ViewModel управляет состоянием экрана. Асинхронные методы проходят между этими уровнями без необходимости передавать completion через каждый слой.

Для больших проектов полезно выделить протокол:

protocol ArticlesRepository {
    func articles() async throws -> [Article]
}

В production-реализации протокол обращается к серверу, а в тестах используется mock с заранее заданными данными. Это позволяет проверять состояния загрузки, ошибки сервера и отмену без настоящих HTTP-запросов. Практические примеры приложений и разработок можно посмотреть в каталоге проектов, где cross-platform подход сочетается с конкретными прикладными задачами.

Следует также следить за избыточным созданием задач. Task {} внутри цикла или при каждом изменении текста поиска может породить гонку ответов: старый запрос завершится позже нового и перезапишет актуальные данные. Для поиска обычно применяют отмену предыдущей задачи, debounce или AsyncStream, чтобы обрабатывать только нужные события.

Диагностика и безопасная практика

Ошибки конкурентного доступа не всегда проявляются сразу. Инструменты Xcode, Thread Sanitizer и строгие настройки Swift Concurrency помогают обнаруживать передачу несобезопасных значений между задачами. Предупреждения о Sendable не стоит бездумно подавлять: они показывают, что тип может быть изменён одновременно из разных контекстов.

Значение Sendable безопасно передавать между конкурентными областями. Структуры с неизменяемыми свойствами обычно соответствуют этому требованию естественно, а классы могут потребовать дополнительной синхронизации или изоляции актором. Особое внимание нужно уделять замыканиям, кэшам, singleton-объектам и моделям, которые используются несколькими задачами.

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