Posted at

iOSのプッシュ通知と位置情報の利用許諾がかぶるのを抑止する

More than 1 year has passed since last update.


やりたいこと



  • ニッチな自作iOSアプリ(タブバーコントローラーで複数のビューを持つ)にFirebaseによるプッシュ通知を実装した

  • が、実は同じアプリ内ではCore Locationも使っていて、どちらも初回起動時に確認アラート表示が表示される

  • 何も考えずに実装してしまうと、アラート表示がかぶって妙なことになる場合がある

  • 機能には影響はないようだが、できればどうにかしたい

  • なお、位置情報に関する通知は、起動直後のみ「地図の中心を現在位置に移動する」ために使う。いったん位置情報の取得が有効になれば、あとはMapKitが勝手に現在位置を地図上にプロットしてくれる。すなわち、位置情報はプロセスの起動時に1回のみ取得すればよい

  • 日本どローカルなアプリなので、各種表示の国際化はとりあえず考えない


レギュレーション


  • Xcode8.2

  • iOS 8対応(それ以降の新APIはとりあえず捨て置き)

  • Objective-C

  • Rxってなんですか?


実装の要点


FCMとCore Location、それぞれの確認のタイミング

まずFCMは再掲になりますがこちら。registerUserNotificationSettingsの呼び出しに対し、権限がない場合はアラートが表示され、ユーザーに確認を求める動作になります。ユーザーが許可した場合はdidRegisterUserNotificationSettingsが呼び出されます。


AppDelegate.m

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions

{
// other initializing...

[FIRApp configure];

UIUserNotificationType types = UIUserNotificationTypeSound | UIUserNotificationTypeAlert;
UIUserNotificationSettings *mySettings = [UIUserNotificationSettings settingsForTypes:types categories:nil];
[application registerUserNotificationSettings:mySettings];

// other initializing...

return YES;
}

- (void)application:(UIApplication *)application didRegisterUserNotificationSettings:(UIUserNotificationSettings *)notificationSettings {
[application registerForRemoteNotifications];
[[FIRMessaging messaging] subscribeToTopic:@"/topics/TOPICNAME"];
}


一方、Core Locationで「アプリ起動時のみ利用」としたい場合、以下のコードとなります。AppDelegateCLLocationManagerDelegateを使えるように宣言しておかないといけません。


AppDelegate.h

@interface AppDelegate : UIResponder<UIApplicationDelegate, CLLocationManagerDelegate>

/// other declarations

@end



AppDelegate.m

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions

{
// other initializing...

self.locationManager = [[CLLocationManager alloc] init];
self.locationManager.delegate = self;
[self.locationManager requestWhenInUseAuthorization];

// other initializing...

return YES;
}

- (void)locationManager:(CLLocationManager *)manager didChangeAuthorizationStatus:(CLAuthorizationStatus)status {
switch (status) {
case kCLAuthorizationStatusAuthorizedWhenInUse:
[self.locationManager startUpdatingLocation];
break;
case kCLAuthorizationStatusRestricted:
case kCLAuthorizationStatusDenied:
// アラート出すなら出す
break;
default:
break;
}
}



Core Locationの一見不可解なドキュメント?

前章のCore Locationの初期化ですが、Appleは既に許可済のときにrequestWhenInUseAuthorizationを呼び出してもdidChangeAuthorizationStatusは呼ばれないぜい、と書いています。しかし実際には、Xcode 8.2でビルドターゲットを10.2にしたとしても、本稿の前提である8.0にしたとしても、10.2のシミュレーター・実機の双方で、上記コードの実行時のdidChangeAuthorizationStatusに渡されるstatusの値は以下のとおりとなっています。

パターン
1回目呼び出され
2回目呼び出され

初回起動時に許可
NotDetermined
WhenInUse

初回起動時に不許可
NotDetermined
Deined

許可後の再起動
WhenInUse
-

ネット上でよく見かけるコードに、didChangeAuthorizationStatusの中でkCLAuthorizationStatusNotDeterminedだった場合にそこでrequestWhenInUseAuthorizationを呼び出す、というものがありますが、そのように実装すると、

パターン
1回目呼び出され
2回目呼び出され

初回起動時に許可
NotDetermined
WhenInUse

初回起動時に不許可
NotDetermined
Deined

許可後の再起動
WhenInUse
WhenInUse

となり、許可後の再起動でdidChangeAuthorizationStatuskCLAuthorizationStatusWhenInUseで2回呼ばれてしまいます。一方で、そのようなコードにした場合でも、ロケーションマネージャーのデリゲートを設定したすぐ後、許可状態が変わっていなくてもdidChangeAuthorizationStatusが必ず呼び出されます

すなわち、didChangeAuthorizationStasusは、デリゲートを設定すると必ず1度は呼び出され、その後未確定状態でrequestWhenInUseAuthorizationを呼び出すともう1度呼び出される、という挙動のようです。

ここでもう一度、Appleのドキュメントを読み返すと、なるほど、requestWhenInUseAuthorizationの呼び出しそのもので、許可済みの場合にdidChangeAuthorizationStatusが呼ばれていないのは確かです。なのでドキュメントは誤りではないのですが、めちゃくちゃ不明瞭だと私には思えてしまいますよ…。


位置情報のあれやこれやをデリゲートでマップのビューコントローラーに渡す

さて、FCMは、基本的にプッシュ通知がアプリケーションに送られてくるものであり、その処理もアプリケーションレベルで行うものなので、AppDelegateに実装すれば済みます。

しかし位置情報ではそうも行きません。今回の場合は、位置情報が欲しいのはあくまでもマップ上に現在位置をプロットしたいからであり、それはマップ用のビューコントローラーのお仕事だからです(なので、以前はこのあたりの処理は、ぶっちゃけ、マップ用ビューコントローラーのviewDidLoad内でやってました)。

ただ、そうするとまさに、「FCMのアラートとCore Locationのアラートがバッティングする問題」の調整ができません。

なので、やむなく、すべての処理をAppDelegateで逐次行うこととし、そこで受けた結果をデリゲートで通知するという、古色蒼然としたやり口で行くことにします。

このデリゲートに関して考慮すべきことは2点。

まず、通知すべきネタは2つあります。言うまでもなく、位置情報が利用可能かどうかと、実際に取得できた現在位置の2つです。そして前者はBOOLですが、後者はCLLocationCoordinate2Dの値でして、これが普通にCの構造体です。なので、「位置情報がnilならば通知不可」というのを素朴に短く実装するのが難しく、よってこれらは別のプロパティーとして管理することになります。

これらを、以下のように定めてみました。


AppDelegate.h

@protocol LocationUpdatedDelegate

- (void)beginLocating:(BOOL)enabled;
- (void)locationWasUpdated:(CLLocationCoordinate2D)location;

@end

@interface AppDelegate : UIResponder<UIApplicationDelegate, CLLocationManagerDelegate>

@property(nonatomic) id<LocationUpdatedDelegate> locationDelegate;

/// other declarations

@end


次に、このデリゲートはマップ用のビューコントローラークラスが使用することになりますが、自らをデリゲートとして設定するのは、通常viewDidLoadになります。しかし、これと、非同期に呼び出されるdidChangeAuthorizationStatusの順序について仮定を置くことは危険だと思われます。viewDidLoadの時点で、状態が確定するdidChangeAuthorizationStatus呼び出されが起こったか起こっていないかは不定だ、と考えた方が無難です。

というわけで、


  • デリゲートの設定の時点で、既に結果がわかっていればそれを即座に返す

  • わかっていなければ結果がわかってからそれを返せる

という処理にすることにします。もちろん前者はsetterで何とかすることになります。そしてこの場合、前に出た結果を保持しておかねばなりませんし、さらに、そもそも結果がわかっているかどうかも内部的に管理しなければなりません(涙)。


AppDelegate.m

@interface AppDelegate ()

@property(nonatomic,assign) BOOL firstLocatingDone;
@property(nonatomic,assign) BOOL locatingEnabled;
@property(nonatomic,assign) CLLocationCoordinate2D currentLocation;

@end

@implementation AppDelegate

@synthesize locationDelegate = _locationDelegate;

- (void)locationManager:(CLLocationManager *)manager didChangeAuthorizationStatus:(CLAuthorizationStatus)status {
switch (status) {
case kCLAuthorizationStatusAuthorizedWhenInUse:
// 位置情報有効を保存してデリゲートで通知、位置情報取得開始
self.locatingEnabled = YES;
[self.locationDelegate beginLocating:YES];
[self.locationManager startUpdatingLocation];
break;
case kCLAuthorizationStatusRestricted:
case kCLAuthorizationStatusDenied:
// 位置情報無効を保存してデリゲートで通知、位置情報処理は終了
self.firstLocatingDone = YES;
self.locatingEnabled = NO;
[self.locationDelegate beginLocating:NO];
break;
default:
break;
}
}

- (void)locationManager:(CLLocationManager *)manager didUpdateLocations:(NSArray *)locations {
CLLocation *location = [locations lastObject];
self.currentLocation = [location coordinate];
// 位置情報取得完了、デリゲートで通知し以後の取得を中止して位置情報処理は終了
self.firstLocatingDone = YES;
[self.locationManager stopUpdatingLocation];
[self.locationDelegate locationWasUpdated:self.currentLocation];
}

- (void)setLocationDelegate:(id<LocationUpdatedDelegate>)locationDelegate {
_locationDelegate = locationDelegate;
if (self.firstLocatingDone) {
// 位置情報取得が終わっていたら、その旨を設定されてきたデリゲートに通知
[locationDelegate beginLocating:self.locatingEnabled];
if (self.locatingEnabled) {
[locationDelegate locationWasUpdated:self.currentLocation];
}
}
}

- (id<LocationUpdatedDelegate>)getLocationDelegate {
return _locationDelegate;
}



マップ用ビューコントローラーでいろいろやる

マップ用ビューコントローラー、要はMapKitが提供するMapViewを持つコントローラー側では何をすべきでしょうか。

実は、いや当然のことですが、アプリに位置情報の利用が許可されているとMapView上では自動的に現在位置が青い丸で示されますし、地図表示の中心を現在位置にすることもかんたんにできます。また、アプリに位置情報の利用が許可されていない場合は、青い丸は表示されません。

なんだ、ではCLLocationいらないの? となりそうですが、いやいやいやいや、初回起動時のマネジメントがどうしても必要になります。初回起動時にはアラートでユーザーに位置情報利用の可否が尋ねられ、それをユーザーが許可した後で測地は開始されます。そして最初の測地の取得が終わったところで、ただちに、地図の中心を現在位置にしたいわけです。

なので結局は、位置情報として通知された段階で、すなわち上記のデリゲートのlocationWasUpdatedの中で、地図の中心を現在位置に設定する、という処理が、もっともユーザービリティーが高くなるし、コードもそれほど煩雑にはなりません。

なお、このデリゲート経由の通知、実はなくてもなんとかなります。MapView.userTrackingModeMKUserTrackingModeFollowにすれば、一度地図が現在位置中心に移動した後、自動的にMKUserTrackingModeNoneになるという記事のとおりにすれば事足ります。

しかし実際に[self.mapView setUserTrackingMode:MKUserTrackingModeFollow animated:YES]を呼び出してみると、なにやら現在位置に移動するだけでなく、勝手にズームをしてしまうことがあるようです。

この仕様はもちろん、普通の地図アプリの場合、現在位置を詳細に見せるわけですから、自然ともいえます。ただし今回の自作アプリの場合、それは欲しくなかったので、やっぱり自前で通知を受けて、それで自前で移動することにしています。

それと、ユーザー操作で地図の中心を現在位置にするボタンの有効/無効も、デリゲートで切り替えるべきですよね。


MapViewController.h

@interface MapViewController : UIViewController <MapViewDelegate,

UISearchBarDelegate,
LocationUpdatedDelegate
>


MapViewController.m

@interface MapViewController ()

@property(nonatomic,strong) MapView *mapView;
@property(nonatomic,strong) UIBarButtonItem *presentButton; // 現在位置に移動するボタン
@property(nonatomic,assign) BOOL locatingEnabled;

@end

@implementation MapViewConroller

- (void)beginLocating:(BOOL)enabled {
if (!enabled) {
//位置情報取得ができなかった場合の何かをする
}
}

- (void)locationWasUpdated:(CLLocationCoordinate2D)location {
self.locatingEnabled = YES;
self.mapView.showsUserLocation = YES;
[self setButtonsEnabled:YES];
[self setLocation:location]; //中心位置を設定する独自実装を呼ぶ
}

- (void)setButtonsEnabled:(BOOL)enabled {
self.presentButton.enabled = enabled && self.locatingEnabled;
//その他のボタンのenabledプロパティーにenabledの値をセット
}



メインテーマの「アラートのかぶりの防止」

って肝心なメインテーマを書いてませんでした。要は、2つのアラートが必ずシーケンシャルに表示されるようにすればよいわけです。なので、とりあえずプッシュ通知の可否を取得できてから、位置情報の確認が走るようにすればよいわけです(不許可時の処理を忘れずに。)。

ということで、didFinishLaunchingWithOptionsから位置情報の初期化をメソッドに分離した上で、こんな感じで。


AppDelegate.m

- (void)application:(UIApplication *)application didRegisterUserNotificationSettings:(UIUserNotificationSettings *)notificationSettings {

[application registerForRemoteNotifications];
[[FIRMessaging messaging] subscribeToTopic:@"/topics/TOPICNAME"];

[self tryRegisterLocationNotification:application];
}

- (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error {
[self tryRegisterLocationNotification:application];
}

- (void)tryRegisterLocationNotification:(UIApplication *)application {
self.locationManager = [[CLLocationManager alloc] init];
self.locationManager.delegate = self;
[self.locationManager requestWhenInUseAuthorization];
}



…ん? シミュレーターで動きがあやしい??

さて、これでうまくいきそうな気がするんですが、実際にこれをシミュレーターで実行すると、なぜか初回起動時の位置情報許可アラートが2度表示されてしまいます。

「全然問題が解決しとらんやんけ!」状態なのですが、デバッガーで追うと、requestWhenInUseAuthorizationを呼び出した直後、かつdidChangeAuthorizationStatusが呼び出される直前になぜかアラートが表示され、いったん消えます。そして2度めのdidChangeAuthorizationStatusが呼び出される前にまた表示されているようです。

実機では、1度目のdidChangeAuthorizationStatusで表示されたアラートはそのまま表示されつづけ、ちらつかずに普通に表示されています。

うーん、何かCore Locationの闇に触れてしまったんでしょうかあたしゃ…


結論


  • Core Locationはドキュメントも動きも怪しげ…

  • 早くこんなデリゲートやら構造体やらレガシー言語やらもやめて、SwiftとRxで光のオーロラを身にまといたい…