Энергосберегающий background location + отправка данных на сервер из фона

в 10:17, , рубрики: geolocation, ios development, разработка под iOS, метки: ,
Постановка задачи

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

Решение

Решение в лоб — использовать данные из коллбеков [CLLocationManagerInstance startUpdatingLocation] как в фоне, так и когда приложение активно. Первый и наиболее критичный недостаток данного решения — высокое энергопотребление (за несколько часов аккумулятор iPhone может полностью сесть). Второй — если приложение будет свернуто и 'убито', никаких апдейтов положения пользователя мы получить не сможем.

Для решения этих двух проблем, а также для того, чтобы сделать данное решение обособленным и не связанным с кодом основного приложения, напишем свой компонент, который будет использовать [CLLocationManagerInstance startUpdatingLocation] в активном режиме приложения и [CLLocationManagerInstance startMonitoringSignificantLocationChanges] в фоне. В компоненте будет два блока, которые будут исполнятся в зависимости от того, в каком состоянии находится приложение.

Определение местоположения пользователя

Foreground

Для активного приложение решение очевидно — нам нужно создать инстанс CLLocationManager и установить делегат, а затем в коллбеках обрабатывать полученные данные. Создадим объект-обертку:

#import <CoreLocation/CoreLocation.h>

typedef void(^locationHandler)(CLLocation *location);

@interface DLLocationTracker : NSObject <CLLocationManagerDelegate>

@property (nonatomic, strong) CLLocationManager *locationManager;

@property (nonatomic, copy) locationHandler locationUpdatedInForeground;

- (void)startUpdatingLocation;
- (void)stopUpdatingLocation;

@end

Блок locationUpdatedInForeground будет исполнятся при обновлении положения пользователя. Объект создается в контроллере, затем необходимо вызвать метод startUpdatingLocation для начала работы сервиса.

Background

Как уже упоминалось выше, есть два основных способа получать обновления координат в фоне:

  • Выставить в *.plist приложения UIBackgroundModes = «location», и использовать [locationManager startUpdatingLocation] — очень энергозатратный, но точный способ;
  • Использовать Significant Location Changes (>iOS 4.0) — энергоэффективно, использует данные сотовых сетей. Обновляется приблизительно раз в 10-15 минут, погрешность до 500 метров (определено опытным путем). Более подробно можно прочесть здесь.

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

#import <CoreLocation/CoreLocation.h>

typedef void(^locationHandler)(CLLocation *location);

@interface DLLocationTracker : NSObject <CLLocationManagerDelegate>

@property (nonatomic, strong) CLLocationManager *locationManager;

@property (nonatomic, copy) locationHandler locationUpdatedInForeground;
@property (nonatomic, copy) locationHandler locationUpdatedInBackground;

- (void)startUpdatingLocation;
- (void)stopUpdatingLocation;
- (void)endBackgroundTask;

@end

locationUpdatedInBackground блок будет вызываться, когда приложение получает апдейт координат в фоновом режиме.
endBackgroundTask — метод, который позволяет закончить задачу, выполняющуюся в фоне (рассмотрим позже).

Также в *.plist приложения нужно добавить пункт Required background modes = {App registers for location updates}.

Механизм Significant Location Changes позволяет получать апдейты местоположения даже в том случае, если приложение не запущено. Для этого нужно немножко переписать стандартный метод appDelegate applicationDidFinishLaunchingWithOptions:

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    
 	if ([launchOptions objectForKey:UIApplicationLaunchOptionsLocationKey]) {
        self.locationTracker = [[DLLocationTracker alloc] init];
        [self.locationTracker setLocationUpdatedInBackground:^(CLLocation *location) {
//тестовый блок, будет показывать local notification с координатами
            UILocalNotification *notification = [[UILocalNotification alloc] init];
            notification.fireDate = [NSDate dateWithTimeIntervalSinceNow:15];
            notification.alertBody = [NSString stringWithFormat:@"New location: %@", location];
            [[UIApplication sharedApplication] scheduleLocalNotification:notification];
        }];
        [self.locationTracker startUpdatingLocation];
    }
     .....
}

UIApplicationLaunchOptionsLocationKey — ключ, который показывает, что приложение было запущено в ответ на поступившее событие об изменении местоположения.

Реализация компонента

При инициализации компонента создается инстанс CLLocationManager и объект устанавливается его делегатом, также подписываем его на нотификации об изменении состоянии прилоложения (активное/фоновое).

- (id)init {
    if (self = [super init]) {
        [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(applicationDidBecomeActive) name:UIApplicationDidBecomeActiveNotification object:nil];
        [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(applicationDidEnterBackground) name:    UIApplicationDidEnterBackgroundNotification object:nil];
        self.locationManager = [[CLLocationManager alloc] init];
        self.locationManager.delegate = self;
    }
    return self;
}

Дальше вызовем startUpdatingLocation:

- (void)startUpdatingLocation {
    [self stopUpdatingLocation];
    [self isInBackground] ? [self.locationManager startMonitoringSignificantLocationChanges] : [self.locationManager startUpdatingLocation];
}

В зависимости от состяния приложения активируется нужный сервис.
Все самое интересное происходит в коллбеке CLLocationManager:

- (void)locationManager:(CLLocationManager *)manager didUpdateToLocation:(CLLocation *)newLocation fromLocation:(CLLocation *)oldLocation {
    //фильтруем апдейты на основании минимального времени обновления и минимально дистанции 
    if (oldLocation && ([newLocation.timestamp timeIntervalSinceDate:oldLocation.timestamp] < kMinUpdateTime ||
                        [newLocation distanceFromLocation:oldLocation] < kMinUpdateDistance)) {
        return;
    }
    
    if ([self isInBackground]) {
        if (self.locationUpdatedInBackground) {
            bgTask = [[UIApplication sharedApplication] beginBackgroundTaskWithExpirationHandler: ^{
                [[UIApplication sharedApplication] endBackgroundTask:bgTask];
            }];
            
            self.locationUpdatedInBackground(newLocation);
            [self endBackgroundTask];
        }
    } else {
        //если приложение активно - выполняем этот блок
        if (self.locationUpdatedInForeground) {
            self.locationUpdatedInForeground(newLocation);
        }
    }
}

Для того, чтобы наше приложение могло что-либо сделать в фоне, необоходимо вызвать метод beginBackgroundTaskWithExpirationHandler и проинициализировать идентификатор bgTask (тип UIBackgroundTaskIdentifier). Каждый вызов этого метода должен быть сбалансирован вызовом endBackgroundTask:, что и происходит в [self endBackgroundTask]:

- (void)endBackgroundTask {
    if (bgTask != UIBackgroundTaskInvalid) {
        [[UIApplication sharedApplication] endBackgroundTask:bgTask];
        bgTask = UIBackgroundTaskInvalid;
    }
}

Важным моментом является то, что блок locationUpdatedInBackground выполняется синхронно (мы можем себе это позволить, когда приложения в фоне), это может вызвать проблемы, если сворачивать/разворачивать приложение во время выполнения блока, а именно, если блок не выполниться в течение 10 секунд, приложение упадет.

Асинхронная отправка данных из фона

Для асинхронной отправки немого изменим код нашего компонента:

    if ([self isInBackground]) {
        if (self.locationUpdatedInBackground) {
            bgTask = [[UIApplication sharedApplication] beginBackgroundTaskWithExpirationHandler: ^{
                [[UIApplication sharedApplication] endBackgroundTask:bgTask];
            }];
            
            self.locationUpdatedInBackground(newLocation);
            //[self endBackgroundTask]; - заканчивать таск будем по коллбекам нашей асинхронной операции в реализации блока
        }

Блок locationUpdatedInBackground:

    __weak DLLocationTracker *lc = self.locationTracker;
    [self.locationTracker setLocationUpdatedInBackground:^ (CLLocation *location) {
//предположим, что у нас есть метод с completion и fail хендлерами для отправки местоположения
        [self sendLocationToServer:location completion:^{
               [lc endBackGroundTask];
        } fail:^(NSError *fail) {
               [lc endBackGroundTask];
        }];
    }];
Заключение

Подобный энергоэффективный способ используется во многих приложениях. Например, фича Radar в Forsquare. Код тестового приложения можно взять на github.

Автор: garnett

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js