От конфигурации сессии и сборки URLRequest до фоновых задач, делегатов, WebSocket, кэша и грамотного завершения. Разбор каждой стадии, лучшие практики и нестандартные кейсы — всё, что должен знать iOS-разработчик о главном сетевом API системы.
URLSession — это центральный API Foundation для работы с HTTP/HTTPS, FTP, WebSocket и фоновой передачей данных. Внутри Apple-платформ он стоит на CFNetwork, на Linux — на собственной реализации Swift Foundation. Когда мы видим в коде URLSession.shared.data(from:) — за этой одной строкой скрывается длинный конвейер: создание задачи, разрешение DNS, TLS-хендшейк, отправка заголовков, потоковое чтение тела ответа, обновление кэша, вызов делегатов и финальная очистка ресурсов.
Если бы это был UIKit, мы бы говорили про viewDidLoad → viewWillAppear → viewDidAppear. Если SwiftUI — про init → body → onAppear → onDisappear. У URLSession свой собственный, не менее структурированный жизненный цикл — и тот, кто знает его в деталях, пишет код, который не падает в плохом метро, корректно докачивает файлы при перезапуске приложения и не съедает у пользователя гигабайт трафика на повторные запросы.
Эта статья — попытка пройти по этому циклу шаг за шагом. Цикл удобно раскладывать на семь стадий: на каждой можно показать анимациями, что именно происходит, и связать её с реальными проблемами, с которыми мы сталкиваемся в продакшене — фоновая загрузка, обрыв соединения, обработка HTTPURLResponse, отмена при скролле, защита от повторных запросов, retry с экспоненциальным бэкоффом, корректное завершение сессий. Многие из этих тем подробно разбирались в отдельном посте о URLSession в канале — здесь мы соберём их в одну карту.
Почему вообще стоит знать API настолько глубоко, если есть Alamofire, AsyncHTTPClient, Moya, MultipartFormDataKit? Потому что все они построены поверх. Когда что-то ломается — а оно обязательно ломается — отлаживать всегда приходится в терминах URLSession, URLSessionDelegate, URLSessionTaskMetrics. Знание базового API — это не альтернатива библиотекам, это фундамент. И это лучшее, что вы можете сделать для своего сетевого слоя.
Ещё один аргумент: с появлением Swift Concurrency (см. пост про Concurrency) URLSession получил полноценный async/await-интерфейс, а с iOS 18 — улучшенную поддержку фоновых сессий и более удобный API метрик. Старое наследие никуда не делось: делегаты, конфигурации, кэширование, фоновая идентификация — всё это работает и сегодня, просто часть упаковок стала лаконичнее. Чтобы выбирать осознанно, нужно видеть всю картину.
Дальше будет много кода, диаграмм и анимаций. Если читаете с телефона — листайте без спешки, диаграммы рассчитаны и на мобильный экран. Если читаете с компьютера — обратите внимание на главную анимацию ниже: она показывает все семь стадий жизненного цикла в одном экране и подсвечивает каждую по очереди.
Ниже — главная анимация статьи. Она показывает все семь стадий, через которые проходит каждый сетевой запрос. Стадии подсвечиваются по очереди: сначала система готовит конфигурацию, затем собирается URLRequest, создаётся задача, вызывается resume(), идёт сетевая работа, обрабатывается ответ и, наконец, всё завершается — успехом или ошибкой.
.suspended.URLCache, валидация статуса.Эта схема — каркас всей статьи. Каждой стадии посвящён отдельный раздел ниже: со своей анимацией, кодом и предостережениями. Можно читать линейно, можно прыгать по содержанию. Но прежде чем нырять в детали, заметим важное: жизненный цикл — не строго линейный. На любом этапе задача может перейти в .canceling, на седьмом — рестартовать с нуля (retry), на пятом — приостановиться и продолжиться позже (background). Анимация показывает «счастливый путь», но любая реальная сетевая работа — это граф, а не лента.
Конфигурация сессии задаётся один раз, в момент создания сессии. Менять её у живой сессии нельзя — Apple явно пишет об этом в документации. Все мутации делаются на новой сессии или через URLRequest, если параметр доступен на уровне запроса.
Сетевой жизненный цикл начинается не с URL, а с того, в каком мире мы будем жить. URLSessionConfiguration описывает правила игры: разрешено ли использовать сотовую сеть, как кэшировать ответы, сколько секунд ждать соединения, какие сервисы устанавливать в качестве прокси, какие cookies переиспользовать. Это «вселенная» для всех будущих задач, которые мы создадим у этой сессии.
Foundation предоставляет три типичные конфигурации — это заготовки, которые покрывают 90% случаев. Все они мутабельны до момента создания сессии. Дальше — read-only.
Самая универсальная сессия. Использует системные cookies, кэш на диске и в памяти, постоянное хранилище учётных данных. Подходит для большинства REST-вызовов.
URLCache cookies credentialsНичего не пишет на диск. Ни кэш, ни cookies, ни credentials. Идеальна для приватного браузера, одноразовых сессий, токен-обмена.
in-memory no disk privacyЗагрузки и выгрузки, которые система продолжит выполнять, даже если приложение убито. Требует уникальный идентификатор и обязательный делегат.
background discretionary launch-on-finishЭту тройку важно держать в голове. Если вы пишете API-клиент общего назначения — стартуйте с .default. Если делаете отдельную «инкогнито»-секцию или хотите гарантировать, что сервер не получит ваши старые cookies — берите .ephemeral. Если задача переживёт жизнь приложения — нужна .background.
let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = 15 // сек до неактивности config.timeoutIntervalForResource = 60 // сек на всю задачу config.httpMaximumConnectionsPerHost = 4 config.allowsCellularAccess = true config.allowsExpensiveNetworkAccess = false // hotspot, дорогой роуминг config.allowsConstrainedNetworkAccess = false // Low Data Mode config.waitsForConnectivity = true // ждать сеть, а не падать сразу config.requestCachePolicy = .returnCacheDataElseLoad config.httpAdditionalHeaders = ["X-Client": "iOS-3.4"] let session = URLSession(configuration: config)
Несколько важных пунктов, которые часто упускают:
timeoutIntervalForRequest — это таймаут между сетевыми событиями. То есть если данные капают по чуть-чуть, таймер сбрасывается. Не путайте с общим временем загрузки.timeoutIntervalForResource — общий бюджет на всю задачу. Если 60 секунд истекли, а файл ещё не докачался — задача упадёт. Для крупных загрузок ставьте 1–7 дней. Серьёзно — Apple рекомендует дни.waitsForConnectivity — флаг, изменивший правила игры. При true URLSession не упадёт мгновенно при отсутствии сети, а будет ждать, пока появится соединение. Это и есть бесшумный способ переждать туннель в метро. Подробно — в отдельном посте про waitsForConnectivity.allowsExpensiveNetworkAccess и allowsConstrainedNetworkAccess — респект к настройкам пользователя. Если человек включил Low Data Mode, он явно не хочет, чтобы приложение тянуло мегабайты картинок.httpMaximumConnectionsPerHost — по умолчанию небольшое (Apple документирует значение 4). Поднимать стоит редко: HTTP/2 и так мультиплексирует, а серверу неприятно ловить десятки соединений с одного клиента.URLSession.shared — нормальный выбор для разовых запросов в коде вне сетевого слоя: «дёрнуть фид статусов», «получить превью». Но как только речь о центральном API-клиенте, лучше создавать свою сессию с явной конфигурацией: тогда легко менять политику кэша, добавлять авторизационные заголовки в один заход, тестировать через URLProtocol-моки и подменять её в DI-контейнере.
Сессии — это дорогие объекты. Не создавайте новую URLSession для каждого запроса: внутри пул соединений, который теряется при пересоздании. Один синглтон на сетевой клиент — норма.
На этом этапе мы ещё не отправили ни одного байта. Но именно решения, принятые здесь, определят, как поведёт себя приложение в плохом интернете, в Low Data Mode, в фоне и при перезапуске устройства.
Запрос — это конкретное «куда» и «как». Если сессия задаёт мир, то URLRequest описывает один конкретный поход в этот мир. У запроса есть URL, метод, заголовки, тело, политика кэша, политика повторных перенаправлений, политика сетевого сервиса (например, .responsiveData — для запросов, важных пользователю прямо сейчас).
Самая частая боль — пытаться склеить URL строками. Это работает, пока у вас не появятся пробелы, кириллица, query-параметры с амперсандами или ASCII-несовместимые символы. Современный подход — URL.appending(queryItems:) и URLComponents. Это рассматривалось отдельно в посте про современные практики работы с URL: там есть примеры, как Apple постепенно убирает легаси-инициализаторы и заводит безопасные альтернативы.
var components = URLComponents(string: "https://api.example.com/v3/items")! components.queryItems = [ URLQueryItem(name: "q", value: "привет мир"), URLQueryItem(name: "page", value: "1"), URLQueryItem(name: "per", value: "20"), ] var request = URLRequest(url: components.url!) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.httpBody = try JSONEncoder().encode(payload) request.timeoutInterval = 10 request.cachePolicy = .reloadIgnoringLocalCacheData
На уровне запроса cachePolicy позволяет переопределить решение, заданное в конфигурации:
.useProtocolCachePolicy — по умолчанию: следовать заголовкам Cache-Control с сервера..reloadIgnoringLocalCacheData — всегда идти на сервер..returnCacheDataElseLoad — попробовать кэш, при отсутствии — сходить на сервер..returnCacheDataDontLoad — только кэш, иначе ошибка. Удобно в офлайн-режимах.Не все знают, но политика «можно ли по сотовой» задаётся и на запросе через request.allowsCellularAccess. Если конфигурация разрешает, а конкретный «тяжёлый» запрос мы хотим выполнять только по Wi-Fi — выставляем флаг прямо на нём. Сочетание двух уровней даёт точный контроль: «качай прелоад изображений по Wi-Fi, а CRUD-операции — по любой сети».
Не складывайте логику авторизации в каждое место, где создаётся URLRequest. Заведите построитель (RequestBuilder), который принимает на вход эндпоинт и сам подмешивает токены, общий User-Agent, корреляционный ID и язык. Любая попытка добавить заголовок в десятке мест заканчивается одним «забыли — упало в проде».
Сессия и запрос — это описания. Задача — это исполнение. URLSession предоставляет несколько типов задач, и выбор правильного типа влияет на всё: на потребление памяти, на возможность выполнять работу в фоне, на способ обработки данных.
| Тип задачи | Когда использовать | Особенности |
|---|---|---|
URLSessionDataTask |
REST/JSON, маленькие и средние ответы, in-memory. | Данные накапливаются в памяти. Не работает в фоне. |
URLSessionDownloadTask |
Загрузка файлов, медиа, обновлений. | Пишет во временный файл. Можно в фоне. |
URLSessionUploadTask |
Отправка файлов, мультипарт-форм, бэкап. | Поддерживает fromFile, что важно для фоновой выгрузки. |
URLSessionStreamTask |
Произвольный TCP-стрим над URL (редкие случаи). | Низкоуровневое, чаще берут Network.framework. |
URLSessionWebSocketTask |
Двунаправленный обмен сообщениями. | iOS 13+. Сам управляет фреймингом и пингами. |
Правило простое: под крупные файлы — download/upload, под JSON — data, под двусторонний поток — websocket. Многие используют data-задачу для всего, и в итоге получают «я не могу запустить загрузку в фоне» или «память на 200 МБ улетела на одном файле». Подробный разбор того, какой тип под какие задачи подходит, есть в отдельном посте про DownloadTask.
// 1. Completion handler (старый, простой, до сих пор живой) let t1 = session.dataTask(with: request) { data, response, error in // колбэк зовётся на delegateQueue (по умолчанию — фоновая) } t1.resume() // 2. Делегат (полный контроль, прогресс, аутентификация) let t2 = session.downloadTask(with: request) t2.resume() // 3. async/await (iOS 15+, самый чистый) let (data, response) = try await session.data(for: request)
Самое важное на этом этапе: задача не стартует сама. Создав её, вы получаете объект в состоянии .suspended. Запуск — отдельным шагом, resume(). Это сделано намеренно: вы можете повесить делегата, прицепить метаданные, поменять приоритет, добавить в свой реестр для отмены — и только после этого нажать «старт».
Метод dataTask(with:) возвращает задачу, но не запускает её. Если забыть resume(), запрос просто никогда не уйдёт — а в логах ничего не появится. Это самая частая «ничего не работает, и я не понимаю почему».
Метод resume() переводит задачу из .suspended в .running. С этого момента сессия отдаёт работу в свой внутренний пул, и URLSession готов к жизни в реальном мире. У задачи есть стейт-машина из четырёх состояний, и нам важно понимать, между какими переходы возможны, а между какими — нет.
.suspended в .running переходим через resume(). Можно вызывать многократно — лишние вызовы игнорируются..running в .suspended — через suspend(). Это «пауза»: сетевые соединения сохраняются, но мы перестаём читать. Удобно при потере фокуса экрана для тяжёлых запросов..canceling переходим из любого состояния, кроме .completed. Это финальное направление: отменили — не запустится снова, нужно создавать новую задачу..completed — конечное состояние. Туда мы приходим после делегата urlSession(_:task:didCompleteWithError:).Знание этих переходов критично для пары проблем:
cancel(), потом сразу resume() на той же задаче — а она уже мёртвая. Нужно создавать новую.suspend() часто путают с cancel(). Если позвать suspend и забыть — задача висит в пуле, потребляет ресурсы и держит TLS-соединение.// Отмена при уходе с экрана — классика override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) currentTask?.cancel() } // Приоритет — простая, но недооценённая фича task.priority = URLSessionTask.highPriority // 1.0 task.priority = URLSessionTask.defaultPriority // 0.5 task.priority = URLSessionTask.lowPriority // 0.0
Приоритет — подсказка планировщику. Если у вас параллельно тянутся скучные превью и важный «контент текущего экрана» — повышайте приоритет последнего. Это особенно заметно при медленной сети, где порядок выгрузки превращается в порядок UX.
Это самый длинный этап и единственный, который мы реально не контролируем. Внутри происходит:
Каждый шаг занимает время и может провалиться. Apple даёт нам прекрасный инструмент для замеров — URLSessionTaskMetrics. Это объект, который приходит в делегата после завершения задачи и содержит детальные тайминги для каждого «транзакционного» соединения внутри неё.
func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { for m in metrics.transactionMetrics { print("DNS: \(m.domainLookupEndDate!.timeIntervalSince(m.domainLookupStartDate!))") print("TLS: \(m.secureConnectionEndDate!.timeIntervalSince(m.secureConnectionStartDate!))") print("TTFB: \(m.responseStartDate!.timeIntervalSince(m.requestStartDate!))") print("Protocol: \(m.networkProtocolName ?? \"-\")") } }
Логирование метрик в проде — лучший способ найти, где именно проседает сеть. TTFB (time to first byte) — это про сервер. DNS и TLS — про инфраструктуру и провайдера. Полезно дополнительно вытаскивать networkProtocolName — там можно увидеть h2, h3 или http/1.1 и понять, кто из бэкенда не перешёл на HTTP/2.
Когда речь о большом ответе, важно понимать: с completion handler URLSession не отдаст вам байты по мере прихода. Он соберёт ответ полностью, потом вызовет колбэк. Если вы хотите стримить — нужен делегат и метод urlSession(_:dataTask:didReceive data: Data). С iOS 15 появился изящный async-вариант: session.bytes(for:) — он возвращает AsyncSequence байтов, и можно итерироваться по строкам.
let (bytes, _) = try await session.bytes(for: request) for try await line in bytes.lines { // каждая строка SSE-стрима, лога, NDJSON process(line) }
Server-Sent Events и NDJSON-стримы (например, ответы от LLM) идеально стримятся через .lines. Это пара строк против самописного парсера.
Когда сервер прислал заголовки, URLSession может позвать делегата urlSession(_:dataTask:didReceive response:completionHandler:) — и спросить нас, продолжать ли. В большинстве случаев мы отвечаем .allow, но это место, где можно отменить заведомо плохие ответы (например, статус 401 без права на retry).
Возвращаемый URLResponse — это абстрактный тип. У HTTP-запросов он на самом деле HTTPURLResponse, и от его статуса зависит, считаем ли мы запрос успешным. URLSession сам считает любой ответ с заголовками «успешным» — даже 500. Ошибку он выдаст только при сетевой проблеме (соединение разорвалось, TLS не сошёлся). Поэтому валидация — на нашей стороне.
struct HTTPError: Error { let status: Int; let data: Data } func validate(_ data: Data, _ response: URLResponse) throws -> Data { guard let http = response as? HTTPURLResponse else { throw URLError(.badServerResponse) } switch http.statusCode { case 200...299: return data case 401: throw AuthError.expired case 429: throw HTTPError(status: 429, data: data) default: throw HTTPError(status: http.statusCode, data: data) } }
В отдельном посте разобрано, как удобно превращать коды ошибок в человекочитаемые сообщения через HTTPURLResponse.localizedString(forStatusCode:). Это статический метод, который возвращает локализованную строку вроде «not found» или «request timeout». Удобно для лога, не для пользователя.
Если сервер прислал Cache-Control: max-age=3600, а у вас URLCache на 50 МБ диска, следующий идентичный запрос даже не пойдёт в сеть. Это бесплатная оптимизация, которую любит ронять кастомный сетевой клиент.
Запрос совпал по URL и валидным заголовкам. Ответ берётся из URLCache и колбэк отрабатывает мгновенно.
Нет валидной записи. Запрос уходит в сеть и, в случае успеха, оседает в кэше для следующего вызова.
Тонкость: URLCache по умолчанию кэширует только ответы на безопасные методы — практически это GET. Это следствие HTTP cache semantics из RFC 7234 §4, которому подчиняется реализация (см. также правила кэширования в Apple Developer · URLCache и условия вызова willCacheResponse). POST вы, как правило, кэшируете самостоятельно — в памяти или через отдельную БД. И не забывайте про URLRequest.cachePolicy: иногда нужно явно сказать «иди в сеть», например при pull-to-refresh.
URLSession сама обрабатывает 30x по умолчанию. Если хочется вмешаться (например, заменить редирект на собственный экран авторизации), реализуем urlSession(_:task:willPerformHTTPRedirection:newRequest:completionHandler:). Можно подставить нужный URLRequest или передать nil, чтобы редирект не выполнился.
Парсить тело ответа до проверки HTTPURLResponse.statusCode. Сервер может вернуть HTML-страницу ошибки или пустой JSON — и ваш JSONDecoder сломается на «непонятный» декод, хотя истинная проблема — 502.
Финальная стадия — это .completed. Сюда приходим тремя способами: успех, ошибка, отмена. Все три ветви обязательно вызывают urlSession(_:task:didCompleteWithError:), где error — либо nil при успехе, либо инстанс URLError.
URLError — это Error с богатой информацией. Полезные коды:
.notConnectedToInternet — сеть выключена..timedOut — таймаут запроса или ресурса..cancelled — задачу отменили (вы или система)..networkConnectionLost — соединение оборвалось во время передачи..cannotFindHost / .cannotConnectToHost — DNS или TCP..dataNotAllowed — режим экономии данных, политика конфигурации..appTransportSecurityRequiresSecureConnection — ATS отказал.Слепо повторять запрос — плохая стратегия. Хороший retry смотрит на код ошибки и решает: имеет ли смысл повторять, через сколько и сколько раз.
func retrying<T>(max: Int = 4, _ op: () async throws -> T) async throws -> T { var attempt = 0 while true { do { return try await op() } catch let err as URLError where shouldRetry(err) { attempt += 1 guard attempt <= max else { throw err } let base = pow(2.0, Double(attempt - 1)) * 0.5 let jitter = Double.random(in: 0...0.3) try await Task.sleep(for: .seconds(base + jitter)) } } }
Главный приём — jitter. Если 10 000 клиентов попадают в одну ошибку 503 и все повторяют через ровно 1 секунду, вы получаете «грозовую тучу». Случайная добавка размазывает нагрузку.
Используйте Idempotency-Key в заголовках для POST/PUT, чтобы безопасно повторять. Многие современные API поддерживают этот заголовок «из коробки» — см., например, Stripe Idempotent Requests и Square Idempotency. Точные имена и формат у каждого свои (где-то встречается Idempotence-Key с другим написанием), сверяйтесь с документацией конкретного сервиса.
Мы кратко прошлись по конфигурациям в начале. Теперь — глубокое сравнение, потому что от выбора зависит судьба ваших данных. Особенно интересен .background — это не «обычная сессия, но в фоне», это принципиально другой режим работы, в котором даже общение с задачами идёт через делегата.
| Свойство | .default | .ephemeral | .background |
|---|---|---|---|
| Хранение кэша | диск | память | диск |
| Cookies | системные | в памяти | системные |
| Credentials | Keychain | сессия | Keychain |
| Работа в фоне | нет | нет | да |
| Completion handler | да | да | нет, только делегат |
| Идентификатор | не нужен | не нужен | обязателен и уникален |
| Что внутри задачи | любые типы | любые типы | upload/download из файла |
| discretionary | нет | нет | да (Wi-Fi, заряд) |
Идеален в трёх случаях: режим «инкогнито», тестовые сборки, временные обмены токенами (там, где не нужны cookies между запросами). Ещё это удобный способ убедиться, что ваш слой авторизации не «забывает» куки от предыдущей сессии — стартуйте интеграционные тесты в ephemeral.
Фоновая сессия — это контракт с системой: «продолжай этот upload/download, даже если приложение умрёт, и разбуди приложение, когда закончится». Платой за это становятся ограничения: нельзя ставить тело запроса в память, нужно ставить файлом; нельзя использовать completion handler, только делегата; appDelegate должен сохранить backgroundCompletionHandler, который система вызывает на пробуждении. Тонкости — в посте про подводные камни фоновой загрузки.
let id = "com.iosiq.bgsession" let bgConfig = URLSessionConfiguration.background(withIdentifier: id) bgConfig.isDiscretionary = true // система выберет момент (Wi-Fi, зарядка) bgConfig.sessionSendsLaunchEvents = true // разбудить приложение bgConfig.isWaitingForConnectivity = true let session = URLSession(configuration: bgConfig, delegate: BackgroundDownloader.shared, delegateQueue: nil)
Важная деталь: после перезапуска приложения вы пересоздаёте сессию с тем же идентификатором. URLSession при этом возвращает уже выполняющиеся задачи через делегата. Никакого «сохранить ID задачи в UserDefaults и потом достать» — система сама помнит весь свой контекст.
В фоновой сессии запрещены задачи с телом из Data. Только URLSessionUploadTask с fromFile:. Если попробуете передать httpBody — система молча проигнорирует или вернёт ошибку. Это не баг, это намеренно: фоновый демон не должен полагаться на in-memory данные приложения.
Когда вы используете completion handler, делегаты у задачи всё равно вызываются — просто вы их не видите. Если же передать сессии собственный URLSessionDelegate, открывается доступ к десяткам колбэков. Это «низкоуровневый» API, и именно он позволяет реализовать прогресс загрузки, аутентификацию по сертификату, кастомные редиректы.
Иерархия делегатов:
URLSessionDelegate — общие события сессии.URLSessionTaskDelegate — события на уровне задачи (любой).URLSessionDataDelegate — события data/upload-задач.URLSessionDownloadDelegate — события download-задач.URLSessionWebSocketDelegate — события WebSocket.URLSessionStreamDelegate — события stream-задач.Самый востребованный делегат — didReceive challenge. Его зовут на каждое «защищённое» событие: сервер просит сертификат, прокси — пароль, HTTPS — проверку pinned-сертификата.
func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @Sendable @MainActor (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, let trust = challenge.protectionSpace.serverTrust else { completionHandler(.performDefaultHandling, nil) return } if isTrusted(serverTrust: trust) { completionHandler(.useCredential, URLCredential(trust: trust)) } else { completionHandler(.cancelAuthenticationChallenge, nil) } }
SSL pinning — отдельная большая тема. Коротко: вы сравниваете отпечаток сервера с зашитым в приложение. Если совпало — пускаем. Иначе — отказ. Это блокирует MITM-атаки в публичных сетях и подобные сценарии.
С iOS 15 у нас три практически эквивалентных по возможностям async-метода:
session.data(for:) — получить (Data, URLResponse).session.download(for:) — получить (URL, URLResponse) с локальным файлом.session.upload(for:from:) — отправить и получить ответ.session.bytes(for:) — потоковый AsyncSequence байтов.Каждый из них поддерживает кооперативную отмену через Task.cancel(). То есть никакого «достать dataTask, чтобы отменить руками» — отмена идёт по дереву задач Swift Concurrency.
struct APIClient { let session: URLSession func items(page: Int) async throws -> [Item] { var request = URLRequest(url: URL(string: "https://api.example.com/items?page=\(page)")!) request.setValue("application/json", forHTTPHeaderField: "Accept") let (data, response) = try await session.data(for: request) try validate(data, response) return try JSONDecoder().decode([Item].self, from: data) } } // Использование let task = Task { let items = try await api.items(page: 1) render(items) } // task.cancel() — корректная отмена сетевого запроса по цепочке
В посте про API-клиент на async/await разобран паттерн «один билдер запроса + один валидатор + один декодер». Это базовый каркас, который позволяет не плодить десятки сетевых функций.
С async/await параллелить — одно удовольствие:
async let profile = api.profile(id: userID) async let feed = api.feed() async let notif = api.notifications() let (p, f, n) = try await (profile, feed, notif)
Все три запроса стартуют одновременно, мы ждём всех. Если какой-то упал — упадёт и общая операция, остальные будут отменены через Task.cancel().
Не отказывайтесь от старого делегатского API целиком. async-методы — для большинства случаев, но если нужны метрики, прогресс или сложная аутентификация — делегат всё ещё нужен. Можно создать сессию с делегатом и при этом дёргать у неё data(for:).
Мы уже видели анимацию состояний. Здесь — практические рецепты для каждого перехода.
Стандартный путь. Можно осознанно создавать задачи в .suspended, складывать в очередь и запускать партиями — например, при возобновлении доступа к сети.
«Пауза, но не отмена». Используется редко — когда вы хотите придержать загрузку, но не хотите терять прогресс. Например, при просмотре фотогалереи: пользователь свайпает быстро, и вы ставите тяжёлые preview на паузу, чтобы дать дорогу видимым.
Самая частая отмена — при уходе с экрана. Для download-задач есть особый рецепт: cancel(byProducingResumeData:). Он отменяет задачу, но даёт Data, по которой можно возобновить загрузку позже:
downloadTask.cancel(byProducingResumeData: { resumeData in // сохранить на диск store(resumeData) }) // позже: let resumed = session.downloadTask(withResumeData: load()) resumed.resume()
Это «святой грааль» для загрузчиков медиа: пользователь свернул приложение, через час открыл — и фильм качается с того места, где остановился.
Финальный шаг отмены. URLError(.cancelled) приходит в делегата. Важно: ваша completion-функция тоже зовётся — обработайте отмену тихо, без алертов «что-то пошло не так».
if let urlErr = error as? URLError, urlErr.code == .cancelled { return // никаких сообщений пользователю }
URLSessionWebSocketTask — официальный способ работать с WebSocket в iOS 13+. Это аккуратная замена сторонним библиотекам вроде Starscream для большинства случаев. Сообщения двух типов — .string и .data. Подробно работу через async-итератор можно посмотреть в этом посте.
let task = session.webSocketTask(with: URL(string: "wss://api.example.com/ws")!) task.resume() // отправка try await task.send(.string("{\"event\":\"subscribe\"}")) // чтение через async-цикл while true { let message = try await task.receive() switch message { case .string(let text): handleText(text) case .data(let data): handleData(data) @unknown default: break } }
Хорошие практики WebSocket:
URLSessionWebSocketTask сам пинги не шлёт — это делается через sendPing(pongReceiveHandler:) по таймеру. На входящий ping от сервера задача автоматически отвечает pong, но инициатива «живости» соединения остаётся на стороне клиента.URLSession, не путайте с HTTP-клиентом.WebSocket-задача не возобновится по resume-data, как download. Если соединение упало — это новое соединение, новая задача, новая аутентификация. Учитывайте это в дизайне протокола: восстанавливайте состояние через идемпотентные subscribe-сообщения.
Этот раздел стоит выделить отдельно. Многие команды тратят недели на собственный кэш, тогда как URLCache уже встроен и работает на двух уровнях — память и диск.
let memory = 20 * 1024 * 1024 // 20 МБ let disk = 200 * 1024 * 1024 // 200 МБ let cache = URLCache(memoryCapacity: memory, diskCapacity: disk, directory: nil) config.urlCache = cache
Что важно знать:
Cache-Control, ETag, Last-Modified.cache.storeCachedResponse(_:for:) — например, для оптимистичных предзагрузок.URLCache.shared.removeAllCachedResponses(). Удобно при логауте.В канале есть отдельный разбор кэширования — целая статья со схемами стратегий обновления: TTL, версионирование, инвалидация при логауте, плюсы и минусы каждого подхода. Здесь не повторяемся, но URLCache — это первый слой, и часто его одного хватает.
До iOS 11 поведение по умолчанию было драматичным: запрос без сети падал с .notConnectedToInternet, и приложению приходилось самому подписываться на reachability, ждать, перезапускать. С появлением waitsForConnectivity мы можем переложить эту работу на систему.
Главное место для понимания — делегат urlSession(_:taskIsWaitingForConnectivity:). Его зовут, когда сети нет и задача ждёт. В этот момент вы можете показать ненавязчивый индикатор «ждём интернет» и обновлять его, если ситуация затянется.
config.waitsForConnectivity = true config.timeoutIntervalForResource = 60 * 60 // 1 час, чтобы реально подождать extension MyNetworkDelegate: URLSessionTaskDelegate { func urlSession(_ session: URLSession, taskIsWaitingForConnectivity task: URLSessionTask) { Logger.net.info("Waiting for connectivity for \(task.currentRequest?.url?.host ?? \"-\")...") } }
Если у вас «вечно» бывают пользователи в метро — выставьте waitsForConnectivity = true и timeoutIntervalForResource побольше. UX мгновенно улучшится: люди привыкли, что приложения сразу пишут «нет сети», но ничто не мешает работать иначе.
Повторим важное: не создавайте сессии под каждый запрос. Один синглтон на API-клиент — норма. Если у вас несколько контекстов (основной API, аналитика, фоновая загрузка) — заведите по сессии на каждый.
В проде включите сбор URLSessionTaskMetrics и шлите в систему мониторинга. Время DNS, TLS, TTFB, размер тела, протокол — это ваши «глаза» на стороне клиента. Без них вы будете долго гадать, у кого проблема — у бэка, у провайдера или у вашего кода.
Никаких голых URLRequest в репозиториях. Заведите Endpoint-протокол или enum со всеми эндпоинтами. Это даст вам автодополнение, единый стиль, лёгкое тестирование.
Mock-сессий через подмену shared — это плохо. URLProtocol позволяет перехватывать запросы прозрачно и подсовывать любые ответы. Подключается в URLSessionConfiguration.protocolClasses, и тесты выполняются без сети.
final class MockURLProtocol: URLProtocol { static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? override class func canInit(with request: URLRequest) -> Bool { true } override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } override func startLoading() { guard let handler = MockURLProtocol.handler else { return } do { let (response, data) = try handler(request) client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) client?.urlProtocol(self, didLoad: data) client?.urlProtocolDidFinishLoading(self) } catch { client?.urlProtocol(self, didFailWithError: error) } } override func stopLoading() {} }
Когда пользователь уходит со списка, отменяйте все его сетевые задачи. Это и про экономию батареи, и про то, чтобы не получить «опоздавший ответ», который перезапишет состояние нового экрана.
POST по сути неидемпотентен — повторный вызов может создать дубль. Если ваш API поддерживает Idempotency-Key — используйте. Если нет — сделайте свой идентификатор на клиенте (UUID) и логируйте, какие операции уже завершились.
Если пользователь включил режим экономии, не качайте предзагрузки. allowsConstrainedNetworkAccess = false — это уважение, а не ограничение.
Сервер может прислать локализованную ошибку, и завтра она будет на другом языке. Парсите по кодам, по структуре. Тексты — для пользователя.
Не создавайте JSONDecoder() на каждый запрос. Это не критично, но за тысячи запросов накапливается. И не забывайте про .iso8601, .convertFromSnakeCase и кастомные стратегии.
Если ваши делегаты делают что-то заметное (логируют, парсят), не лейте всё в main thread. Передайте сессии собственный OperationQueue с разумным maxConcurrentOperationCount.
Одна сессия на клиент, метрики в проде, обёртка над URLRequest, тесты через URLProtocol, отмена на каждом экране, идемпотентность POST, уважение Low Data Mode, разумный JSONDecoder, отдельный delegateQueue.
Включаем isDiscretionary = true у фоновой сессии — и iOS сама выбирает момент: предпочтительно Wi-Fi, в идеале с зарядкой. Идеально для обновлений контента, словарей, моделей машинного обучения. Пользователь не платит за мобильный трафик, а вы получаете гарантированную загрузку — пусть и не мгновенную.
timeoutIntervalForResource может быть огромным — Apple сама пишет, что 7 дней нормально. В сочетании с waitsForConnectivity и фоновой сессией вы получаете «качай, когда сможешь» — пользователь начал загрузку фильма в самолёте, открыл приложение через два дня — и фильм уже на диске.
Зарегистрируйте свой URLProtocol, который проверяет наличие записи в локальной БД и отдаёт её как ответ. Сетевой код не меняется — он просто думает, что запрос ушёл и вернулся. Удобно для офлайн-первых приложений и тестовых сборок без интернета.
С iOS 14+ можно настроить DoH (или DoT) на уровне отдельного приложения — через NEDNSSettingsManager из NetworkExtension либо через privacy-контекст в Network.framework (nw_privacy_context_t). После активации все запросы URLSession в этом приложении пойдут через зашифрованный резолвер, а в URLSessionTaskMetrics можно увидеть, сколько занял DNS.
URLSession не поддерживает unix://-схему напрямую: официально работают только HTTP(S), FTP, file и data URL. Unix-сокеты умеет Network.framework и серверные библиотеки вроде AsyncHTTPClient. Если нужна локальная межпроцессная коммуникация в обычном iOS-приложении, проще поднять loopback-HTTP-сервер на http://127.0.0.1:port и ходить туда обычными data-задачами — весь существующий сетевой слой переиспользуется без изменений.
Иногда мы знаем, что после первого ответа будет ещё пять — например, листинг страниц. Можно предзагрузить следующую страницу в момент, когда пользователь дочитал до середины текущей. Маленький приоритет (URLSessionTask.lowPriority) + параллельный запуск — и переходы становятся мгновенными.
Внутренний таймаут URLSession — это таймаут сети. Но бывает, что вы хотите ограничить операцию по времени бизнес-логики (например, поиск должен ответить за 2 секунды, иначе мы показываем «пока пусто»). Оборачиваем async-запрос в Task и сами вызываем cancel() через Task.sleep(for: .seconds(2)).
func withTimeout<T>(_ seconds: Double, _ op: @escaping () async throws -> T) async throws -> T { try await withThrowingTaskGroup(of: T.self) { group in group.addTask { try await op() } group.addTask { try await Task.sleep(for: .seconds(seconds)) throw URLError(.timedOut) } let result = try await group.next()! group.cancelAll() return result } }
Конфигурация .background с isDiscretionary учитывает условия. Но можно жёстче — самостоятельно ставить задачу только при условиях. Используем NWPathMonitor и проверяем path.isExpensive == false и UIDevice.current.batteryState == .charging. Запускаем загрузку только тогда.
Зная Content-Length, можно отказаться от загрузки, если файл оказался слишком большим. В делегате didReceive response проверяем response.expectedContentLength и при превышении лимита возвращаем .cancel.
Сервер может требовать клиентский сертификат (mTLS). В делегате didReceive challenge мы достаём SecIdentity из связки ключей и возвращаем URLCredential(identity:certificates:persistence:). Это редкий, но рабочий путь для корпоративных приложений.
Если передаёте сессии делегата, он retainится сессией. Чтобы освободить — нужно вызвать session.invalidateAndCancel(). Иначе утечка памяти и сессии, которые висят после диалогов.
Для одного identifier может существовать только одна сессия. Попытка создать вторую — крэш в проде. Делайте через DispatchQueue.once или static let.
По умолчанию это не main thread. Если обновляете UI — оборачивайте в DispatchQueue.main.async или используйте @MainActor-замыкания. С async/await это не проблема, но при completion-стиле — главный источник «UI не обновляется».
По умолчанию декодер ищет точное соответствие имён. Установите decoder.keyDecodingStrategy = .convertFromSnakeCase — и забудьте про CodingKeys на каждый ключ.
Это фича: при .useProtocolCachePolicy и отсутствии сети URLSession отдаст последнюю валидную запись. Если для вас это плохо — переключайтесь на .reloadIgnoringCacheData.
Иногда вы получите NSError с доменом kCFErrorDomainCFNetwork. Это редкость, но проверяйте domain прежде чем кастить.
Apple передаёт в application(_:handleEventsForBackgroundURLSession:completionHandler:) handler, который мы обязаны вызвать после обработки. Если забыть — система будет думать, что приложение зависло, и в следующий раз даст меньше времени.
App Transport Security в Debug может «прощать» localhost через NSAllowsArbitraryLoads, но в Release — нет. Не нарывайтесь на это перед самой публикацией: проверяйте Release-сборку заранее.
В большинстве случаев — да, разбирает. Но если сервер шлёт очень крупные фреймы, проверьте maximumMessageSize на задаче и поднимите при необходимости.
Это известная история: при подключенном дебаггере фоновые задачи иногда ведут себя «не как в реальности» (см. пост про подводные камни). Проверяйте на отключённом устройстве — без LLDB.
Используйте при code review сетевого слоя:
URLSession, не shared.HTTPURLResponse.statusCode до парсинга.JSONDecoder с настройкой ключей и дат, один на клиент.URLSessionTaskMetrics в проде.URLProtocol-моки.viewWillDisappear и .task-модификаторах SwiftUI.handleEventsForBackgroundURLSession.Data.progress у задачи или KVO..ephemeral или с очисткой кэша.Idempotency-Key, если сервер поддерживает.За годы существования URLSession у нас накопилось четыре способа получить данные. Все они живы, все используются в проде, и стоит понимать, в чём разница и где какой уместен.
Самый старый стиль. Его сильная сторона — минимальные требования к окружению: ни Swift Concurrency, ни Combine. Работает с iOS 7. Слабая сторона — callback hell, ручная отмена, отсутствие структуры на уровне типов. В современном коде используем там, где не можем поднять минимальную версию iOS до 15.
session.dataTask(with: request) { data, response, error in if let error = error { return done(.failure(error)) } guard let data = data, let response = response as? HTTPURLResponse else { return done(.failure(URLError(.badServerResponse))) } done(.success((data, response))) }.resume()
URLSession отдаёт dataTaskPublisher(for:) начиная с iOS 13. Это удобно, когда у вас вся остальная архитектура построена на Combine: подписки, операторы, ретраи через .retry(_:). Combine отлично подходит для функциональных пайплайнов «трансформируй → отфильтруй → сложи». Но сам Combine — это другой большой мир, и сегодня Apple явно двигает Swift Concurrency как первый класс.
session.dataTaskPublisher(for: request) .tryMap { data, response in guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else { throw URLError(.badServerResponse) } return data } .decode(type: Items.self, decoder: decoder) .retry(2) .receive(on: DispatchQueue.main) .sink(receiveCompletion: { _ in }, receiveValue: { items in ... }) .store(in: &cancellables)
С iOS 15 — рекомендованный стиль. Свой пакет async-методов, кооперативная отмена через дерево Task, простая параллелизация через async let и TaskGroup. Главный плюс — линейность кода: ошибки бросаются как обычно, нет вложенных замыканий, легко читать. Главный минус — нужен iOS 15 как минимум.
Самый «низкоуровневый» способ. Дают полный контроль: прогресс на каждом байте, аутентификация, метрики, редиректы, paused state. Невозможно построить корректную фоновую загрузку без них. С async/await прекрасно сочетаются — создаём сессию с делегатом, а данные забираем через data(for:).
| Ситуация | Рекомендуемый стиль |
|---|---|
| Обычный JSON-запрос | data(for:) async/await |
| Загрузка файла в фоне | delegate + background config |
| WebSocket-стрим сообщений | async/await + цикл receive() |
| Сервер-сент эвенты | bytes(for:) + .lines |
| Пайплайн с трансформациями | Combine (если стек уже на нём) |
| Прогресс загрузки | delegate либо URLSessionTask.progress + KVO |
| Поддержка iOS 14 | completion handler + ручная обёртка |
Загрузка файлов с метаданными — самая частая «не очень очевидная» история. Multipart/form-data — это формат тела с разделителями. URLSession его не строит сам — мы готовим тело руками или через библиотеки. Покажу минимальный собранный пример, без зависимостей.
func multipartBody(boundary: String, parameters: [String: String], file: (name: String, filename: String, mime: String, data: Data)) -> Data { var body = Data() let lineBreak = "\r\n" for (key, value) in parameters { body.append("--\(boundary)\(lineBreak)".data(using: .utf8)!) body.append("Content-Disposition: form-data; name=\"\(key)\"\(lineBreak)\(lineBreak)".data(using: .utf8)!) body.append("\(value)\(lineBreak)".data(using: .utf8)!) } body.append("--\(boundary)\(lineBreak)".data(using: .utf8)!) body.append("Content-Disposition: form-data; name=\"\(file.name)\"; filename=\"\(file.filename)\"\(lineBreak)".data(using: .utf8)!) body.append("Content-Type: \(file.mime)\(lineBreak)\(lineBreak)".data(using: .utf8)!) body.append(file.data) body.append(lineBreak.data(using: .utf8)!) body.append("--\(boundary)--\(lineBreak)".data(using: .utf8)!) return body }
Тонкости:
Content-Type: multipart/form-data; boundary=... — обязателен. Без него сервер не поймёт, как делить тело.Data. Это съест память. Используйте URLSessionUploadTask с fromFile: и предварительно склеенным временным файлом.URLSessionTaskDelegate в методе didSendBodyData.Если файл больше десятка мегабайт, очень хорошая практика — отдавать его серверу чанками (Tus.io, S3 multipart upload). У вас будет прогресс на каждом чанке, ретрай только по упавшим частям и возможность возобновления.
Multipart — один из тех случаев, где имеет смысл написать собственную крошечную обёртку и не тянуть гигантскую библиотеку. Это сто строк кода, которые не растут, не зависят от обновлений и тестируются за час.
Аутентификация — место, где сетевой слой ломается чаще всего. Самая болезненная история — refresh-токен: сервер отвечает 401, нам нужно бесшумно обновить токен и повторить исходный запрос. Если обработать наивно, два параллельных запроса спровоцируют две одновременные попытки рефреша, и один из ответов перезатрёт другой.
Правильное решение — сериализация рефреша. Делаем актора, который гарантирует, что в любой момент времени активна одна задача обновления, остальные дожидаются её результата.
actor TokenStore { private var token: String? private var refreshTask: Task<String, Error>? func currentToken() async throws -> String { if let token = token { return token } return try await refresh() } func refresh() async throws -> String { if let task = refreshTask { return try await task.value } let task = Task<String, Error> { defer { refreshTask = nil } let newToken = try await requestNewToken() token = newToken return newToken } refreshTask = task return try await task.value } }
Дальше — middleware, который перехватывает 401, дёргает рефреш и повторяет запрос:
func authorized<T>(_ build: () -> URLRequest, decode: (Data, URLResponse) throws -> T) async throws -> T { func go() async throws -> (Data, URLResponse) { var request = build() let token = try await tokens.currentToken() request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") return try await session.data(for: request) } let (data, response) = try await go() if let http = response as? HTTPURLResponse, http.statusCode == 401 { _ = try await tokens.refresh() let (data2, response2) = try await go() return try decode(data2, response2) } return try decode(data, response) }
Этот же приём работает для логаута: если рефреш вернул 401 — токен полностью невалиден, чистим Keychain и кидаем пользователя на экран входа.
Никогда не складывайте refresh-токен в UserDefaults. Только Keychain с уровнем доступа .afterFirstUnlock или строже. UserDefaults — это plist в Documents, который читается простым доступом к файловой системе при наличии физического доступа к устройству.
URLSession сам решает, какой протокол использовать на основе ALPN при TLS-хендшейке. Если сервер поддерживает HTTP/2 — будет HTTP/2. С iOS 15+ Apple включает поддержку HTTP/3 (QUIC) — поверх UDP. Что это значит для нас?
Чтобы увидеть, по какому протоколу шёл запрос — посмотрите в URLSessionTaskTransactionMetrics.networkProtocolName. Возвращает "h2", "h3" или "http/1.1".
URLSession переиспользует соединения. После запроса соединение какое-то время живёт в пуле — следующий запрос на тот же хост сэкономит TLS-хендшейк. Чтобы пул работал эффективно, не пересоздавайте сессии. Это вторая причина (после конфигурации) держать сессию синглтоном.
HTTP/2 поощряет «дёшево попробовать ещё раз». Если соединение упало в середине, можно безопасно повторить идемпотентный запрос. Поэтому GET и PUT на HTTP/2 могут ретрайтиться чаще, чем вы ожидаете — URLSession делает это сам в некоторых случаях. Это нормально, но важно понимать, что «один вызов» не всегда означает «один запрос на сервер».
В SwiftUI у каждого View есть модификатор .task { ... }, который запускает async-замыкание в момент появления и автоматически отменяет, когда View уходит. Это идеальное место для сетевых вызовов, и оно стыкуется с URLSession как родное.
struct FeedView: View { @StateObject var vm = FeedViewModel() var body: some View { List(vm.items) { ItemRow(item: $0) } .overlay { if vm.isLoading { ProgressView() } } .task { await vm.load() // отменится при уходе с экрана } .refreshable { await vm.load() // pull-to-refresh } } } @MainActor final class FeedViewModel: ObservableObject { @Published var items: [Item] = [] @Published var isLoading = false func load() async { isLoading = true; defer { isLoading = false } do { items = try await api.items(page: 1) } catch is CancellationError { } catch { logger.error("feed load failed: \(error)") } } }
Главный плюс — отмена идёт автоматически. Никаких cancellables, никаких currentTask?.cancel() в onDisappear. Если view ушёл — async-задача внутри получит CancellationError, и URLSession-запрос внутри неё отменится по цепочке через async-bridge.
Один частый источник багов: .task(id:). Этот вариант перезапускает задачу, когда меняется id. Если вы покажете FeedView для разных пользователей подряд, без id запрос не перезапустится. Делайте так:
.task(id: vm.userID) { await vm.load() }
Стыковка SwiftUI и URLSession разобрана в отдельном посте — там есть рабочий пример с .task-модификатором, .refreshable и обработкой ошибок без алертов на каждый чих.
Если у вас контейнер с пейджингом — пагинируйте через .task(id: lastVisibleID). View сам перезапустит async-задачу, когда последняя видимая запись изменится, и подгрузит следующую страницу без сложного state-машины.
Лучший способ найти сетевые баги — намеренно ухудшать сеть и смотреть, как ведёт себя приложение. Apple даёт нам Network Link Conditioner — системная утилита для эмуляции медленных, нестабильных соединений.
waitsForConnectivity и поведения retry.Для просмотра реального трафика — Charles или Proxyman. Они работают как HTTPS-прокси и показывают каждый запрос с заголовками, телом и таймингами. Чтобы расшифровать TLS на симуляторе, нужно поставить рут-сертификат прокси в систему. На реальном устройстве это сложнее (Apple усиливает требования), но возможно.
Для логирования внутри приложения — отдельный URLSessionTaskDelegate, который пишет каждое событие в OS Log с категорией "net". Тогда в Console.app можно фильтровать только сетевые события и видеть полную картину без брейкпойнтов.
import OSLog extension Logger { static let net = Logger(subsystem: "com.iosiq.app", category: "net") } extension NetDelegate: URLSessionTaskDelegate { func urlSession(_ s: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { Logger.net.debug("\(task.currentRequest?.url?.absoluteString ?? \"-\", privacy: .public) \(error?.localizedDescription ?? \"ok\")") } }
И самое главное — не отлаживайте сетевые проблемы только в идеальном Wi-Fi. Прогоните приложение в метро, в лифте, в роуминге. Большинство багов сетевого слоя проявляются именно там — а воспроизводимо они проявятся под NLC.
ATS — это политика iOS, требующая HTTPS с TLS 1.2+ для всех сетевых запросов. По умолчанию все наши запросы должны идти по защищённому каналу. Послаблений три:
NSAllowsArbitraryLoads — пускать всё (опасно, ревью может отказать).NSAllowsLocalNetworking — пускать локалку (для разработки).NSExceptionDomains — точечные исключения по доменам.В реальной жизни старайтесь не отключать ATS вообще. Серверы, которые ещё не научились в TLS 1.2, постепенно должны исчезнуть — это вопрос политики, а не возможностей.
Pinning — это сравнение отпечатка сертификата (или публичного ключа) с зашитым в приложение. Зачем: даже если злоумышленник установит на устройство свой корневой сертификат и сможет расшифровывать TLS, ваше приложение откажется работать с «чужим» сертификатом.
Есть два варианта:
Реализация на стороне делегата — десяток строк через сравнение SecCertificateCopyData. Не буду повторять код, который легко гуглится — важнее принципы. И второе: обязательно держите план Б на случай, если ключ нужно срочно сменить. Иначе одна ошибка на сервере — и все ваши пользователи остаются без приложения.
URLSession по умолчанию использует общее системное хранилище cookies. Это удобно для веб-вью и URLSession-запросов, разделяющих авторизацию. Но это анти-приватно, если у вас разные пользователи в одном приложении. В этом случае дайте сессии собственное хранилище:
config.httpCookieStorage = HTTPCookieStorage.sharedCookieStorage(forGroupContainerIdentifier: "group.com.app") config.httpCookieAcceptPolicy = .onlyFromMainDocumentDomain
iOS 12.2+ умеет TLS 1.3, и URLSession согласовывает его автоматически, если сервер поддерживает. TLS 1.3 быстрее (один round-trip), безопаснее (нет старых шифров) и поддерживает 0-RTT для повторных соединений. Никаких настроек не нужно — оно «само».
Не отключайте проверку сертификатов «для разработки» через cancelAuthenticationChallenge с заглушкой «всегда trust». Эту строчку забудут вытащить, и она доберётся до прода. Лучше держать DEBUG-секцию через флаги компилятора, чтобы код в Release-сборке физически не существовал.
У каждой задачи есть свойство progress типа Progress. Это объект из Foundation, который умеет агрегироваться, делиться долями и рассчитывать оставшееся время. Можно подписаться на него тремя способами.
Классика. Подписываемся на fractionCompleted, получаем колбэки.
let observation = task.progress.observe(\.fractionCompleted) { p, _ in Task { @MainActor in ui.setProgress(p.fractionCompleted) } }
Для загрузки — didWriteData. Для выгрузки — didSendBodyData. Самый точный способ, потому что зовётся ровно тогда, когда происходит передача.
func urlSession(_ s: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { let fraction = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) Task { @MainActor in ui.setProgress(fraction) } }
Для async-кода удобно превратить прогресс в AsyncStream и отдавать наружу. Тогда View сам подписывается через цикл for await.
func download(_ url: URL) -> AsyncThrowingStream<DownloadEvent, Error> { AsyncThrowingStream { continuation in let task = session.downloadTask(with: url) let obs = task.progress.observe(\.fractionCompleted) { p, _ in continuation.yield(.progress(p.fractionCompleted)) } continuation.onTermination = { _ in obs.invalidate(); task.cancel() } task.resume() } }
Это особенно удобно в SwiftUI: .task { for try await event in download(url) { state = event } }. Никаких подписок, никакого ручного управления — отмена идёт автоматически вместе с View.
Если ваше приложение работает в корпоративной сети с DNS-разрезами (test-стенды по интранету), URLSession уважает /etc/hosts-подобное поведение через системный resolver. На уровне приложения можно подменить домен через URLProtocol, перехватив запрос и переписав хост на актуальный IP.
Бывает, что у вас два кандидата на бэкенд (старый и новый), и нужно временно слать запросы в оба, сравнивая ответы. Без TaskGroup это превратится в лапшу, с ним — десять строк. Запускаем оба, ждём более быстрого через first(where:), остальные отменяем.
На старте приложения можно «прогреть» TLS-соединения к нашим хостам пустым HEAD-запросом. Тогда первый реальный запрос сэкономит ~150 мс на хендшейке. Простой трюк, заметный на медленной сети.
func warmUp(hosts: [URL]) async { await withTaskGroup(of: Void.self) { group in for url in hosts { group.addTask { var request = URLRequest(url: url) request.httpMethod = "HEAD" _ = try? await session.data(for: request) } } } }
Перед миграцией трафика на новый эндпоинт можно отправлять 1% запросов параллельно на оба и сравнивать структуры ответов. Любые расхождения — в Sentry. Так вы поймаете несоответствия до выкладки.
У Progress есть полезное свойство localizedDescription и localizedAdditionalDescription — система сама напишет «3 МБ из 12 МБ», «осталось примерно 4 секунды». Это бесплатная локализация прогресс-индикаторов.
Аналитические запросы не должны конкурировать с UX-критичными за пул соединений. Заведите отдельную сессию с маленьким httpMaximumConnectionsPerHost (например, 1) и низким приоритетом задач — это «фоновый» канал, который не мешает основному.
Если у вас есть 50 маленьких запросов, отправить их вразнобой через 50 задач — плохо. Лучше попросить бэкенд сделать batch-эндпоинт и отправить один большой POST. Если бэкенд не умеет — упакуйте 50 идентификаторов в один query-параметр ?ids=1,2,3,...,50. Один запрос вместо пятидесяти — это всегда быстрее.
На CI часто нет интернета. Поднимите внутри тестов локальный HTTP-сервер (например, через NIO или SwiftNIOHTTP1), а сессию настройте на него через override host. Тесты остаются «реалистичными», но выполняются за миллисекунды.
Если сервер поддерживает заголовок Range, можно качать файл частями параллельно. Это редкий, но мощный приём — особенно для крупных медиафайлов на медленной сети. Параллельные части суммарно качаются быстрее, чем последовательная одна.
Записывайте среднюю скорость прошлых загрузок в UserDefaults. Тогда при новой загрузке вы сразу можете показать «осталось ~12 секунд» — без ожидания первых байт. Маленькая фича, но она ощутимо повышает доверие к интерфейсу.
Из сетевого слоя наружу должны выходить ошибки вашего домена, а не голые URLError. Маппинг — на границе слоя. ViewModel не должен думать о .notConnectedToInternet, у него должно быть FeedError.offline.
Сервер может прислать «application/json» с пустым телом или HTML внутри. Проверяйте тело перед декодом и логируйте странности — найдёте бэкенд-баги быстрее, чем команда бэка.
Если бэкенд отдаёт заголовок X-Request-Id — обязательно складывайте его в лог. Это «золотой ключ» к отладке: вы говорите бэкендеру ID, и он за минуту находит вашу проблему в своих логах.
Не дёргайте прод в тестах. Кладите образцы JSON в bundle и подсовывайте через URLProtocol. Тесты остаются «настоящими» с точки зрения декодера, но не зависят от сети.
.shouldUseExtendedBackgroundIdleModeНа некоторых сценариях этот флаг позволяет URLSession чуть дольше держать соединение в фоне. Полезен для WebSocket-сессий.
Если сервер вернул 429 с заголовком Retry-After, не делайте свой бэкофф. Сервер уже сказал, через сколько ему позвонить — повинуйтесь.
Сессия — синглтон, но создание сессии — синхронное, оно зовёт под капотом несколько системных вызовов. Делайте через lazy var, чтобы не платить старт-ап стоимость до первого запроса.
Apple дала нам JSONEncoder.OutputFormatting.sortedKeys и .prettyPrinted. Запишите образцы из реальных ответов сервера в JSON-файлы в репозитории — это и документация, и фикстуры для тестов одновременно.
JSONDecoder игнорирует лишние поля — это нормально. Но если у вас Codable-структура с обязательным полем, которое сервер вдруг не прислал — это краш. Делайте поля Optional, либо проверяйте обязательность вручную.
Если ваше приложение читает запросы из background-сессии после релиза, а вы поменяли формат идентификаторов задач — пользователи с pending-задачами от старой версии получат «битые» события. Сохраняйте обратную совместимость на одну-две версии.
Сеть — это не только корректность, но и время. Долгий запрос — это секунды, проведённые пользователем в ожидании, и батарея, потраченная зря. Несколько практик, которые ощутимо ускоряют сетевой слой.
Одна сессия на клиент — это уже половина выигрыша. URLSession держит keep-alive соединения в пуле, и повторные запросы на тот же хост экономят TLS-хендшейк. Если у вас 10 запросов к одному API подряд — будет одно физическое соединение для всех, а не десять отдельных.
Бэкенд может прислать заголовок Link: </style.css>; rel=preload. URLSession не пушит превентивно, но рекомендации логировать в метрики стоит — это маркер «здесь можно подкачать заранее».
URLSession сам отправляет Accept-Encoding: gzip, deflate, br и сам распаковывает ответ. Сервер должен поддерживать сжатие. Для запросов с большим телом (POST/PUT) сжатие тоже работает, но требует ручного Content-Encoding: gzip и предварительно сжатого httpBody. На больших телах это сокращает трафик в три-пять раз.
URLSession сам управляет TCP-параметрами, но если вы делаете WebSocket с очень маленькими сообщениями, имеет смысл отключить Nagle через URLSessionStreamTask или Network.framework. По умолчанию Nagle копит мелкие пакеты — это полезно для трафика, но повышает латентность.
URLCache — это байты. Но в продвинутом случае вам нужен кэш декодированных структур: запрос вернул JSON, мы распарсили в [Item], и было бы здорово в следующий раз отдать готовые Item, не парся заново. Решение — простая обёртка-декоратор: при cache-hit пропускаем JSONDecoder, при miss — декодируем и кладём в in-memory словарь по URL.
Когда пользователь смотрит на экран профиля, мы знаем: следующим шагом он, скорее всего, нажмёт «настройки». Можно тихонько прогреть запрос настроек с низким приоритетом. К моменту реального тапа данные уже будут готовы. Это пограничный случай — экономьте ресурсы пользователя и не качайте слишком агрессивно.
Никогда не верьте «ощущениям». Если кажется, что сеть тормозит — измерьте. URLSessionTaskMetrics вам в помощь. Записывайте p50/p95 для каждого эндпоинта в течение недели. Дальше — ищите аномалии: вот эндпоинт, у которого TTFB 1.2 с при p95 — значит, сервер тормозит. Вот этот — DNS 800 мс, значит, инфраструктура нестабильна. Без метрик это всё гадание на кофейной гуще.
Отмена — это не только про корректность, это про производительность. Если вы при скролле списка не отменяете запросы на превью невидимых ячеек, ваш пул соединений забит «мусором». Отмена сразу освобождает соединение для нужных запросов.
Кликните по вопросу, чтобы развернуть ответ.
Нет. Конфигурация копируется в момент создания сессии и становится read-only. Изменить можно только через создание новой сессии. Это намеренное архитектурное решение: оно гарантирует, что задачи внутри сессии живут в стабильном мире.
Проверьте три вещи: (1) сессия создана с делегатом — URLSession(configuration:delegate:delegateQueue:), а не URLSession(configuration:); (2) делегат не отпущен из памяти (URLSession держит strong-ссылку, но если объект weak в другом месте — никто не удержит); (3) для completion-стиля dataTask(with:completionHandler:) некоторые делегатные методы не зовутся, только финальный.
Самая частая причина — попытка передать httpBody вместо файла. В фоновой сессии можно только uploadTask(with:fromFile:) или downloadTask. Вторая причина — не реализован application(_:handleEventsForBackgroundURLSession:completionHandler:).
Своя. shared удобна для разовых запросов в служебных местах, но как только речь о центральном API-клиенте, лучше создавать свою сессию с явной конфигурацией. Это упрощает тестирование, логирование и контроль политик.
Зависит от задач. Если требования к multipart, цепочкам интерсепторов, валидации сложные — Alamofire даёт всё из коробки. Если речь о REST с JSON — URLSession + 200 строк собственной обёртки закроют почти всё. В продакшене часто достаточно URLSession: меньше зависимостей, меньше сюрпризов при обновлениях.
URLProtocol-моки. Они полностью прозрачны для сетевого слоя, не требуют mock-фреймворков и работают одинаково в Unit/UI тестах. Подробный пример выше в разделе «Лучшие практики».
Когда нужен UDP, кастомный TCP-протокол, multipath, очень тонкое управление соединениями. Большинству приложений Network.framework не нужен — URLSession поверх него и так использует всё лучшее. См. также пост про UDP-клиент.
Нет. Накладные расходы на background больше, чем выгода. Background-сессия имеет смысл для крупных и длительных передач. Маленькие — обычная сессия с retry.
Да, при ровно тех же условиях, что и в фоне приложения. Систему интересует — есть ли сеть, есть ли заряд, не приоритетнее ли что-то ещё. Защита экрана паролем — не препятствие.
Полная гигиена: session.invalidateAndCancel(), очистка URLCache, очистка HTTPCookieStorage, очистка Keychain. После этого — создание новой сессии. Половинчатые меры приводят к утечке данных между аккаунтами.
Простая схема — какой инструмент брать в зависимости от характера данных. Четыре типичных сценария закрывают почти все продакшен-задачи.
session.data(for:)
async/await · одна общая сессия
session.download(for:)session.upload(for:fromFile:)
+ .background для долгих задач
session.webSocketTask(with:)
iOS 13+ · текстовые и бинарные кадры
session.bytes(for:)
.lines для построчной обработки
Все ссылки ниже — посты канала, в которых разобраны отдельные грани URLSession и сетевого стека. Сохраняйте, делитесь, читайте под кофе.
Базовый разбор: типы сессий, что под капотом, как настраивается и что важно знать про фоновые задачи.
Открыть в Telegram → BackgroundПодводные камни background-задач: дебаггер, реальное устройство, особенности API.
Открыть в Telegram → ConnectivityКак настроить URLSession, чтобы он бесшумно дожидался появления подключения.
Открыть в Telegram → async/awaitПаттерн «эндпоинт + билдер + валидатор» для аккуратного сетевого слоя.
Открыть в Telegram → ErrorsHTTPURLResponse.localizedString — кратчайший путь к человекочитаемой ошибке.
Открыть в Telegram → DownloadКак качать файлы, даже если приложение убито пользователем.
Открыть в Telegram → WebSocketИтерация сообщений через async-цикл, минимум кода — максимум выразительности.
Открыть в Telegram → SwiftUIКак уживаются View, .task-модификатор и сетевой клиент.
Открыть в Telegram → URLURLComponents, queryItems, безопасные инициализаторы.
Открыть в Telegram → Network.frameworkКогда URLSession не хватает и нужно идти в Network.framework.
Открыть в Telegram → CacheСтратегии, TTL, версионирование, mermaid-диаграммы, плюсы и минусы.
Открыть в Telegram → ConcurrencyКонтекст для async-методов URLSession и общая картина по конкурентности.
Открыть в Telegram →В iOS IQ собрана углублённая база: примеры, разборы интервью, задачи, схемы. URLSession — лишь одна из тем. Если хочется усилить тему сети и идти глубже в архитектуру, многопоточность, производительность — заходите.