Всем привет. Меня зовут Максим Батраков и я iOS-разработчик в 65apps. В этой статье я хочу рассказать о некоторых особенностях работы с URLSession, разобрать загрузку файлов в background URLSession и показать процесс переноса выполнения активных запросов в background URLSession после того, как приложение ушло в фон.

Общая теория по теме

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

Скипабельно

Что такое URLSession

  • An object that coordinates a group of related, network data transfer tasks.

  • Предоставляет высокоуровневое API для работы с http(s) (и другими) запросами

  • Доступен из коробки в Foundation

  • Может выполнять запросы, когда приложение не запущено или приостановлено

  • При выполнении запросов создает URLSessionTask, которые можно, например, приостанавливать или отменять

Типы URLSession

Типы URLSession определяются используемой конфигурацией URLSessionConfiguration, передаваемой в инициализатор URLSession.

  • default

  • ephemeral. Не записывает cache, cookies и credentials

  • background. Может загружать контент, когда приложение приостановлено или не запущено

Также имеется shared экземпляр URLSession, похож на default, но без возможности установки свойств конфигурации

Виды URLSessionTask

  • URLSessionDataTask - запрашивают ресурс, возвращая ответ сервера в виде одного или нескольких объектов NSData. Они поддерживаются в dafault и ephemeral типах URLSession по умолчанию. Не может работать в фоне.

  • URLSessionUploadTask - аналогичны URLSessionDataTask, за исключением того, что они упрощают предоставление тела запроса, чтобы вы могли загружать данные до получения ответа сервера. Может работать в фоне.

  • URLSessionDownloadTask - загружают ресурс непосредственно в файл на диске. Задачи загрузки поддерживаются в любом типе сеанса. Может работать в фоне.

  • URLSessionStreamTask - устанавливают TCP/IP-соединение с именем хоста и портом или объектом сетевой службы. Не может работать в фоне.

  • URLSessionWebSocketTask - обеспечивают обмен сообщениями по WebSocket протоколу. Не может работать в фоне.

Подготовления

Итак, мы знаем, что URLSessionUploadTask и URLSessionDownloadTask могут выполняться в background URLSession, когда приложение не активно. Давайте воспользуемся этой возможностью background URLSession и рассмотрим, как можно переводить активные таски дефолтной URLSession в background URLSession при уходе приложения в фон. Получать результаты запросов default URLSession можно при помощи колбеков или делегата. В этой статье для простоты будем использовать дефолтную URLSession с колбеками. Для логов будем использовать os_log. Никаких дополнительных пермишенов приложению устанавливать не будем.

Далее, для краткости, я буду называть URLSessionUploadTask просто uploadTask, а URLSessionDownloadTask - downloadTask соответственно. В тексте я не буду оборачивать названия классов в тег кода, а URLSession буду называть urlSession чтобы не перетягивать внимание от текста во время чтения.

Default URLSession

Немного остановимся на default urlSession и взглянем, что происходит с активными запросами при уходе приложения в фон и какие ошибки будут получены.

Для удобства заведём отдельный сервис и будем осуществлять запросы через него:

final class NetworkService {

	private let defaultSession = URLSession(configuration: .default)

	func performRequest(
		to path: String?,
		method: HttpMethodType,
		bodyData: Data?,
		completion: @escaping RequestCompletion
	) {
		let url = URLCreator.makeUrl(withPath: path)
		var request = URLRequest(url: url)
		request.httpMethod = method.rawValue
		request.httpBody = bodyData

		let task = defaultSession.dataTask(with: request, completionHandler: completion)
		task.resume()
	}
}

Выполнение запросов через сервис выглядит так:

networkService.performRequest(
	to: "/12-Light-compressed.jpg",
	method: .get,
	bodyData: nil
) { data, response, error in
	os_log("#### error: %{public}@", type: .default, error?.localizedDescription ?? "no error")
}

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

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

#### error: The request timed out.

По умолчанию это 60 секунд.

Блокировка девайса во время выполнения запроса работает стабильнее и в этом примере всегда завершается ошибкой:

#### error: The network connection was lost.

В обоих случаях ошибка будет получена только когда приложение вернется в активное состояние. Это важный момент. Ошибки будут получены слишком поздно и поэтому нам придется самим следить за тем, что приложение собирается уходить фон и переводить активные запросы в background urlSession.

Работа с background URLSession

Теперь давайте рассмотрим, как создать background urlSession и выполнять запросы даже когда приложения не находится в активном состоянии.

Конфигурирование бек сессии

Процедура создания background urlSession принципиально не отличается от других сессий - нам нужно создать соответствующую конфигурацию и передать ее в инициализатор сессии. При создании бек конфигурации нужно указать строковый идентификатор. Бек сессия имеет возможность будить неактивное приложение и благодаря идентификатору дает понять, какой экземпляр сессии завершил загрузку.

private lazy var urlSession: URLSession = {
	let config = URLSessionConfiguration.background(withIdentifier: "MySession")
	config.isDiscretionary = true
	config.sessionSendsLaunchEvents = true
	return URLSession(configuration: config, delegate: self, delegateQueue: nil)
}()

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

isDiscretionary - Позволяет операционной системе дожидаться оптимального времени для выполнения запроса, когда установлено в true.

sessionSendsLaunchEvents - Позволяет будить неактивное приложение по завершению загрузки бек сессии, когда установлено в true

При создании экземпляра бек сессии обязательно передаем делегат. Бек сессия работает только с ним. Свойство delegate у urlSession get-only и установить мы его можем только в инициализаторе. Если попробовать выполнить запрос с completion замыканием, то получим исключение:

*** Terminating app due to uncaught exception 'NSGenericException', 
reason: 'Completion handler blocks are not supported in background sessions.
Use a delegate instead.'***

Если не передать delegate в инициализатор, то никакой ошибки или предупреждения мы не получим, но и результатов выполнения запросов тоже.

Выполнение uploadTask в фоне

При использовании urlSession с делегатом, URLSessionUploadTask могут быть проинициализированы двумя способами. Первый способ - передать Data в метод uploadTask(with:from:), второй - передать URL к файлу в uploadTask(with:fromFile:).

Но с background URLSession выбор метода для создания URLSessionUploadTask имеет значение. Если попробовать передать Data в upload task background urlSession, то получим исключение:

*** Terminating app due to uncaught exception 'NSGenericException', 
reason: 'Upload tasks from NSData are not supported in background sessions.'***

Выполнение download task в фоне

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

let backgroundTask = urlSession.downloadTask(with: url)
backgroundTask.earliestBeginDate = Date().addingTimeInterval(60 * 60)
backgroundTask.countOfBytesClientExpectsToSend = 200
backgroundTask.countOfBytesClientExpectsToReceive = 500 * 1024
backgroundTask.resume()

earliestBeginDate - параметр позволяющий отложить запуск таски на заданное время. Установка этого параметра не гарантирует, что запрос начнет выполняться с точно спустя заданный интервал, но он не будет выполнен раньше. По сути это дополнительное ограничение на выполнение таски. Если не задать это значение, то система попытается выполнить запрос без задержки.

countOfBytesClientExpectsToSend и countOfBytesClientExpectsToReceive - в этих параметрах следует указать верхние границы отправляемых и получаемых байт с учетом заголовков и тела запроса. Эти значения могут быть не точными. Они помогают лучше подобрать время для выполнения запроса. Apple настоятельно рекомендует не оставлять эти значения дефолтными и устанавливать значения.

Не забываем указать делегату имплементацию протокола URLSessionDownloadDelegate, чтобы получать результаты загрузки файла. Когда файл успешно загружен произойдет вызов метода urlSession(_:downloadTask:didFinishDownloadingTo:). В этом методе можно будет сохранить файл или использовать как-то по своему усмотрению.

Ограничения background urlSession

В дополнение вышеописанным особенностям, у background urlSession имеются еще несколько дополнительных ограничений при выполнении запросов в фоне:

  • Поддерживаются только HTTP и HTTPS протоколы.

  • Редиректы всегда выполняются и не могут быть обработаны делегатом.

Пробуждение приложения

Бек сессия может выполнять upload и download таски при любом состоянии приложения. Если приложение находится в фоне, а сессия сконфигурирована с sessionSendsLaunchEvents = true, то при завершении загрузки произойдет пробуждение приложения. В этом случае, в appDelegate будет вызван метод application(_:handleEventsForBackgroundURLSession:completionHandler:). Этот метод получает идентификатор бек сессии завершившей запрос и completionHandler. CompletionHandler нужно будет вызвать позднее в делегате urlSession. Поэтому в теле этого метода его нужно сохранить, например, в appDelegate, чтобы потом его можно было найти вызвать.

func application(_ application: UIApplication,
                 handleEventsForBackgroundURLSession identifier: String,
                 completionHandler: @escaping () -> Void) {
        backgroundCompletionHandler = completionHandler
}

Когда бек сессия завершит обработку событий, в urlSessionDelegate произойдет вызов urlSessionDidFinishEvents(forBackgroundURLSession:). В этом методе будет нужно выполнить completion из appDelegate в main очереди.

func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
    DispatchQueue.main.async {
        guard let appDelegate = UIApplication.shared.delegate as? AppDelegate,
            let backgroundCompletionHandler =
            appDelegate.backgroundCompletionHandler else {
                return
        }
        backgroundCompletionHandler()
    }
}

Вызовом этого completion мы сообщаем системе, что закончили обработку фоновой загрузки и iOS перерисует снапшот запущенного приложения на экране многозадачности:

Если же приложение было выгружено из памяти системой, то при завершении загрузки оно будет запущено заново. При добавлении логов в методы viewDidLoad и viewDidAppear стартового UIViewController приложения можно увидеть, что происходят вызовы этих методов:

После перезапуска приложения так же произойдет вызов метода application(_:handleEventsForBackgroundURLSession:completionHandler:). В этом случае, поскольку приложение было перезапущено, в памяти будет отсутствовать background urlSession с ожидаемым идентификатором. Поэтому нужно создать новую background urlSession. Новый экземпляр бек сессии будет автоматически ассоциирован с текущей фоновой активностью и его делегат получит результаты выполнения запроса.

Обратите внимание на листинг конфигурации background urlSession. Свойство urlSession имеет модификатор lazy, чтобы оборачивающий сервис имел возможность установить себя в качестве делегата. Поэтому обращаемся к свойству сессии в инициализаторе сервиса, чтобы она проинициализировалась вместе с ним:

override init() {
	super.init()
	_ = urlSession
}

В таком случае, если при запуске приложения инициализировать сервис с background urlSession и держать на него ссылку, то все события загрузки будут получены.

Перенос URLSessionTask из обычной сессии в фоновую при уходе приложения в фон

Вообще, можно использовать бек сессию для загрузки файлов на постоянной основе. Загрузка не будет прерываться, когда приложение уходит в фон. Это круто, можно даже закрыть глаза на неудобства использования. Но есть минус, с которым очень сложно смириться. Скорость background urlSession регулируется системой и заметно ниже дефолтной. Я не собирал большую статистику и не проводил точных расчетов скорости. Но сравнивал время выполнения запросов загрузки файлов на сервер из локальной сети и оно отличалась примерно в 4 раза. Вероятно, эта цифра может меняться в зависимости от различных условий, но она уже сейчас позволяет задуматься над тем, стоит ли загружать файлы всегда используя бек сессию.

Поэтому попробуем воспользоваться преимуществами обоих сессий:

  • пока приложение активно загружаем файлы используя быструю default urlSession

  • когда приложение уходит в фон, переносим активные запросы в background urlSession (если возможно)

Перенос URLSessionTask из одной сессии в другую

Нет какого-то явного способа взять и перенести выполнение urlSessionTask из одной urlSession в другую. Если urlSessionTask выполняется на одной URLSession, то она привязана к ней и с этим ничего не поделать. Но для downloadTask имеется возможность отменить выполнение, с сохранением прогресса загрузки и возобновить запрос позднее с имеющимся прогрессом. Используя этот механизм можно осуществить перенос выполнения downloadTask одной urlSession на другую. Давайте пока остановимся на этом и рассмотрим, как это можно сделать.

В downloadTask имеется метод cancel(byProducingResumeData:). Этот метод отменяет выполнение downloadTask и передает данные для востановления загрузки асинхронно в передаваемом замыкании. Если эту загрузку нельзя восстановить или не было скачано данных, то в замыкание вместо данных будет передан nil. Отмена URLSessionTask приводит к отправке ошибки  NSURLErrorCancelled в делегат urlSession или замыкание обработчика запроса, в зависимости того, что используется.

Если при отмене downloadTask были получены данные, то их можно использовать чтобы продолжить загрузку. Для этого нужно создать и запустить новую downloadTask при помощи метода urlSession downloadTask(withResumeData:).

Для возможности продолжения загрузки имеются дополнительные условия, которые можно посмотреть в документации метода cancel(byProducingResumeData:).

Для uploadTask нельзя отменить запрос и продолжить с того момента где мы остановились. Максимум, что можно сделать — это отменить выполнение текущей uploadTask и создать новую в background urlSession без сохранения прогресса.

Отмена активных URLSessionTask при уходе приложения в фон

Отмену активных URLSessionTask при уходе приложения в фон, можно поделить на две подзадачи:

  • Нужно поймать момент когда приложение уходит в фон

  • Выполнить отмену активных URLSessionTask urlSession

Определять когда приложение уходит в фон можно разными способами. Рассмотрим их:

  • Добавить обработку в AppDelegate.applicationDidEnterBackground(_:) / SceneDelegate.sceneDidEnterBackground(_:). Не очень удобное место для прерывания запросов. Придется откуда-то доставать сетевые сервисы в которых нужно отменить запросы, а их в приложении может быть много. К тому же, нужно выполнить это быстро. На выполнение кода есть примерно 5 секунд. Если процесс будет слишком долгим, то приложение может быть выгружено из памяти.

  • Завести backgroundTask используя UIApplication.beginBackgroundTask(expirationHandler:). В сущностях, работающих с сетевыми сервисами, при инициализации можно заводить backgroundTask и, когда приложение будет уходить в фон, будет выполняться отмена URLSessionTask. Из плюсов, приложение будет выделять дополнительное время на работу в фоне для выполнения имеющихся backgroundTask. Минус в том, что нужно вызывать endBackgroundTask(_:), когда мы завершили работу. URLSessionDownloadTask завершаются асинхронно и вызов endBackgroundTask(_:) может усложниться.

  • Подписаться на событие UIApplication.didEnterBackgroundNotification. Самый простой способ для отмены выполнения URLSessionTask когда приложение уходит в фон. Действуем так же как при создании backgroundTask, но без необходимости сообщать о завершении выполнения кода. Как и в случае добавления кода в applicationDidEnterBackground(:) / sceneDidEnterBackground(:), у нас есть ограничение по времени выполнения.

Для получения URLSessionTask в urlSession имеются 2 метода - getTasksWithCompletionHandler(_:) и getAllTasks(completionHandler:). По названию понятно что первый метод возвращает URLSessionTask с completionHandler, а второй возвращает все URLSessionTask.

На этом различия методов не заканчиваются - в замыкание первого метода приходят URLSessionTask сгруппированные по типу:

defaultSession.getTasksWithCompletionHandler { _, uploadTasks, downloadTasks in
	uploadTasks.forEach {
		// some code
	}
	downloadTasks.forEach {
		// some code
	}
}

Это достаточно удобно при работе с default URLSession и использовании completion в URLSessionTask.

Во втором методе в замыкание приходит массив [URLSessionTask].

defaultSession.getAllTasks { tasks in
	// some code
}

Никакой группировки по типу тут нет и, если она понадобится, реализовывать ее потребуется самостоятельно.

Объединение информации по переносу в фон и размышления 

В целом, на этом этапе уже можно переводить downloadTask в background urlSession. Для этого нужно:

  • отлавливать событие ухода приложения в фон или завести backgroundTask

  • в этом событии у urlSession получить все URLSessionTask

  • для downloadTask вызывать cancel(byProducingResumeData:) и если есть данные создавать новую downloadTask с этими данными в background URLSession, а если нет, то создавать новую downloadTask используя свойство request прерываемой downloadTask

Для пересоздания uploadTask в фоне возникнет проблема того, что не получится вытянуть fileURL из отменяемого экземпляра uploadTask. Кроме того, такой вариант перевода URLSessionTask имеет некоторые недостатки:

  1. Допустим, интерфейс приложения позволяет отменить загрузку. При отмене запроса пользователем и при уходе приложения в фон, в completion замыкание запроса будет передана одна и та же ошибка - NSURLErrorCancelled. Внутри замыкания не получится понять, что привело к отмене - действие пользователя или перенос запросов из одной urlSession в другую.

  2. Сам перенос выглядит очень не явно, поток исполнения обрывается ошибкой NSURLErrorCancelled в замыкании и запускается некоторая активность где-то в другом месте.

Для исправления этих недостатков и возможности реализации переноса uploadTask можно, например, ввести процедуру прерывания URLSessionTask. При прерывании URLSessionTask в completion замыкание будут передана отличную от NSURLErrorCancelled ошибка и на нее можно реагировать переносом запроса в background URLSession. Если прервать downloadTask, то вместе с ошибкой будем получать данные для возобновления URLSessionTask.

Для наглядности давайте посмотрим на результат введения прерывания, на примере отправки файла:

os_log("#### performUploadRequest", type: .default)
interruptibleNetworkService.performUploadRequest(
	to: "/upload",
	method: .post,
	fileURL: imageURL
) { [backgroundContinuableNetworkService] data, response, error in
	if let error = error as? InterruptibleNetworkService.ServiceError, error == .uploadInterruptedByApp {
		os_log("#### upload interrupted", type: .default)
		os_log("#### will restart upload request in background", type: .default)
		backgroundContinuableNetworkService.restartUploadRequest(to: "/upload", method: .post, fileURL: imageURL)
	}
}

Когда interruptibleNetworkService получает сигнал о том, что нужно прервать выполнение активных запросов, в completion замыкание urlSessionTask попадает ошибка о том, что запрос был прерван. В отличии от ошибок “The request timed out” и “The network connection was lost” эта ошибка будет получена не когда приложение вернется в foreground, а спустя короткий промежуток времени после вызова прерывания. При возникновении этой ошибки можно успеть перенести выполнение URLSessionTask в background URLSession. К плюсам можно отнести, что все управление находится в одном месте, перевод в фон выполняется явно, url к файлу находится в области видимости и его не нужно искать.

Статья направлена на обзор более общих сведений по переносу в background URLSession и такое частное решение с прерыванием здесь рассмотрено не будет. Возможно, через какое-то время выйдет продолжение, в котором будут рассмотрены некоторые решения, направленные на повышение эффективности работы с background URLSession.

Заключение

Apple предоставляет возможность загружать файлы пока приложение не находится на переднем плане с помощью background URLSession. Прошло много времени с момента релиза этого инструмента, но каких-то изменений в удобстве использования urlSession не произошло. Для того чтобы перенести выполнения URLSessionTask в background urlSession при уходе приложения в фон, разработчикам нужно самим следить за состоянием приложения и выполнять перенос в ручном режиме.

Это относительно неплохо работает с downloadTask. Есть возможность перевести downloadTask в background URLSession при уходе приложения в фон. Можно даже дополнительно реализовать перенос из background URLSession в default URLSession, когда приложение возвращается в foreground. При условии поддержки возобновления загрузки со стороны сервера.

Для uploadTask нет возможности сохранить прогресс отправки файла при переносе в background urlSession. Начинать отправку файла с самого начала с пониженной скоростью выглядит как-то малопривлекательно.

Нужно помнить о том, что на нашу фоновую сессию влияют все ограничения и приоритеты системы: критически низкий заряд батареи, режим энергосбережения(тоже самое что и критически низкий заряд), частота/время использования вашего приложения, видимо ли ваше приложение на данный момент в app switcher, выделенные ресурсы системы, потребляемая мощность, трафик и т.д. Так же на загрузку в background urlSession оказывает влияние resume rate limiter, который предотвращает злоупотребление фоновых загрузок и параметры конфигурации urlSession.

Комментарии (1)


  1. dzmitry-antonenka
    00.00.0000 00:00

    Спасибо за статью. Не пробовали экспериментировать с настройкой ниже? Для загрузки файлов думаю она вряд ли подойдёт, но для обычных запросов выглядит как то, что нужно.

    URLSession.shared.configuration.shouldUseExtendedBackgroundIdleMode