Pull to refresh
70.31
red_mad_robot
№1 в разработке цифровых решений для бизнеса

iOS. Работа с сетью, когда приложение не запущено

Reading time14 min
Views24K

image


Пользователи ожидают, что работа с сетью происходит «волшебно» и незаметно. Зависит эта волшебность от разработчиков системы и приложений. На систему повлиять сложно, поэтому ограничимся приложением.


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


Для начала, разберёмся с терминологией.


Передача данных бывает в двух направлениях:


  • download (скачивание, загрузка данных с сервера),
  • upload (отправка данных на сервер).

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


  • background (когда приложение «свёрнуто»),
  • active (когда приложение активно, на экране).

Полезные паттерны: callback, delegate (Cocoa Design Patterns , про callback в Википедии). Также нужно знать, как работает URLSession (в статье по ссылке есть и упоминание фоновой работы с сетью, но вскользь).


Все примеры написаны на Swift 5, работают на iOS 11 и более новых (тестировалось на iOS 11 и 12) и предполагают использование обычных HTTP-запросов. По большей части всё это будет работать, начиная с iOS 9, но «есть нюансы».


Общая схема работы с сетью. URLSession


Работа с сетью не представляет особой сложности:


  • создаем конфигурацию URLSessionConfiguration;
  • создаем по конфигурации экземпляр URLSession;
  • создаем task (при помощи методов session.dataTask(…) и аналогичных);
  • подписываемся на обновления задачи. Обновления приходят асинхронно, могут приходить в delegate, который прописывается при создании сессии, а могут в callback, который создаётся при создании задачи;
  • когда увидели, что задача завершена, возвращаемся к логике приложения.

Простой пример выглядит так:


let session = URLSession(configuration: .default)
let url = URL(...)
let dataTask = session.dataTask(with: url) { data, response, error in
    ... 
    // обработать результат работы задачи
    // вызвать callback, передающий управление дальше
}

Эта схема сходна для различных задач, меняются только мелочи. И до тех пор, пока нам не требуется, чтобы работа с сетью продолжалась после того, как пользователь закрыл приложение, всё сравнительно просто.


Сразу отмечу, что даже в этом сценарии много интересного. Иногда требуется работать с хитрыми редиректами, иногда нужна авторизация, SSL-пиннинг или всё сразу. Про это можно прочитать много где. Работа с сетью в background состоянии почему-то описана сильно меньше.

Создание сессии для работы в background


Чем отличается background URLSession от обычной? Она работает вне процесса приложения, где-то внутри системы. Поэтому она не «умирает», когда завершается процесс приложения. Называется она background-сессией (также, как и состояние приложения, что немного путает) и требует специфической настройки. Например, такой:


let configuration = URLSessionConfiguration.background(withIdentifier: "com.my.app")
configuration.sessionSendsLaunchEvents = true
configuration.isDiscretionary = true
configuration.allowsCellularAccess = true
configuration.shouldUseExtendedBackgroundIdleMode = true
configuration.waitsForConnectivity = true

URLSession(configuration: configuration, delegate: self, delegateQueue: nil)

У конфигурации множество других параметров, но эти относятся непосредственно к background сессиям:


  • identifier (передаётся в инициализаторе) — это строка, которая используется для сопоставления background сессий при перезапуске приложения. Если приложение перезапустилось, и вы создаёте background сессию с идентификатором, который уже используется в другой background сессии, то новая получит доступ к задачам предыдущей. Вывод из этого простой. Для корректной работы нужно, чтобы этот идентификатор был уникальный для вашего приложения и постоянный (можно использовать, например, производную от bundleId приложения);
  • sessionSendsLaunchEvents показывает, должна ли background сессия запускать приложение, когда завершается передача данных. Если этот параметр поставить false, то запуска не произойдёт, а все события приложение получит, когда само в следующий раз запустится. Если параметр true, то после завершения передачи данных, система запустит приложение и вызовет соответствующий метод AppDelegate: application(_:handleEventsForBackgroundURLSession:completionHandler:);
  • isDiscretionary даёт возможность системе планировать выполнение задачи более редко. Это, с одной стороны, улучшает длительность работы аккумулятора, с другой — может затормозить выполнение задачи. А может и ускорить. Например, если скачивается большой объем, система сможет поставить задачу на паузу до момента подключения к WiFi, а потом быстро всё скачать, не тратя медленный мобильный интернет (если он вообще разрешён, о чем дальше). Если таск создается тогда, когда приложение уже в background, то этот параметр автоматически проставляется в true;
  • allowsCellularAccess — параметр, который показывает, что можно использовать сотовую связь для работы с сетью. Я с ним внимательно не игрался, но по отзывам, тут (вместе с аналогичным системным переключателем) разложено огромное количество граблей;
  • shouldUseExtendedBackgroundIdleMode. Полезный параметр, который показывает, что система дольше должна сохранять коннект с сервером, когда приложение уходит в фон. В противном случае коннект будет разорван.
  • waitsForConnectivity В условиях мобильного устройства связь может пропадать на короткие промежутки времени. Созданные в этот момент задачи могут либо быть приостановлены до появления связи, либо сразу вернуть ошибку «нет связи». Параметр позволяет управлять этим поведением. Если он false, то при отсутствии связи задача сразу же сломается с ошибкой. Если true — подождёт, пока не появится связь.
  • последняя строка (инициализатор сессии) содержит важный параметр, delegate. Про него — чуть подробнее.

Delegate vs Callbacks


Как я уже говорил выше, есть два способа получения событий от задачи/от сессии. Первый — callback:


session.dataTask(with: request) { data, response, error in 
    ... обрабатываем результат
}

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


Второй вариант работы с сессией — через delegate. В этом случае мы должны создать класс, который реализует протоколы URLSessionDataDelegate и (или) другие рядом стоящие (для разных типов задач протоколы немного отличаются). Ссылка на экземпляр этого класса живёт в сессии, а его методы вызываются при необходимости передачи в делегат событий. Прописать ссылку в сессии можно инициализатором. В примере прописывается self.


URLSession(configuration: configuration, delegate: self, delegateQueue: nil)

Для обычных сессий доступны оба метода. Background сессии умеют использовать только делегат.


Итак, настроили сессию, создали, давайте посмотрим на то, как что-нибудь скачать.


Общая схема скачивания данных в фоне


Чтобы скачать данные, обычно нужно сформировать запрос (URLRequest), прописав в нём нужные параметры/заголовки/данные, создать URLSessionDownloadTask и запустить её на исполнение. Примерно так:


var request = URLRequest(...)
// настроим request, как требуется

let task = session.downloadTask(with: request)
if #available(iOS 11, *) {
    task.countOfBytesClientExpectsToSend = [approximate size of request]
    task.countOfBytesClientExpectsToReceive = [approximate size of response]
}
task.resume()

В этом месте ничего особо не отличается от обычной задачи на скачивание. Появились, правда, два параметра countOfBytesClientExpectsToSend/countOfBytesClientExpectsToReceive, они показывают объём данных, которые мы планируем отослать в запросе и получить обратно в ответе. Это нужно для того, чтобы система могла более грамотно распланировать работу с задачей, скачать быстрее, не перенапрягаясь. Эти величины не обязательно должны быть точные.


После resume() задача отправится на выполнение. Во время передачи данных будет передаваться прогресс (про него — читайте ниже, там тоже есть варианты), а после завершения выполнится несколько методов делегата. Среди них есть один очень важный:


urlSession(_:downloadTask:didFinishDownloadingTo:)

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


После этого важного вызовется ещё метод, куда свалится ошибка, если она произошла. Если ошибки нет, error будет равен nil.


urlSession(_:task:didCompleteWithError:)

А что происходит при завершении, если приложение ушло в фон или было завершено? Как вызвать делегатные методы? Тут всё непросто.


Если закончилось скачивание чего-то, что было запущено приложением, и стоит флаг sessionSendsLaunchEvents в конфигурации сессии, то система запустит приложение (в фоне), и вызовет в AppDelegate, метод application(_:handleEventsForBackgroundURLSession:completionHandler:).


В этом методе приложение должно:


  • сохранить completionHandler (его потребуется вызвать через некоторое время, асинхронно и в главном потоке);
  • пересоздать фоновую сессию с тем же идентификатором, который был раньше (и который передаётся в этот метод на случай, если есть несколько фоновых сессий);
  • во вновь созданную сессию в делегат прилетят события (в частности, тот самый, важный, urlSession(_:downloadTask:didFinishDownloadingTo:)), нужно их обработать, скопировать файлы, куда требуется;
  • после того, как все методы вызваны, вызовется ещё один метод делегата, который называется urlSessionDidFinishEvents(forBackgroundURLSession:) и в котором нужно будет вызвать сохранённый раньше completionHandler.

Важно. Вызывать completionHandler необходимо обязательно в главном потоке, используя DispatchQueue.main.async(...).

При этом нужно помнить, что всё это происходит в приложении, которое работает в фоне. А это значит, что ресурсы (время исполнения) ограничены. Быстро сохранить файлы куда нужно, поменять нужные состояния в приложении и завершить работу — вот примерно всё, что можно сделать. Если хочется сделать больше, то можно воспользоваться UIApplication.beginBackgroundTask() или новыми BackgroundTasks.


Общая схема фоновой отправки данных


Отправка файлов на сервер также работает с ограничениями. Начинается, впрочем, всё похожим образом: формируем запрос, создаем задачу (теперь это будет URLSessionUploadTask), запускаем задачу. В чём проблема?


Проблема в том, как мы создаём запрос. Обычно мы формируем отправляемые данные, как Data. Background URLSession, не умеет с таким работать. И с потоковым запросом (uploadTask(withStreamedRequest:)) тоже не умеет. Необходимо записать всё, что нужно отправить, в файл, и создать задачу отправки из файла. Получается как-то так:


var fileUrl = methodThatSavesFileAndRetursItsUrl(...)

var request = URLRequest(...)
let task = session.uploadTask(with: request, fromFile: fileUrl)
task.resume()

Зато нет необходимости прописывать размер, URLSession может его посмотреть сама. После отправки вызовется тот же метод делегата urlSession(_:task:didCompleteWithError:), что и при скачивании. И точно также, если приложение было убито или ушло в фон в процессе отправки, прилетит application(_:handleEventsForBackgroundURLSession:completionHandler:), который нужно обработать ровно по тем же правилам, что и при скачивании данных.


Что такое «приложение завершено»


Чтобы тестировать фоновые скачивания и отправки, нужно имитировать завершение приложения (фоновая работа с сетью специально сконструирована, чтобы это переживать). Как это сделать? Изначально — никак. То есть нет никакого штатного (разрешённого, публичного) метода, который бы это позволял сделать. Посмотрим, где тут грабли.


  • во-первых, просто закрыть приложение (нажав кнопку Home или выполнив соответствующий жест) — не выйдет. Это не убьёт приложение, а лишь отправит его в фон. Смысл же работы с background сессией в том, что она работает даже, если приложение «совсем-совсем» убито;
  • во-вторых, нельзя, чтобы был подключён отладчик (AppCode, Xcode или просто LLDB), он не даст умереть приложению даже через некоторое время после его «закрытия»;
  • в-третьих, нельзя убивать приложение из панели задач (task manager, двойной Home или медленный свайп «наверх»). Таким образом убитое приложение считается убитым «насовсем», и система останавливает вместе с таким действием и фоновые сессии, привязанные к приложению;
  • в-четвертых, тестировать этот процесс нужно на настоящем устройстве. Там нет проблем с протоколированием (про это ниже) и оно более отлажено. Утверждается, что симулятор тоже должен работать, как нужно. Но я замечал необъяснимые странности, которые ничем, кроме глюков симулятора, не могу объяснить. В общем, тестируйте на устройстве;
  • единственный разумный способ сделать то, что хочется — функция exit(int). Как все знают, выкладывать это в стор нельзя (это напрямую противоречит требованиям), но мы пока просто тестируем — не страшно. Мне известно два разумных варианта использовать эту функцию:
    • вызывать её автоматически в методе AppDelegate.applicationDidEnterBackground(_:), чтобы приложение убивалось сразу после выхода в СпрингБорд;
    • сделать в интерфейсе компонент (например, кнопку, или на жест повесить действие), по нажатию на который, вызывается exit(...).
      При этом приложение убьётся, а фоновая работа с сетью должна продолжиться. И, через некоторое время, мы должны получить вызов application(_:handleEventsForBackgroundURLSession:completionHandler:).

Как протоколировать работу приложения, если нельзя пользоваться отладочной консолью Xcode?


Ну, как нельзя. Можно, если очень хочется. Запускать из Xcode нельзя, а если приложение уже, например, перезапустилось по системному событию, можно присоединиться (attach to process) к приложению и подебажить. Но это решение так себе, нужно ведь и сам процесс перезапуска как-то тестировать.


Можно использовать протоколы (логи, logs). Есть несколько вариантов их реализации:


  • print. Используется часто, как «давайте быстро что-нибудь выведем». В нашем случае использовать нельзя, так как доступа к консоли на устройстве у нас нет, приложение убито.
  • NSLog. Будет работать, так как использует третий метод.
  • os_log. Самый правильный метод, позволяющий нормально настроить логи, проставить им нужный тип, отключить после отладки, не вырезая сам код, и так далее.

Внимание! С os_log встречаются проблемы (например, отсутствие debug-логов), которые воспроизводятся только в симуляторе, но не воспроизводятся на настоящем устройстве. Пользуйтесь устройством.

Как пользоваться os_log, читайте, как его правильно настроить, в документации Apple. В частности, стоит включить debug и info логи, по-умолчанию они скрываются.


Отслеживание прогресса скачивания или отправки данных


В процессе передачи данных, хочется понимать, сколько уже отправлено, сколько ещё осталось. Для этого есть два способа. Первый — использовать делегатные методы:


  • для отправки нужно использовать urlSession(_:task:didSendBodyData:totalBytesSent:totalBytesExpectedToSend:)
  • для скачивания есть похожий метод urlSession(_:downloadTask:didWriteData:totalBytesWritten:totalBytesExpectedToWrite:)

Эти методы вызываются каждый раз, когда скачалась или отправилась очередная порция данных. Они не обязательно согласованы с методами завершения процесса, могут вызываться и после того, как данные полностью скачались или отправились, поэтому определять, что «всё завершилось» по ним нельзя.


Второй способ более интересный. Дело в том, что каждая задача предоставляет объект типа Progress (лежит он в поле task.progress), который предоставляет возможность следить за произвольным процессом, в том числе за процессом передачи данных. Чем он интересен? Двумя вещами:


  • из объектов Progress можно создавать дерево выполнения задач, каждый узел которого будет показывать, насколько продвинулись все задачи, которые в нём содержатся. Например, если вам нужно отправить пять файлов, можно взять по прогрессу на каждый, сделать общий прогресс, добавить в него пять других, и следить за одним, родительским прогрессом, привязав его обновления к какому-нибудь элементу интерфейса;
  • можно добавлять свои прогрессы в это дерево, а также можно приостанавливать и отменять действия, связанные с добавленными прогрессами.

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


«Передача» задач из обычной сессии в background сессию


Хорошо, с background сессией работать сложнее. Но это же удобно! Ни одна задача не потеряется, мы когда-нибудь получим все данные, которые запросили, почему не использовать background сессию всегда?


К сожалению, у неё есть недостатки, и серьёзные. Например, background сессия работает медленнее. В моих экспериментах скорость разнилась в несколько раз. Во-вторых, фоновое выполнение задачи может быть отложено (особенно если проставлен параметр isDiscretionary, который, как я упоминал, всегда true для задач, созданных, пока приложение работает в фоновом режиме.


Поэтому каждый раз, когда вы создаёте задачу, нужно точно понимать, какие критерии её работы, куда её добавлять, в обычную или в background сессию. Обычная работает быстрее, запускается сразу. Background — дольше, не сразу, но не убьётся, если пользователь закрыл приложение.


Если нет очевидного понимания, что задача должна быть выполнена в background сессии (как, например, некритичная передача очень большого количества данных, вроде синхронизации или бекапа), то стоит делать следующим образом:


  • начинаем задачу в обычной сессии. При этом запускаем backgroundTask, чтобы система понимала, что нам нужно время на завершение задачи. Это даёт какое-то время (до нескольких минут, но в iOS 13 что-то сломали и не понятно, что с этим происходит), чтобы задача успела выполниться.
  • если не успевает — то в завершении backgroundTask переносим задачу из обычной сессии в фоновую, где она продолжает работать, и завершается, когда может.

Как передать? Никак. Просто убиваем (cancel) обычную задачу, и создаём аналогичную (с таким же запросом) фоновую. Почему же это называется «передача»? И почему в кавычках?


Для отправки данных никакой передачи нет. Есть именно то, что описано. Убили одну задачу, запустили другую, все данные, которые отправили в первый раз, потерялись.


Для скачивания ситуация другая. Система знает, в какой файл какой запрос скачивается. Если вы запустите несколько задач на скачивание одинакового урла, например, она не будет выполнять запрос несколько раз. Данные скачаются один раз, после чего несколько раз выполнится завершающий делегатный метод (или колбэк). Здесь описан эксперимент, который это подтверждает. Скорее всего, внутри используется стандартное HTTP-кеширование, такое же, как в браузерах.


Вот примерный код, который это делает:


let request = URLRequest(url: url)
let task = foregroundSession.downloadTask(with: request)
let backgroundId = UIApplication.shared.beginBackgroundTask {
    task.cancel()
    let task = backgroundSession.downloadTask(with: request)
    task.resume()
}
task.resume()

Если задача завершится раньше, чем сработает expirationHandler, то необходимо не забыть вызвать UIApplication.shared.endBackgroundTask(backgroundId). Более подробно это описано в документации.


Чтобы помочь системе продолжить скачивание (например, отмена может привести к тому, что временный файл удалится перед тем, как возобновится фоновое скачивание), есть специальные методы:



let request = URLRequest(url: url)
let task = foregroundSession.downloadTask(with: request)
let backgroundId = UIApplication.shared.beginBackgroundTask {
    task.cancel { data in
        let task: URLSessionDownloadTask
        if let data = data {
            task = backgroundSession.downloadTask(withResumeData: data)
        } else {
            task = backgroundSession.downloadTask(with: request)
        }
        task.resume()
    }
}

Грабли, на которые я наступил


Логи


Самая сложная часть во всём этом — точно понимать, что происходит. Отличное протоколирование — первая задача, которую нужно сразу решать. Поведение background сессий нельзя протестировать никак, кроме как нормальными логами.


Также, к сожалению, работа background сессий никак не тестируется юнит-тестами, так как для их работы нужно приложение, нужно уметь уводить приложение в фон, необходимо уметь убивать приложение и потом отслеживать его запуск (причём запуск без UI, система перезапускает приложение в фоновом состоянии). Поэтому, повторюсь, единственный способ тестирования — вручную. И единственный способ видеть то, что тестируется — логами, в консоли, используя os_log. (или NSLog)


Приостановка бизнес-логики


Если скачивание или отправка данных является частью какой-то бизнес логики, нужно уметь приостанавливать эту логику на время передачи данных. Поскольку это время может включать в себя перезапуск приложения, данные логики должны сохраняться куда-то кроме памяти. Когда система перезапускает приложение, так как закончился процесс передачи данных, это не значит, что у вас есть время (и возможность) продолжить делать длинную цепочку действий. Повторюсь, перезапуск происходит в фоне, поэтому нет возможности спросить пользователя что-то, повторно залогиниться, ничего другого. Если работа с сессией — это последнее действие в цепочке действий — ура, вы завершили что хотели. Если нет — нужно отметить, что передача данных завершена и запустить что-то другое (другую передачу), либо запомнить этот факт до тех пор, пока пользователь снова не запустит приложение.


Тестирование на устройстве


Тестировать нужно только на устройстве. И ни в коем случае не пользоваться выгрузкой приложения из памяти (из переключателя задач), это действие всё ломает. Система считает, что пользователь не зря выгружает приложение, что ему это нужно, и не перезапускает фоновые процессы передачи данных.


Ограничения


Напомним ограничения фоновых сессий:


  • только делегатные методы, колбэки запрещены;
  • отправка — только файлов, ничего другое не работает;
  • получение прогресса передачи работает только пока приложение запущено, в фоне процесс недоступен (впрочем, зачем он убитому приложению…);

Мелочи


  • Если у вас в приложении есть несколько сессий, то идентификатор задачи (task.taskIdentifier) нельзя использовать, как ключ ассоциативного массива (Dictionary). Это простые целые числа, и у каждой созданной сессии эти числа начинаются с 1, поэтому будут пересекаться.
  • Есть такой метод, URLSession.getAllTasks. Он может быть удобен, если хочется перевести все текущие таски в background сессию. К сожалению, у него есть особенность. Если тасков нет, то колбек не вызовется. ¯\_(ツ)_/¯
  • При отправке файлов, временные файлы, которые необходимо создавать и которые потом отправляются на сервер, не удаляются автоматически сессией, эта ответственность лежит на приложении.

Если у вас в приложении есть расширения, там тоже можно работать с background сессиями, и даже использовать идентификаторы основного приложения. Но делать это нужно очень аккуратно, чтобы расширение и основное приложение что-нибудь не поломали друг другу. Вот немного документации по этому поводу: https://developer.apple.com/library/archive/documentation/General/Conceptual/ExtensibilityPG/ExtensionScenarios.html#//apple_ref/doc/uid/TP40014214-CH21-SW1. Там много интересного, например:


If your app extension initiates a background NSURLSession task, you must also set up a shared container that both the extension and its containing app can access. Use the sharedContainerIdentifier property of the NSURLSessionConfiguration class to specify an identifier for the shared container so that you can access it later.
Tags:
Hubs:
+17
Comments18

Articles

Information

Website
redmadrobot.ru
Registered
Founded
Employees
1,001–5,000 employees
Location
Россия