Firebaseプッシュ通知をObjective-Cで実装してみた

## やりたいこと

+ [ニッチな自作iOSアプリ](https://github.com/yonezawaizumi/iOritsubushi/tree/fcm)（タブバーコントローラーで複数のビューを持つ）にプッシュ通知を実装したい
+ [同機能のAndroidアプリも作ってる](http://qiita.com/yonezawaizumi/items/2cc74c120f12444f0981)ので、プッシュ通知のフレームワークとしてFirebase FCMを使いたい
+ プッシュ通知をバックグラウンド受信した場合、通知のタップでアプリ内の特定のビューを表示させたい
+ プッシュ通知をフォアグラウンドで受信した場合、アラートを表示し、ユーザーによる「詳細を見る」のタップでアプリ内の特定のビューに遷移させたい（キャンセルなら何もしない）
+ プッシュ通知をフォアグラウンドかつ非アクティヴな状態（コントロールセンター表示中など）で受信した場合、コントロールセンターが閉じられてアクティヴ化した直後にアラートを表示させたい
+ 日本どローカルなアプリなので、各種表示の国際化はとりあえず考えない

## 画面遷移はこんな感じで [^1]

![プッシュ通知受信時のUIと遷移](https://qiita-image-store.s3.amazonaws.com/0/98308/0f06a643-74cc-4fa8-a640-071d627aed07.png) 

## レギュレーション

+ Xcode8.2
+ iOS 8対応（それ以降の新APIはとりあえず捨て置き）
+ Objective-C
+ Rxってなんですか?

## 実装の要点

### FCMの最低限の初期化

最低限これが必要です。iOS 8でMethod Swizzlingありならこれで大丈夫です（バッジを使っていませんが）。

```Objective-C: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"];
}
```

### フォアグラウンド時に出すアラート

2か所から呼び出すことになるため、メソッドとして実装します。下の例では最後の方で「現在トップにあるビューコントローラーを取得する」黒魔術が唱えられています。
なお、引数で`userInfo`を渡せるようにします。プッシュ通知の内容を文字列としてべた書きしていますが、国際化するなら、[私が前職で書いたこれ](https://www.mate-en.com/techblog/google-cloud-messaging-2.html)が参考になるかもしれません。GCMの話ですがFCMでもほぼ同じです。

```Objective-C:AppDelegate.m
- (void)showPushNotificationAlertWithUserInfo:(NSDictionary *)userInfo {
    UIAlertController *alertController = [UIAlertController alertControllerWithTitle:userInfo[@"aps"][@"alert"][@"title"]
                                                                             message:userInfo[@"aps"][@"alert"][@"body"]
                                                                      preferredStyle:UIAlertControllerStyleAlert];
    [alertController addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"詳細を見る", nil)
                                                        style:UIAlertActionStyleDefault
                                                      handler:^(UIAlertAction * action) {
                                                               // 詳細を表示する
                                                               [self changeTab];
                                                           }]];
    [alertController addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"キャンセル", nil)
                                                        style:UIAlertActionStyleCancel
                                                      handler:^(UIAlertAction * action) {}]];

    UIViewController *topController = [UIApplication sharedApplication].keyWindow.rootViewController;
    while (topController.presentedViewController) {
        topController = topController.presentedViewController;
    }
    [topController presentViewController:alertController animated:YES completion:nil];
}
```

### ４つのパターンを区別できるようにする

プッシュ通知がアプリにやってきたら、

+ フォアグラウンドにいるようにユーザーに見えている場合はアラートを表示し、画面遷移の選択をしてもらう
+ フォアグラウンドにいないようにユーザーに見えている場合はいきなり画面遷移する

とする、のが今回の目的です。
が、内部的には、アプリの状態は以下の4つのいずれかになっています。

1. フォアグラウンドにある
2. フォアグラウンドにあるが、コントロールセンターや通知センターがオーバーラップ表示されている
3. プロセスがバックグラウンドに存在する
4. プロセスが起動されていない

そして、この４つのパターンそれぞれを判別し、それぞれに応じてアラート表示なり画面遷移なりを行わなければなりません。
いろいろ調べてみましたが、プッシュ通知を受信した際に、以下の差異がある模様です。

| # | `didReceiveRemoteNotification` | その中での<br>`application.applicationState` | `applicationWillEnterForeground` | `didFinishLaunchingWithOptions` |
|:-------:|:-----------------------------:|:-------:|:---:|:---:|
| 1. | 呼ばれる | `UIApplicationStateActive` | 呼ばれない | 呼ばれない |
| 2. | 呼ばれる | `UIApplicationStateInactive` | 呼ばれない | 呼ばれない |
| 3. | 呼ばれる | `UIApplicationStateInactive` | 呼ばれる | 呼ばれない |
| 4. | 呼ばれない? | - | 呼ばれる | 呼ばれる |

まず4.の場合、そもそも`didReceiveRemoteNotification`が呼び出されないことがあるようです。ただしこの場合は、アプリ起動の`didFinishLaunchingWithOptions`が呼ばれ、そこからuserInfoは取得できます。

それ以外の1.-3.はすべて`didReceiveRemoteNotification`で通知を受けられます。そしてその中で`application.applicationState`の値を見ると、1.の場合はActiveになっており、これで区別できます。

問題は2.と3.です。これらについては、通知関連で取得できる状態はInactiveで区別ができません。
そこで、苦肉の策ですが、バックグラウンドでの受信かどうかを「直前の`applicationWillEnterForeground`呼び出しの有無」でフラグ判定するというきわめてあたまわるい方法で乗り切ることにしました。

### 結局、`userInfo`も保持しておかねばならない

アラートを表示するのは1.および2.ですが、1.の場合は`didReceiveRemoteNotification`の中から直接表示すれば問題ありません。
一方、2.の場合は、その場でアラートを出すことは、非アクティヴなので不可能です。コントロールセンターが閉じられると`applicationDidBecomeActive`が呼び出されますので、そのタイミングでアラートを表示することになります。

また、3.と4.では、`didReceiveRemoteNotification`が呼び出されるかどうかという重大な違いがあります。そしていずれの場合でも、画面遷移をするのはアプリがフォアグラウンドに移行した後、すなわち`applicationDidBecomeActive`で処理したほうが無難かな、と考えました。

これらを勘案すると、結局、プッシュ通知の内容も、どこかに保持しておかねばならない、ということになります。

というわけで、変数を2つもかかえたコードが以下になります。

```Objective-C:AppDelegate.m
@interface AppDelegate ()

// バックグラウンドからの復帰時にYES
@property(nonatomic) BOOL fromBg;
// フォアグラウンド化した際に使うプッシュ通知内容
@property(nonatomic, strong) NSDictionary *initialUserInfo;

@end

@implementation AppDelegate

- (void)showPushNotificationAlertWithUserInfo:(NSDictionary *)userInfo {

    // other initializing

    NSDictionary *userInfo = [launchOptions objectForKey:UIApplicationLaunchOptionsRemoteNotificationKey];
    if (userInfo) {
        self.initialUserInfo = userInfo;
    }
    self.fromBg = YES;

    return YES;
}

- (void)applicationWillEnterForeground:(UIApplication *)application
{
    self.fromBg = YES;
}

- (void)applicationDidBecomeActive:(UIApplication *)application
{
    // フォアグラウンド化時にやるべきことをやる
    if (self.initialUserInfo) {
        if (self.fromBg) {
            // バックグラウンドからなのでいきなり詳細を表示する
            [self changeTab];
            self.fromBg = NO;
        } else {
            // コントロールセンターが閉じたのでアラートを表示する
            [self showPushNotificationAlertWithUserInfo:initialUserInfo];
        }
        self.initialUserInfo = nil;
    }
}

- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler {
    if (application.applicationState == UIApplicationStateActive) {
        // アクティヴなのでアラートを表示する
        [self showPushNotificationAlertWithUserInfo:userInfo];
    } else {
        // 非アクティヴなのでアクティヴ化した際に何かする
        self.initialUserInfo = userInfo;
    }
    completionHandler(UIBackgroundFetchResultNoData);
}
```

## タブバーコントローラー使用時にタブを切り替える

と言っても特に難しいことはありませんが、コードでタブバーに設定するビューコントローラーを追加している場合、`UITabBarController`の`selectedIndex`プロパティーは使えません。タブが６個以上の場合、右端のタブボタンは「その他」になります。またその「その他」を選んだ状態で、タブを入れ替える編集機能もあります。これらの場合に`selectedIndex`で対応するのは無理筋で、代わりに`selectedViewController`プロパティーを使うべきです。
そしてこのプロパティーに設定するビューコントローラーの値は…やっぱり保持かよ！！orz

```Objective-C:AppDelegate.m
@interface AppDelegate ()

// お知らせ表示用のナビゲーションコントローラー
@property(nonatomic, strong) UINavigationController *informationNavigationController;

@end

@implementation AppDelegate

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

    // other initializing...

    // addTabViewControllerWithClassは別途実装
    self.informationNavigationController = [self addTabViewControllerWithClass:[InformationViewController class] viewControllers:viewControllers customizedViewControllers:customizableViewControllers];

    // other initializing...

    return YES;
}

- (void)changeTab {
    self.tabBarController.selectedViewController = self.informationNavigationController;
}
```

## 結論

+ …早くこんな変数保持もレガシー言語もやめて、SwiftとRxで光のオーロラを身にまといたい…

## 脚注

[^1]: 画面に表示されている内容は架空のものです。東京メトロ訓練線は駅データベースには未来永劫登録されません。