JavaRush /Курси /Swift SELF /URLSession.dataTask і completion handler

URLSession.dataTask і completion handler

Swift SELF
Рівень 63 , Лекція 1
Відкрита

1. Асинхронність мережевих запитів

Якщо ви тільки починаєте, то найприродніша мрія звучить так: «Я викличу fetchBooks(), вона поверне мені масив книг, і я піду далі». Мрія чудова — як «код без багів у пʼятницю ввечері». Але мережа працює повільно й непередбачувано: сервер може відповісти за 50 мс, за 5 с або не відповісти взагалі. Якщо зробити запит синхронно, програма просто зависне, очікуючи на відповідь.

Тому у Swift (і взагалі майже скрізь) класичний мережевий API влаштований так: ви запускаєте запит зараз, а результат отримуєте пізніше — через функцію зворотного виклику, тобто completion handler. Це і є модель зворотного виклику: «я повернуся до вас, чесно-чесно».

Щоб це відчувалося не містикою, а інженерією, тримайте просту ментальну картинку:

flowchart LR
    A[Ваш код] -->|створює задачу| B[URLSession]
    B -->|йде в мережу| C[Інтернет і сервер]
    C -->|відповідь / помилка| B
    B -->|викликає completion handler| A

Сигнатура dataTask і параметри completion handler

Найважливіший рядок цієї лекції — це сигнатура completion handler у URLSession.dataTask. У навчальному сенсі вона цінніша, ніж мотиваційні цитати.

У Swift вона виглядає так — спрощено за змістом, але точно за ключовими частинами:

// Спрощена ідея сигнатури з документації та еволюції Swift:
func dataTask(
    with url: URL,
    completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void
) -> URLSessionDataTask

Тобто обробник завершення отримує три значення, і кожне з них — Optional:

  • Data? — тіло відповіді (байти). Може бути nil.
  • URLResponse? — метадані відповіді. Може бути nil.
  • Error? — помилка транспорту (наприклад, немає мережі). Може бути nil.

Тут важливо не заплутатися: HTTP-помилка (наприклад, 404) — це найчастіше не Error. Це успішно отримана відповідь, просто зі статус-кодом, який означає «невдача» на рівні протоколу. А Error — це зазвичай ситуація, коли ми взагалі не змогли нормально виконати запит.

Щоб мозок не намагався зберігати це у вигляді каші, можна подивитися на це через маленьку таблицю:

Що прийшло Тип Що означає по-людськи
Тіло
Data?
«Ось байти, спробуйте інтерпретувати»
Відповідь
URLResponse?
«Ось інформація про відповідь (зокрема статус-код, але не завжди доступний напряму)»
Помилка
Error?
«Я не зміг виконати запит: мережа, DNS, таймаут, TLS тощо»

2. @escaping у completion handler

Коли ви бачите @escaping, компілятор не намагається зіпсувати вам настрій. Він намагається захистити вас від ситуації, коли замикання викличуть пізніше, а ви думали — зараз.

У Swift замикання за замовчуванням не escaping. Це означає: функція гарантує, що замикання буде використано всередині неї, поки вона ще виконується. Щойно функція завершилася — замикання більше ніде не зберігається.

Але URLSession влаштований інакше. Ви викликаєте dataTask(...), функція одразу повертається, а completion handler викличуть потім, коли мережа відповість. Отже, completion handler виходить за межі функції, тобто escape.

Спочатку — маленький приклад без мережі, щоб відчути ідею на сухому, але чесному коді.

Не-escaping: викликали й одразу завершили

func runNow(_ action: () -> Void) {
    action()
}

runNow {
    print("Я виконуюся просто зараз") // Я виконуюся просто зараз
}

Тут action не потрібно позначати @escaping, бо воно не переживає завершення runNow.

Escaping: зберегли на потім

var pendingActions: [() -> Void] = []

func storeForLater(_ action: @escaping () -> Void) {
    pendingActions.append(action)
}

storeForLater {
    print("Я виконаюся пізніше") // (поки нічого не друкує)
}

pendingActions.first?()        // Я виконаюся пізніше

Ось чому потрібен @escaping: ми зберегли замикання в масиві й викликали його пізніше, уже після завершення storeForLater.

Те саме робить URLSession

URLSession усередині себе робить приблизно так: «збережи completion handler десь у задачі, а коли все закінчиться — виклич його». Тому completion handler і позначений як @escaping. Це вбудована частина контракту API, а не декоративний атрибут.

3. resume(): задача не стартує сама

З URLSession є одна особливо часта пастка: ви думаєте, що «я створив запит — отже він уже пішов». А він не пішов.

dataTask повертає об’єкт URLSessionDataTask. Це «задача», але вона залишається на паузі, доки ви не викличете resume().

Давайте зробимо мінімальний приклад. Він компілюється, але важлива примітка: у CLI-застосунку (як наш LibraryCLI) програма може встигнути завершитися раніше, ніж мережа відповість. Цю проблему ми розв’яжемо в наступному розділі.

import Foundation

guard let url = URL(string: "https://example.com") else {
    fatalError("Недійсна URL-адреса")
}

let task = URLSession.shared.dataTask(with: url) { data, response, error in
    print("Обробник завершення викликано!")  // Обробник завершення викликано! (якщо програма ще працюватиме)
    print("Байти:", data?.count ?? 0)
}

task.resume() // Без цього рядка не станеться нічого.

Ментальна модель проста:

sequenceDiagram
    participant You as Ваш код
    participant Session as URLSession
    participant Task as URLSessionDataTask
    participant Net as Мережа

    You->>Session: dataTask(with:completion:)
    Session-->>You: повертає Task (ще не запущено)
    You->>Task: resume()
    Task->>Net: виконує запит
    Net-->>Task: відповідь/помилка
    Task-->>You: викликає completion handler

Якщо ви забули resume(), то створили «контейнер для запиту», але не запустили його. Це як купити квиток на потяг і залишитися вдома.

4. Completion handler: базовий порядок перевірок

У completion handler легко написати код, який інколи працює. Це небезпечний різновид працездатності: він гірший за код, який взагалі не працює, бо змушує сумніватися в реальності.

Хоча повну й красиву модель помилок ми зберемо в наступній лекції, уже зараз потрібно запам’ятати базовий порядок думок усередині completion handler. Ми не зобов’язані будувати ідеальну архітектуру, але зобов’язані не плутати причини невдачі.

Почнемо з каркаса:

import Foundation

func handleResponse(data: Data?, response: URLResponse?, error: Error?) {
    if let error {
        print("Помилка транспорту:", error)
        return
    }

    guard let httpResponse = response as? HTTPURLResponse else {
        print("Це не HTTP-відповідь")
        return
    }

    print("Статус-код:", httpResponse.statusCode)

    let bodySize = data?.count ?? 0
    print("Байтів у тілі:", bodySize)
}

Тут важливі одразу дві речі.

По-перше, ми перевіряємо error першою. Якщо транспортна помилка є, обговорювати статус-код безглуздо: можливо, відповіді взагалі немає.

По-друге, ми приводимо URLResponse до HTTPURLResponse через as?, а не через as!. Бо as! — це «я клянусь, що це HTTP». У мережевому коді клятви зазвичай закінчуються так само, як і більшість клятв у пʼятницю ввечері: «чому воно впало».

Поки що про статус-код говоримо коротко: ми його просто друкуємо. У наступній лекції ми перетворимо це на акуратний контракт, щоб код, який викликає, не ворожив за print-ами, що сталося. Але навіть зараз ви маєте відчувати: statusCode — це не «додаткова опція», а частина відповіді.

5. CLI-практика: дочекатися callback і зробити PingClient

У iOS-застосунку або серверному застосунку зазвичай є «життя» процесу: цикл подій крутиться, і callback встигає виконатися.

А CLI-програма часто працює так: виконала код верхнього рівня — і завершилася. Тому ви можете побачити таку ситуацію:

  • запит запустили,
  • resume() викликали,
  • а completion handler так і не спрацював, бо процес уже завершився.

Щоб навчально дочекатися відповіді (не будуючи поки повноцінну архітектуру), зручно використовувати DispatchSemaphore з таймаутом. Так, це трохи пахне синхронізацією, але ми використовуємо це як демонстраційний костиль, а не як стиль життя.

Як навчально дочекатися відповіді в CLI

import Foundation

let semaphore = DispatchSemaphore(value: 0)

guard let url = URL(string: "https://example.com") else {
    fatalError("Недійсна URL-адреса")
}

URLSession.shared.dataTask(with: url) { data, response, error in
    print("Отримано відповідь, байти:", data?.count ?? 0)
    semaphore.signal()
}.resume()

let timeout = DispatchTime.now() + 5
if semaphore.wait(timeout: timeout) == .timedOut {
    print("Вичерпано час очікування відповіді") // Вичерпано час очікування відповіді (якщо не встигли)
}

Тут сенс дуже простий: «почекати максимум 5 секунд, інакше вивести зрозуміле повідомлення». Ми не робимо з цього ідеальний мережевий шар. Ми лише гарантуємо, що програма не завершиться миттєво.

Мініінтеграція в LibraryCLI: PingClient на колбеках

Ми продовжуємо розвивати наш навчальний CLI-проєкт. Зараз нам потрібна дуже маленька утиліта: перевірити, що ми вміємо виконувати HTTP GET і отримувати відповідь. Це буде наш «перший мережевий крок», який пізніше стане цеглинкою для серйозніших команд.

Зробимо тип PingClient, який:

  • приймає URLRequest (щоб використовувати те, що ми вже зібрали в минулій лекції),
  • запускає dataTask,
  • повертає результат через completion handler.

Поки що не створюємо «правильні Result і NetworkError» — це буде в наступній лекції. Тут ми тренуємо саме механіку колбеків і @escaping.

import Foundation

struct PingClient {
    func ping(request: URLRequest, completion: @escaping (Data?, URLResponse?, Error?) -> Void) {
        URLSession.shared.dataTask(with: request) { data, response, error in
            completion(data, response, error)
        }.resume()
    }
}

Зверніть увагу на @escaping у параметрі completion. Ми зобов’язані додати цей атрибут, тому що всередині ping ми передаємо completion далі — до URLSession, а він викличе його пізніше.

Тепер приклад використання — умовно в main.swift, дуже спрощено і без повноцінного CLI-парсингу:

import Foundation

let semaphore = DispatchSemaphore(value: 0)

guard let url = URL(string: "https://example.com") else {
    fatalError("Недійсна URL-адреса")
}

var request = URLRequest(url: url)
request.httpMethod = "GET"

let client = PingClient()
client.ping(request: request) { data, response, error in
    if let error {
        print("Ping не вдався:", error)
        semaphore.signal()
        return
    }

    let code = (response as? HTTPURLResponse)?.statusCode ?? -1
    print("Статус Ping:", code)                 // наприклад: Статус Ping: 200
    print("Байт:", data?.count ?? 0)

    semaphore.signal()
}

_ = semaphore.wait(timeout: .now() + 5)

Це вже відчувається як справжня мережева робота:

  • запит зібрали,
  • клієнт виконав його,
  • результат прийшов у замикання,
  • програма дочекалася його — але не вічно.

Так, це ще не архітектура мрії. Але це чесна, мінімальна основа: ви розумієте, де перебуваєте в часі, і чому @escaping тут не випадковий.

6. Типові помилки

Помилка №1: забути викликати resume().
Це найпопулярніший «невидимий баг»: компілятор мовчить, код виглядає логічно, але запит не йде. У голові має бути правило: dataTask(...) створює задачу, а resume() запускає її. Якщо немає resume() — ви просто створили об’єкт.

Помилка №2: вважати, що код після dataTask виконається «після запиту».
Completion handler викличуть пізніше, тому рядки після dataTask(...).resume() виконаються одразу. Це дає дивний ефект: ви друкуєте «Готово», а запит лише починається. Лікується це просто: усе, що залежить від результату, живе всередині колбека (або в наступній лекції — усередині контракту Result).

Помилка №3: перевіряти data й ігнорувати error.
Іноді пишуть: якщо data не nil, отже успіх. Насправді під час помилки data може бути nil, а error міститиме справжню причину. І навпаки: під час HTTP-помилки data може бути, але це не «успішний результат». Правильна звичка: спочатку error, потім response (приведення до HTTPURLResponse), потім статус-код, потім робота з data.

Помилка №4: робити response as! HTTPURLResponse.
Такий код перетворює мережеву нестабільність на краш програми. as! — це контракт «я впевнений». У мережі впевненість зазвичай не нагорода, а джерело нових вражень. Використовуйте as? і обробляйте ситуацію, коли відповідь не вдалося інтерпретувати як HTTP.

Помилка №5: у CLI не дочекатися completion handler і вирішити, що «інтернет не працює».
Якщо ви тестуєте це в командному рядку, програма може завершитися раніше, ніж надійде відповідь. Це не проблема мережі, а проблема життєвого циклу процесу. Для навчальних прикладів використовуйте очікування через семафор із таймаутом, щоб результат устиг надрукуватися, а застосунок не зависав вічно.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ