В последнее время, часто вижу вопрос:
Можно ли в iOS работать с геолокацией, когда приложение свернули и отправлять данные на сервер?

Это действительно возможно и совсем не сложно.
How to вместится в превью статьи.

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


Чтобы была какая-то конкретика, я предположил, что перед нами стоит задача написать вело-трекер. Со стороны пользователя это выглядит так:


  1. Скачиваю приложение
  2. Запускаю
  3. Регистрируюсь
  4. Нажимаю куда просят
  5. Закрываю
  6. Катаюсь
  7. Запускаю
  8. Вижу результат

p.s. финальный код здесь.


Для разработки можно выделить 3 основных направления работы.


  1. Стабильность работы с гео-данными.
    Пользователь запустил приложение, свернул или же вовсе закрыл — приложение должно обрабатывать данные.
  2. Экономия батареи.
    Пожалуй, не нуждается в дополнительном пояснении.
    Кстати, это самая сложная часть работы.
  3. Правильная обработка данных.
    Работу с данными нужно тестировать и отлаживать.

Инициализация менеджера


Для начала рассмотрим столь привычную работу с CLLocationManager.


  1. Создаем менеджер.
  2. Подписываемся на события.
  3. Запускаем менеджер для реакции на изменения геолокации.

Минимальный код
import CoreLocation

final class LocationService: NSObject, CLLocationManagerDelegate {
    private let locationManager = CLLocationManager()

    override init() {
        super.init()
        locationManager.delegate = self
        locationManager.startUpdatingLocation()
    }

    func locationManager(_ manager: CLLocationManager,
                         didUpdateLocations locations: [CLLocation]) {
    }
}

Но чтобы это заработало, необходимо подтверждение пользователя, что он позволяет использовать информацию о его местоположении.


… или право имею?


iOS предоставляет 2 права на работу с геолокацией:


  1. requestWhenInUseAuthorization — можем получать информацию об обновлении локации, когда приложение активно.
  2. requestAlwaysAuthorization — дополнительно получаем возможность получения событий CLLocationManager API, когда приложение не активно / закрыто.

Из этого можно подумать, что работать с геолокацией в фоне можно только с правами requestAlwaysAuthorization — это не так.


И точно так же requestAlwaysAuthorization не позволяет спокойно работать в фоне "из коробки". Речь идет о работе с регионами, популярными местами, значительными перемещениями и тп.


Если код приведенный выше это что есть в проекте, то вызов метода requestWhenInUseAuthorization() либо requestAlwaysAuthorization() не покажет пользователю алерт о запросе прав.
Для этого так же необходимо добавить поясняющий текст сообщения в info.plist в соотвествующий ключ NSLocationAlwaysUsageDescription / NSLocationWhenInUseUsageDescription


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


Работаем в фоне


Чтобы приложение могло работать с геолокацией в фоне, необходимо сделать 2 вещи:


  1. Выставить у CLLocationManager
    allowsBackgroundLocationUpdates = true
  2. Добавить в параметр в Background Modes ("Background Modes -> Location updates"). В противном случае будет выброшен exception.

Все, приложение может работать в фоне с геолокацией, а также отправлять сетевые запросы и тп.


Что-то пошло не по плану


Как только пользователь, свернув приложение, будет некоторое время оставаться с точки зрения системы неподвижно, геолокация остановится, а вместе с ним и приложение.
Все дело в том, что CLLocationManager по умолчанию использует паузу для геолокации pausesLocationUpdatesAutomatically. И этот параметр не так прост, как кажется.


  1. Я запустил приложение и начал движение. Приложение работает.
  2. Я свернул приложение и продолжаю движение. Приложение работает.
  3. Я встретил друга и остановился с ним поговорить. Приложение все еще работает.

И в какой то момент оно перестает работать.


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


Я продолжил движение, но LocationManager все еще на паузе. И он будет оставаться на паузе, до тех самых пор, пока я сам не разверну приложение.


Таким образом, система старается экономить заряд батареи в случаях, когда нужно работать до "остановки".


p.s. если пауза нужна

Если все же приложению нужно работать до окончания движения, то можно помочь системе лучше определить это состояние указав в "activityType" подходящую цель, для чего мы работаем с геолокацией.


Для нас это ощутимая проблема, поэтому просто отключаем паузу для LocationManager'а


pausesLocationUpdatesAutomatically = false

Не убивай меня, Иван-царевич username!


Ранее, я уже упоминал о праве доступа к геолокации requestAlwaysAuthorization. И о том, что это дает возможность получать события CLLocationManager API. Причем получать как находясь в фоне, так и в выгруженном состоянии. В случае последнего, система может перезапустить наше приложение, чтобы доставить новое событие. К примеру:


locationManager.startMonitoringSignificantLocationChanges() —  на значительные перемещения
locationManager.startMonitoringVisits() — регулярно посещаемые места
locationManager.startMonitoring(for: CLRegion) — а также вход или выход из установленной области

Это мы и будем использовать. Если пользовать убивает приложение, то нам нужно максимально быстро вернуться в работу. В моем случае самое подходящее будет startMonitoringSignificantLocationChanges, поскольку регионы имеют ограничения в радиусе. Главное не забыть по запуску опять настроить и запустить CLLocationManager.


Полный код
import CoreLocation

final class LocationService: NSObject, CLLocationManagerDelegate {
    private let locationManager = CLLocationManager()

    override init() {
        super.init()

        locationManager.delegate = self
        locationManager.requestAlwaysAuthorization()
        locationManager.allowsBackgroundLocationUpdates = true
        locationManager.pausesLocationUpdatesAutomatically = false
        locationManager.startUpdatingLocation()
        locationManager.startMonitoringSignificantLocationChanges()
    }

    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
    }
}

Отлично! Приложение работает в фоне, приложение перезапускается, работает с геолокацией, может работать с сетью и тп. Вот только удалят нас с такой активностью с девайса.


Экономия батареи


Если открыть статистику энергопотребления, то наше приложение с вероятностью 99.99% будет лидером и к сожалению не по экономии. Поэтому теперь будем оптимизировать.


Погрешность


На расход батареи очень сильно влияет требуемая погрешность от CLLocationManager.
Мы можем потребовать максимально точные данные, а можем с погрешностью около 10 метров, 3 километра и тп (kCLLocationAccuracy*).
Соотвественно, чем выше требуемое качество данных, тем больше расход батареи.


Поэтому, когда вам достаточно погрешности в 100м, не нужно брать максимальное качество.
Более интересно то, что если требовать низкое качество, то скорее всего система даст больше, чем вы ожидаете. Поэтому крайне важно не требовать погрешность лучше, чем вам действительно нужно.
p.s. требуемое не означает действительное.


Конфигурация


Дополнительно можно выиграть в борьбе за батарею, если вспомнить о distanceFilter и allowDeferredLocationUpdates.


  • distanceFilter позволяет задать дистанцию в метрах, в течении которого нас не интересует изменение геолокации от точки до точки. Экономия не сказать что огромная, но экономия.
  • Отложенные уведомления (allowDeferredLocationUpdates) позволяют системе доставлять информацию по своему усмотрению, либо в соотвествии с заданными критериями.
    Критериями могут выступать дистанция и время.
    К примеру, с момента получения последней точки логика приложения не сломается, если в течении 5 минут точки могут не приходить, но потом система их доставит все разом.
    Критерием выступает время равное 5 минутам. Если другое приложение в это время запросит геоданные, система может попутно и нам отдать накопленные точки. Здесь есть ряд смежных ограничений на настройку CLLocationManager которые надо не забыть.

Работаем с данными тогда, когда это действительно нужно


Мы обозначили, что наша цель это вело-трекер. В данный момент мы обрабатываем данные всегда, не зависимо от того нужны они нам или нет. Из-за паузы мы не можем останавливать геолокацию.


Одно из решений будет менять требуемое качество данных во время поездки и в остальное время. Разница между наилучшими данными и наихудшими почти в 4 раза.


Изменения настроек CLLocationManager в зависимости от состояния
func setActiveMode(_ value: Bool) {
    if value {
        locationManager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
        locationManager.distanceFilter = 10
    } else {
        locationManager.desiredAccuracy = kCLLocationAccuracyThreeKilometers
        locationManager.distanceFilter = CLLocationDistanceMax
    }
}

Теперь осталось только отследить, когда пользователь едет на велосипеде. Для этого мы можем использовать CMMotionActivityManager из CoreMotion.


Отслеживаем тип активности
motionManager.startActivityUpdates(to: .main, withHandler: { [weak self] activity in
    self?.setActiveMode(activity?.cycling ?? false)
})

Полный код LocationService
import CoreLocation
import CoreMotion

final class LocationService: NSObject, CLLocationManagerDelegate {
    private let locationManager = CLLocationManager()
    private let motionManager = CMMotionActivityManager()

    override init() {
        super.init()

        locationManager.delegate = self
        locationManager.requestAlwaysAuthorization()
        locationManager.allowsBackgroundLocationUpdates = true
        locationManager.pausesLocationUpdatesAutomatically = false
        setActiveMode(true)
        locationManager.startUpdatingLocation()
        locationManager.startMonitoringSignificantLocationChanges()

        motionManager.startActivityUpdates(to: .main, withHandler: { [weak self] activity in
            self?.setActiveMode(activity?.cycling ?? false)
        })
    }

    func setActiveMode(_ value: Bool) {
        if value {
            locationManager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
            locationManager.distanceFilter = 10
        } else {
            locationManager.desiredAccuracy = kCLLocationAccuracyThreeKilometers
            locationManager.distanceFilter = CLLocationDistanceMax
        }
    }

    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
    }
}

График энергопотребления vs часы работы.

Тип A: Максимальное качество
Тип B: Максимальное качество + фильтрация
Тип C: Худшее качество + фильтрация
Тип D: Без приложения


Можно ли еще улучшить? Разумеется. Данный подход необходим, если критично обрабатывать всю геолокацию в фоне. Дальше зависит от вашей фантазии.


Для iOS 10+ необходимо прописать NSMotionUsageDescription в Info.plist
<key>NSMotionUsageDescription</key>
    <string>$(PRODUCT_NAME) motion use.</string>

Проверяем обработку геоданных


Работу приложения надо проверять. Отрываться в процессе написания на "поездку" — не самая хорошая идея. Да и про дебагинг в таком подходе можно забыть.


К счастью, Apple позволяет нам использовать GPX файлы (и нам даже не нужен реальный девайс для работы в данным случае).


Выбираем сервис, генерирующий маршрут движения, и сохраняем в gpx файл вида:


Пример файла
<?xml version="1.0"?>
<gpx version="1.1" creator="Xcode">
    <wpt lat="54.91148" lon="83.07381"/>
    <wpt lat="54.90792" lon="83.07243"/>
</gpx>

Разрешаем в настройках схемы симуляцию геолокации и загружаем наш GPX файл.

Выбираем нужную симуляцию и пользуемся отладкой.

Вместо заключения


К сожалению, сложно написать что есть интересное, что есть очевидное, поэтому совсем не много моментов:


  • Если приложение "перезапустилось" в фоне, то пользователь не сможет его "убить".
  • Если геолокация ушла в паузу, когда приложение находится в фоне, то снять с паузы не получится, используя пуш-уведомления, регионы и тп. Тоже самое относится к перезапуску менеджера. Применится только после "разворота" приложения.
  • Геолокация работает с погрешностью. Если не использовать фильтр, то можно стоя на одном месте получить множество изменений геолокации.
  • Можно включать симуляцию геолокации на реальном девайсе, причем другие приложения так же будут работать с "новой" геолокацией. К примеру, карты.
  • При длительной симуляции геолокации реальный девайс может "залипнуть" и перестать отключать симуляцию. Помогает только перезагрузка.
  • При старте приложения не исключено, что вы получите "старую точку". Не забывайте отсматривать ts.
  • Можно сделать обертку над CLLocationManager и парсить GPX для тестирования.
  • GPX файл позволяет задавать скорость в точках.
  • GPS около некоторых объектов может не работать.
  • Можно использовать геолокацию в авиарежиме.
  • Можно использовать геолокацию без симкарты.

p.s. финальный код здесь.

Поделиться с друзьями
-->

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


  1. modest_man
    02.05.2017 14:48

    Спасибо за статью.
    Подскажите как правильно реализовать следующую задачу — необходимо получать геопозицию 1-2 раза в день, проверять, что пользователь находится в том же городе или уже в другом, и если переехал — показать push или local уведомление.
    SignificantLocationChanges — проверяет изменение геопозиции каждые 5 — 15 минут, мне кажется это лишнее.
    Я рассматриваю возможность отправки пушей с флагом content-available для того чтобы изредка будить приложение и в фоне проверять геопозицию. Будет ли работать такая схема?


    1. smirnov_a_v
      03.05.2017 17:01

      Добрый день!
      У нас был опыт подобного рода, и простые silent-пуши не решили проблему, так как, начиная с iOS 7, пуши не приходят, если приложение было убито пользователем. Мы попытались извратиться и написать следующий костыль: вместо обычных silent-пушей, отправлять voip-пуш. В этом случае всё работает клёво, но Apple не пропустит такое приложение в стор, по крайней мере без IP-звонков. У нас они всё равно стояли в плановых фичах, поэтому вот делаем. Для Вас вряд ли такое решение актуально, но решил обсудить наболевшую тему.


  1. dolphin4ik
    02.05.2017 16:55
    +2

    Еще бы такую же историю про андроид


  1. DrFaust4
    03.05.2017 17:01

    Статья полезная, спасибо.
    Еще бы подробнее про GPX файлы. Я так понял, что после запуска приложения, система начинает «проигрывать» файл но не понятно:
    Как задается скорость движения между точками?
    CLLocationManager будет реагировать на точки или возможно срабатывание и в промежутках (в зависимости от настроек самого менеджера)?


    1. ajjnix
      04.05.2017 09:21

      Начнет проигрывать файл только по требованию, просто можно указать сразу запускать или не сразу (все там же в настройках схемы).
      Про скорость вот тут можно глянуть.

      Provide one or more waypoints containing a latitude/longitude pair. If you provide one waypoint, Xcode will simulate that specific location. If you provide multiple waypoints, Xcode will simulate a route visitng each waypoint.

      Optionally provide a time element for each waypoint. Xcode will interpolate movement at a rate of speed based on the time elapsed between each waypoint. If you do not provide a time element, then Xcode will use a fixed rate of speed. Waypoints must be sorted by time in ascending order.


      Про точки и промежутки не понял вопроса, метод вызывается когда новые данные появились (обычно в массиве 1 элемент, но к примеру при использовании `defer` в массиве все точки что накопились)


  1. IgorFedorchuk
    05.05.2017 12:29

    По поводу

    func allowDeferredLocationUpdates(untilTraveled distance: CLLocationDistance, timeout: TimeInterval)
    

    В документации в описании к нему указано:
    Start the delivery of location updates before calling this method. The most common place to call this method is in your delegate’s locationManager(_:didUpdateLocations:) method.

    В Вашем примере метод вызывается до старта получения локации и не в колбеке
     locationManager(_:didUpdateLocations:)
    
    Также отсутствует
    locationManager(_:didFinishDeferredUpdatesWithError:)
    
    . Это сделано умышленно?


    1. ajjnix
      05.05.2017 13:21

      Код писался с максимально простотой, по этому отсутствует обработка ошибок в принципе didFailWithError / didFinishDeferredUpdatesWithError (CLError.h).



      Про то что allowDeferredLocationUpdates вызывается до didUpdateLocations:
      Start the delivery of location updates before calling this method.

      Соблюдено, CLLocationManager уже запущен. didUpdateLocations это не старт менеджера, первый вызов метода может быть и спустя пару минут, что наблюдал на практике.
      Другое дело, что обычно allowDeferredLocationUpdates вызывается из didUpdateLocations, потому что в документации дальше следует:


      After processing any new locations, call this method if you want to defer future updates until the distance or time criteria are met.

      p.s. большое спасибо за комментарий, попутно вспомнил и поправил в статье, что я забыл о нюансах allowDeferredLocationUpdates и выпилил использование из примера ибо влиять в том использовании не должно было.
      p.p.s. статью писал почти через 9 месяцев "не работы" с геолокацией, немного подзабыть успел, надеюсь никому не навредил "бесполезным" куском кода.


      1. IgorFedorchuk
        05.05.2017 13:34

        func start() {
                setActiveMode(true)
                locationManager.startUpdatingLocation()
                locationManager.startMonitoringSignificantLocationChanges()
                
                motionManager.startActivityUpdates(to: .main, withHandler: { [weak self] activity in
                    self?.setActiveMode(activity?.cycling ?? false)
                })
            }
        

        В функции setActiveMode(true) выставляется allowDeferredLocationUpdates и только потом стартует получение локации locationManager.startUpdatingLocation()


        1. ajjnix
          05.05.2017 13:38

          Да, большое спасибо, недоглядел. Использование в таком виде allowDeferredLocationUpdates в принципе бесполезно было, по статье и коду убрал, текст дополнил.


  1. 34x
    10.05.2017 05:05

    У меня есть похожее приложение, но я умышленно не использую паузу, в итоге при использовании только significantChange и установке минимальных accuracy и distanceFilter приложение в списке пожирателей батареи не фигурирует вовсе либо изредка в пределах 1%. Но у меня цель записывать именно редкие точки достаточно отдаленные друг от друга.


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


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


    1. ajjnix
      10.05.2017 05:19

      Симуляция нужна только при дебаге, убедится что все работает как надо.
      Про погрешность я уже писал, зато вспомнил благодаря комментарию о "авиарежиме", отсутствии симкарты и блокировке GPS, что дописал в "интересные моменты".