読者です 読者をやめる 読者になる 読者になる

Intelligent Technology's Technical Blog

株式会社インテリジェントテクノロジーの技術情報ブログです。

iOS7で強化されたRemote Notifications

こんにちは、間藤です。

iOS7が正式リリースされて1ヶ月が経ちましたが、今回はiOS7で強化されたRemote Notifications(Push通知)の機能について取り上げてみます。また、Push通知を切っ掛けにして、コンテンツをダウンロードするサンプルも作成してみましたので、そのサンプルの挙動についても見ていきたいと思います。(確認した挙動については、私自身納得の行かないこともあったので、そのあたりについても触れます。)

どんな機能強化なのか?

以下、iOS6までのPush通知の仕組みを表したものです。

f:id:IntelligentTechnology:20131024130700p:plain

iOS6までは、Push通知を受けたアプリがすぐに処理を開始できるわけではなく、ユーザがアプリをフォアグランドにする必要がありました。

それがiOS7になると以下のようになります。

f:id:IntelligentTechnology:20131024130754p:plain

アプリがバックグランドであっても、Push通知を受けるとすぐに処理を開始することができるようになっています。

また、Silent Remote Notificationsというものもあります。

f:id:IntelligentTechnology:20131024130845p:plain

プロバイダから送信するペイロードに"content-available"のみを含めるようにすると、Silentになります。通知センターには通知されないので、ユーザはPush通知があったことを知ることなく、アプリが「こっそり」と処理を行うことになります。

前準備

新しいPush通知の機能を動作確認しながら見ていくためには、Push通知用の証明書を作成したり、プロバイダを用意したりと、いろいろと準備が必要です。この部分については、素晴らしい解説ページを見つけたので、こちらを参考にしていただければと思います。英語ですが、非常に丁寧に書かれていて、とても分かりやすいと思います。また、iOS6向けに書かれた解説ですが、iOS7でも手順はそのまま踏襲できます。この記事の前半は、Push通知に関する概要が書かれているので、単に準備を行うためだけに参考にしたいということであれば、前半を読み飛ばして、「Provisioning Profiles and Certificates, Oh My!」以降から読むとよいでしょう。
なお、プロバイダは、この解説ページからダウンロードしたPHPプログラムを利用しました。「Sending Your First Push Notification」の項にこのサンプルプログラムのリンクがあります。また、解説ページの最後(「Troubleshooting」の項)にも書かれていますが、ご利用の環境のファイアウォールがPush通知に必要なポートをブロックしていると、Push通知を行うことができませんのでご注意ください。

プロバイダ側の実装

前述の通り、解説ページからPHPプログラムをダウンロードして利用しました。サンプルとして動作確認する程度なら、このサンプルをほぼそのまま使えます。但し、アプリがバックグランドでPush通知を受け取るようにするために、ペイロードに"content-available"を含めるよう、プログラムを修正する必要があります。

$body['aps'] = array(
	'content-available' => 1,     ← この行を追加
	'alert' => $message,
	'sound' => 'default'
	);

また、Silent Remote Notificationsを試すのであれば、alertやsoundの行を削除します。

$body['aps'] = array(
	'content-available' => 1
	);

サンプルアプリの実装

今回作成したサンプルは、こちらからダウンロードできます。但し、Push通知を受けたときにダウンロードする対象のURLを指定していませんので、実際に動作させる場合には、プログラムを修正していただく必要があります。

//!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
// Please note that you should specify any URL
//!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
#define TARGET_URL @"http://..."  ← 適当なURLを指定してください

簡単にポイントを説明します。
バックグランドでPush通知を受け取るために、Info.plistに設定を行います。Xcodeでは、以下のように設定を行います。これは、iOS6以前にはなかった設定です。

f:id:IntelligentTechnology:20131024131252p:plain

Push通知に関する実装は、UIApplicationDelegateのメソッドで対応していきます。
application:didFinishLaunchingWithOptions:メソッドでは、UIApplicationクラスのregisterForRemoteNotificationTypes:メソッドを呼び出して、APNSサーバに対してデバイストークンを要求します。このあたりの段取りはiOS6以前と変わっていません。デバイストークンは、application:didRegisterForRemoteNotificationsWithDeviceToken:メソッドで通知されますので、これをプロバイダ側に引き渡します。今回は、これを手動でPHPプログラムにコピペしています。受け取ったデバイストークンをコンソールに出力して、それをプロバイダ側のPHPプログラムに反映します。

- (BOOL)application:(UIApplication *)application
 didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
・・・
    [[UIApplication sharedApplication] registerForRemoteNotificationTypes:
     (UIRemoteNotificationTypeBadge | UIRemoteNotificationTypeSound | UIRemoteNotificationTypeAlert)];
・・・

- (void)application:(UIApplication*)application
 didRegisterForRemoteNotificationsWithDeviceToken:(NSData*)deviceToken
{
    FLog(@"My token is: %@", deviceToken);
・・・


Push通知は、application:didReceiveRemoteNotification:fetchCompletionHandler:メソッドで処理するようにします。これがiOS7からUIApplicationDelegateに追加されたデリゲートメソッドです。
アプリがバックグランドであっても、Push通知を受け取るとこのメソッドが呼び出されて、処理を開始することができます。サンプルでは、ネットワーク上のデータをダウンロードする処理を行っています。

- (void)application:(UIApplication *)application
 didReceiveRemoteNotification:(NSDictionary *)userInfo
 fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler
{
    FLog(@"User data is: %@", userInfo);

#if 0
    [[DownloadManager defaultManager] downloadWithCompletionHandler:^(UIBackgroundFetchResult result) {
#else
    [[DownloadManager defaultManager] downloadCustomWithCompletionHandler:^(UIBackgroundFetchResult result) {
#endif
        switch (result) {
            case UIBackgroundFetchResultNewData:
                FLog(@"UIBackgroundFetchResultNewData");
                break;
            case UIBackgroundFetchResultNoData:
                FLog(@"UIBackgroundFetchResultNoData");
                break;
            case UIBackgroundFetchResultFailed:
                FLog(@"UIBackgroundFetchResultFailed");
                break;
        }
        completionHandler(result);
    }];
}

DownloadManagerクラスは、自前のクラスです。このクラスの中でダウンロード処理を行っています。ダウンロード処理は、2種類用意しました。(downloadWithCompletionHandler:メソッドと、downloadCustomWithCompletionHandler:メソッドの2つです)
ダウンロードを行うという意味では、両者は同じ動きをします。ダウンロードは、iOS7から追加されたNSURLSessionクラスで行っていますが、実装方法として大きく2パターンあるので、それぞれのパターンを用意しました。

以下、downloadWithCompletionHandler:メソッドからの抜粋です。

- (void)downloadWithCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler {
・・・
    session_ = [NSURLSession sessionWithConfiguration:sessionConfiguration delegate:nil delegateQueue:nil];
    
    NSString *urlString = @"(ダウンロード対象のURLを指定してください)";   ← ここは適当なURLに変更してください
    NSURL *url = [NSURL URLWithString:urlString];
    
    NSURLSessionDownloadTask *downloadTask = [session_ downloadTaskWithURL:url
                                completionHandler:^(NSURL *targetPath, NSURLResponse *response, NSError *error)
                     {
                         
                         [UIApplication sharedApplication].networkActivityIndicatorVisible = NO;
                         UIBackgroundFetchResult result = UIBackgroundFetchResultFailed;    // default

・・・(completionHandler内でダウンロード結果を受け取る)

このパターンでは、ダウンロード結果をcompletionHandlerに指定したハンドラで受け取ります。デリゲートメソッドを用意する必要がないので、手軽に実装できるようになっています。

以下は、downloadCustomWithCompletionHandler:メソッドからの抜粋です。

- (void)downloadCustomWithCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler {
・・・
    [session_ invalidateAndCancel];
    session_ = [NSURLSession sessionWithConfiguration:sessionConfiguration delegate:self delegateQueue:nil];
・・・
}

・・・
- (void)URLSession:(NSURLSession *)session
      downloadTask:(NSURLSessionDownloadTask *)downloadTask
 didFinishDownloadingToURL:(NSURL *)location
{
    FLog(@"didFinishDownloadingToURL");
    
    UIBackgroundFetchResult result = UIBackgroundFetchResultFailed;    // default

    if([location isFileURL]){
        NSString *fromPath = [location path];
        
        NSString *docDir = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) objectAtIndex:0];
        NSString *fileName = [NSString stringWithFormat:@"download_%f.pdf", [[NSDate date] timeIntervalSince1970]];
        NSString *toPath = [docDir stringByAppendingPathComponent:fileName];
        
        [[NSFileManager defaultManager] copyItemAtPath:fromPath toPath:toPath error:nil];
        result = UIBackgroundFetchResultNewData;
    }
・・・

こちらのパターンでは、ダウンロード結果をデリゲートメソッドで受け取ります。これ以外にもデリゲートメソッドをたくさん用意しなければならないので、実装は面倒になりますが、きめ細かい制御が行えます。

実装の説明はこれくらいにして、実際の挙動を見ていきたいと思います。

サンプルアプリの挙動

以下の環境で動作確認しました。

iOS 7.0.2
Xcode 5.0.1

サンプルアプリは、実機にインストールし、バックグランドにしておきます。
プロバイダのPHPプラグラムを実行してAPNSサーバに通知を送ると、そのPush通知が実機に送られ、application:didReceiveRemoteNotification:fetchCompletionHandler:メソッドもコールされます。

f:id:IntelligentTechnology:20131024133650j:plain:h300

通知が画面に表示されると同時に、バックグランドでダウンロード処理が実行されます。前述の通り、ダウンロード処理のパターンは2つ用意していますが、どちらのパターンでも同様にダウンロード処理は機能します。
但し、ホームボタンを2回押すと表示されるアプリケーションのプレビュー画面でアプリを終了しまうと、Push通知を受け取ったときにアプリが起動されませんでした。

f:id:IntelligentTechnology:20131024141625p:plain:h300

アプリのプロセスが終了している状態(例えば、Xcodeからアプリ起動/停止したような場合)では、アプリのプレビュー画面は残ったままとなります。そして、この状態でPush通知を受け取った場合は、アプリが起動されてダウンロード処理が実行されます。このあたりは不可解な挙動です。この件、stackoverflowでも取り上げられています。
この中で、

Also keep in mind that if you kill your app from the app switcher (i.e. swiping up to kill the app) then the OS will never relaunch the app regardless of push notification or background fetch.

と書かれており、これはApple社員からの情報だとあります。もしかすると、これはバグとして扱われて、将来修正される可能性もありそうです。

なお、通知センターからユーザが通知をタップした場合も、application:didReceiveRemoteNotification:fetchCompletionHandler:メソッドはコールされます。このサンプルでは、このことを考慮していないので、1回の通知に対して何回でもダウンロード処理を行ってしまう可能性があります。

NSURLSessionについて

今回ダウンロード処理では、iOS7から追加されたNSURLSessionクラスを利用しました。クラスリファレンスのOverviewでは以下のように説明されています。

The NSURLSession class and related classes provide an API for downloading content via HTTP. This API provides a rich set of delegate methods for supporting authentication and gives your app the ability to perform background downloads when your app is not running or, in iOS, while your app is suspended.

つまり、バックグランドでダウンロードする場合は、このクラスを利用しなければならないようです。ところで、今回ダウンロード処理は2パターンを用意しましたが、細かい制御を行えるかどうか以外にも異なる点があります。

サンプルアプリをフォアグランドにした状態で、Push通知を受け取った場合も、直ちにダウンロード処理は開始されます。(通知は画面に表示されません。)
ここで、ダウンロード処理が完了する前に、アプリをバックグランドにしてしまうと、1つ目のパターンの実装ではダウンロード処理がストップしてしまい、最終的にはタイムアウトしてしまいます。一方、2つ目のパターンでは、バックグランドになってもダウンロード処理は継続されます。但し、ダウンロード処理は別プロセスとして継続されます。
この違いは、NSURLSessionConfigurationの生成方法によるものです。2つ目のパターンでは、以下のように生成しています。

NSURLSessionConfiguration *sessionConfiguration = [NSURLSessionConfiguration backgroundSessionConfiguration:@"sessionId"];
session_ = [NSURLSession sessionWithConfiguration:sessionConfiguration delegate:self delegateQueue:nil];

backgroundSessionConfiguration:で生成したNSURLSessionConfigurationのインスタンスを使って、NSURLSessionのインスタンスを生成した場合にこのような挙動(別プロセスで処理が継続される)になるようです。クラスリファレンスからbackgroundSessionConfiguration:の説明を抜粋すると、以下のように書かれています。

Sessions created with configuration objects returned by this method are called background sessions. These sessions differ from other sessions in the following ways:

  • Upload and download tasks in background sessions are performed by an external daemon instead of by the app itself. As a result, the transfers continue in the background even if the app is suspended, exits, or crashes.
  • In iOS, if your app creates a background session, the app is automatically relaunched in the background whenever tasks complete. In OS X, the results of background tasks are available when your app restarts.
  • Background transfers have a few additional constraints, such as the need to provide a delegate.

そして、最後に書かれているように、backgroundSessionConfiguration:で生成したNSURLSessionConfigurationから生成したNSURLSessionでは、デリゲートメソッドが必要になるということです。つまり、1つ目のパターンでは、このConfigurationは利用できないということになります。

Push通知を切っ掛けにダウンロード処理を行う場合、Push通知を受け取った時点でアプリがフォアグランドかどうかはわからないので、基本的にはbackgroundSessionConfiguration:を利用することになるでしょう。

まとめ

いろいろなシチュエーションでどのように挙動するかは、もう少し確認してみる必要がありますが、Push通知をきっかけにバックグランドで処理が行えるというのは、アプリのUX向上に大きく寄与できる可能性があるのではないかと感じています。