iBeacon(3) - リージョン監視とレンジング

前回はデバイス監視を行いましたので今回はリージョン監視とレンジングです。

リージョン監視は、設定したリージョンにユーザが入ったり出たりしたときに通知を受け取る仕組みです。リージョン監視はバックグランドでも動作しますので、お店に入ったときにポイントカードやクーポンの通知を表示するといったアプリを簡単に実装することができます。

レンジングは、リージョンに入った後のiBeaconデバイスのUUID/major/minorといった情報と、Bluetooth信号強度や、およその距離が取得できます。レンジングが有効になっていると、1秒ごとに通知が来ます。レンジングはバックグランドでの動作がサポートされていないので、スタンプラリーやゲームといったユーザと対話的なアプリケーションに活用するのがよいでしょう。

登録したリージョンの監視とレンジングを行うサンプルアプリを用意しましたので、

こちらを動かしながら実装をみていきたいと思います。

リージョン監視

リージョン監視はCoreLocationのCLBeaconRegionというクラスで行います。初期化するときに、UUID、major、minor、identifierの値が指定できます。identifierは内部管理用のIDなので実際のiBeaconの信号には含まれません。UUIDのみを指定した場合は、major, minorの値にかかわらずUUIDにマッチしたすべてのiBeacon信号に反応します。majorを指定した場合は、UUID、majorの両方の値にマッチしたiBeacon信号に反応します。minorについても同様です。

CLBeaconRegionはこのように生成します。

    NSString *identifier = @"Enamel Systems";
    NSString *UUIDString = @"B9407F30-F5F8-466E-AFF9-25556B57FE6D";
    CLBeaconRegion *region = [[CLBeaconRegion alloc] initWithProximityUUID:[[NSUUID alloc] initWithUUIDString:UUIDString] identifier:identifier];

今後各リージョンで、モニタリングをしているかどうか、リージョンに入ったかどうか、レンジングのOn/Offなどのステータスを管理したいので、サンプルコードのほうでは、CLBeaconRegionを継承した、ESBeaconRegionというクラスを作成してCLBeaconRegionの代わりに使っています。

@interface ESBeaconRegion : CLBeaconRegion
@property (nonatomic) BOOL rangingEnabled;
@property (nonatomic) BOOL isMonitoring;
@property (nonatomic) BOOL hasEntered;
@property (nonatomic) BOOL isRanging;
@property (nonatomic) NSUInteger failCount;
@property (nonatomic) NSArray *beacons;
- (void)clearFlags;
@end

@implementation ESBeaconRegion
- (id)init
{
    self = [super init];
    if (self) {
    }
    return self;
}

- (void)clearFlags
{
    self.rangingEnabled = NO;
    self.isMonitoring = NO;
    self.hasEntered = NO;
    self.isRanging = NO;
    self.failCount = 0;
    self.beacons = nil;
}
@end

リージョン監視の開始および停止

リージョン監視の開始と停止は、それぞれ

   [_locationManager startMonitoringForRegion:region];
   [_locationManager stopMonitoringForRegion:region];

で行います。アプリが動作している環境で、リージョン監視が行えるかどうかのチェックが、

 - (BOOL)isMonitoringCapable
{
    if (![CLLocationManager isMonitoringAvailableForClass:[CLBeaconRegion class]]) {
        return NO;
    }
    return YES;
}

という形で行えますので、startMonitoringForRegionを呼び出す前に確認したほうが良いでしょう。

モニタリングが開始するとdidStartMonitoringForRegionが呼ばれます。リージョン監視を開始した時点で既にリージョンの中にいる場合はdidEnterRegionが飛んでこないので、didStartMonitoringForRegionの中でrequestStateForRegionを呼んで今のリージョン状態をリクエストします。

- (void)locationManager:(CLLocationManager *)manager didStartMonitoringForRegion:(CLRegion *)region
{
    NSLog(@"didStartMonitoringForRegion:%@", region.identifier);
    [self.locationManager requestStateForRegion:region];
}

requestStateForRegionに応じてdidDetermineStateが呼び出されます。また、リージョンに入ったり出たりしたときには、didEnterRegion、didExitRegionがそれぞれ呼ばれますので、

- (void)locationManager:(CLLocationManager *)manager didEnterRegion:(CLRegion *)region
{
    if ([region isKindOfClass:[CLBeaconRegion class]]) {
        [self enterRegion:(CLBeaconRegion *)region];
    }
}

- (void)locationManager:(CLLocationManager *)manager didExitRegion:(CLRegion *)region
{
    if ([region isKindOfClass:[CLBeaconRegion class]]) {
        [self exitRegion:(CLBeaconRegion *)region];
    }
}

- (NSString *)regionStateString:(CLRegionState)state
{
    switch (state) {
        case CLRegionStateInside:
            return @"inside";
        case CLRegionStateOutside:
            return @"outside";
        case CLRegionStateUnknown:
            return @"unknown";
    }
    return @"";
}

- (void)locationManager:(CLLocationManager *)manager didDetermineState:(CLRegionState)state forRegion:(CLRegion *)region
{
    NSLog(@"didDetermineState:%@(%@)", [self regionStateString:state], region.identifier);

    if ([region isKindOfClass:[CLBeaconRegion class]]) {
        switch (state) {
            case CLRegionStateInside:
                [self enterRegion:(CLBeaconRegion *)region];
                break;
            case CLRegionStateOutside:
            case CLRegionStateUnknown:
                [self exitRegion:(CLBeaconRegion *)region];
                break;
        }
    }
}

とします。[self enterRegion], [self exitRegion]ではそれぞれレンジングの開始や停止、UIへのdelegate呼び出しなどを行っています。

レンジング

レンジングはリージョン監視が有効になっているregionでのみ実行できます。レンジングの開始停止は、それぞれ

[_locationManager startRangingBeaconsInRegion:region];
[_locationManager stopRangingBeaconsInRegion:region];

で行います。レンジングが有効になると、locationManager:didRangeBeacons:inRegionが1秒ごとに呼ばれます。didRangeBeacons:beaconsのNSArrayはCLBeacon *の配列です。サンプルアプリでは、対応するESBeaconRegionにCLBeacon *の配列をbeaconsというpropertyで保持してdelegateへ通知を行っています。

- (void)locationManager:(CLLocationManager *)manager didRangeBeacons:(NSArray *)beacons inRegion:(CLBeaconRegion *)region
{
    ESBeaconRegion *esRegion = [self lookupRegion:region];
    if (! esRegion)
        return;

    esRegion.beacons = beacons;

    if ([_delegate respondsToSelector:@selector(didRangeBeacons:)]) {
        [_delegate didRangeBeacons:esRegion];
    }
}

レンジングを停止するまで、didRangeBeaconsが呼び続けられます。レンジング対象のiBeaconが一つも見つからない場合はdidRangeBeacons:beaconsがnilで呼ばれます。

リージョン監視の停止通知のほうはあまり感度がよくなくiBeaconの範囲外にでてからしばらくしてから通知されますが、didRangeBeaconsの感度は非常によく、1秒単位でiBeaconの存在検知が可能です。iBeaconの検知ができなくなると対応するCLBeaconオブジェクトがbeaconsから消滅するか、CLBeaconに値にrssiが0 、accuracyが-1.0、proximityがCLProxymityUnknownがセットされて呼び出されますので、リアルタイム性を要求されるアプリを作成する場合はレンジングを活用するのが良いでしょう。

無限レンジング対策

以前みたように、リージョンの監視内に入り、レンジングが有効になった状態で

  • アプリをバックグラウンドにする
  • 設定から位置情報サービスのアプリ項目をOffにする
  • iBeaconのリージョン範囲外に出る(かiBeaconの電源をOffにする)
  • 約5分間程度待つ
  • 設定から位置情報サービスのアプリ項目をOnにする
  • アプリをフォアグランドにする

の手順を踏むと、実際にはリージョンの外に出てしまっているのに、リージョン監視通知もデバイス設定変更通知も来ない状態が発生します。そのまま何もしないとRangingがOnになったままになってしまいます。バグっぽい動作ですので、もしかすると将来のiOSで修正されるかもしれませんが、今の段階では対策しておいたほうがよいでしょう。

問題の本質はバックグランドでの動作中にリージョン監視通知を取りこぼす(というか通知されない)ことにありますので、フォアグランドになったときに、あらためてリージョン監視の状態をチェックすることにします。

アプリケーションがフォアグラウンドになった時にイベントが通知されるように、NSNotificationCenterUIApplicationDidBecomeActiveNotificationを登録します。

- (id)initSharedInstance {
    self = [super init];
    if (self) {
        ...
        [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(applicationDidBecomeActive) name:UIApplicationDidBecomeActiveNotification object:nil];
    }
    return self;
}

これでアプリがフォアグランドになった時にESBeacon:applicationDidBecomeActive が呼ばれます。 applicationDidBecomeActiveを受け取った直後はrequestStateForRegionがうまく動作しないようですので、1秒ほどのdelayを入れて、リージョン監視をしているregionに対してrequestStateForRegionで現在の監視状況を要求します。

- (void)applicationDidBecomeActive
{
    [NSTimer scheduledTimerWithTimeInterval:1.0f target:self selector:@selector(checkRegionState:) userInfo:nil repeats:NO];
}

- (void)checkRegionState:(NSTimer *)timer
{
    // Update current region status when application did become active.
    for (ESBeaconRegion *region in self.regions) {
        if (region.isMonitoring) {
            [_locationManager requestStateForRegion:region];
        }
    }
}

これで、フォアグラウンドになったタイミングでリージョン監視の状態をアップデートしますので、リージョンの外に出てしまっているにも関わらず、無限にレンジングを継続することがなくなります。

デバイス設定変更時のリージョン監視設定失敗対策

Bluetooth設定やFlight modeの変更を頻繁に行うと、locationManager:monitoringDidFailForRegion:withError:が発生するケースがあります。この場合も1秒ほどのdelayを入れて、再トライを行っています。

- (void)locationManager:(CLLocationManager *)manager monitoringDidFailForRegion:(CLRegion *)region withError:(NSError *)error
{
    NSLog(@"monitoringDidFailForRegion:%@(%@)", region.identifier, error);

    if ([region isKindOfClass:[CLBeaconRegion class]]) {
        ESBeaconRegion *esRegion = [self lookupRegion:(CLBeaconRegion *)region];
        if (! esRegion)
            return;

        [self stopMonitoringRegion:esRegion];

        if (esRegion.failCount < ESBeaconRegionFailCountMax) {
            esRegion.failCount++;
            [NSTimer scheduledTimerWithTimeInterval:1.0f target:self selector:@selector(startMonitoringRegionTry:) userInfo:esRegion repeats:NO];
        }
    }
}

無限に再トライしないようにESBeaconRegionに再トライのカウントfailCountを設定しています。