2014年8月25日月曜日

Silent Push / Background Fetch 時の fetchCompletionHandler に渡す引数ごとの挙動の違いを調べてみた

2014/09/10追記: 追加調査によって判明した事項を追記しています。


iOS 7から追加されたPush通知によるバックグラウンド処理機能 (Silent Push) および定期的なバックグラウンドフェッチ機能 (Background Fetch) ですが、これらの機能はバックグラウンド処理が完了したタイミングでいずれもfetchCompletionHandlerにUIBackgroundFetchResult型の値を渡すような設計になっています。

ちょっと調べれば、大体どこの解説サイトにも以下のように説明があります。


  • UIBackgroundFetchResultNewData
    • 新しいコンテンツの取得に成功し、新しいデータが存在した場合
  • UIBackgroundFetchResultNoData
    • 新しいコンテンツの取得には成功したが、新しいデータが用意されていなかった場合
  • UIBackgroundFetchResultFailed
    • 通信エラーなどの理由でコンテンツの取得そのものに失敗した場合


問題は、これらの値をfetchCompletionHandlerに渡したら「何が起きるか」を解説しているサイトや文献がほとんど存在しません。そこでこのたび独自に調査してみました。

UIBackgroundFetchResultの値が影響する項目

いろいろ調べてみた結果、以下の参考文献によりUIBackgroundFetchResultの値は2つの項目に影響することがわかりました。
http://www.objc.io/issue-5/multitasking.html
https://codeiq.jp/magazine/2013/12/3022/

ひとつは、以降のSilent PushおよびBackground Fetchの挙動に対する影響です。OSが返された値を元に、アプリが使用したプロセス時間・電力消費などを判断し、以降のSilent PushやBackground Fetchの頻度に反映させるというものです。ただしどのような判断が行われどのように頻度に対して影響するかは完全にブラックボックスとなっており文献がありません。

もう一つはApp Switcher(ホームボタンをダブルタップした際に表示されるアプリ切り替え画面)のサムネイル画像、スナップショットを更新するために使用しているようです。値が渡されたタイミングで必要に応じてアプリのスナップショットを再描画するために、なんとアプリがバックグラウンドにいるにも関わらずその場でUIの再描画が行われます。この際通常のフォアグラウンドにアプリが存在する場合とまったく同様に、UIViewやUIViewControllerのメソッドが呼ばれます。例えばviewWillAppear, viewDidAppear,  didMoveToSuperViewなどが呼び出されます。

従いましてUIBackgroundFetchResultを渡す際には必ずメインスレッドで行う必要があります。NSURLSessionのdelegateを受け取ったスレッドから直接値を渡すとバックグラウンドでひっそりクラッシュします。ひっそりクラッシュする程度ならまだしもですが、これらのUI処理の中にバックグラウンドで呼び出されることを考慮していない処理が入っていると数々の問題を引き起こします。例えばviewDidAppearのタイミングでユーザさんがその画面を見たものと判断してデータやフラグを更新するですとか、Google Analyticsに統計情報を送っているですとか、そういうことをやっていると一気に大問題になります。

長くなりましたが、以上を踏まえまして、各値ごとにどのような影響があるかを実際に実機でテストして調べました。

UIBackgroundFetchResultNewDataを渡した時

スナップショットの更新が発生します。
Silent Pushの受取可能頻度に対して影響はないようです。一日4回程度、3〜6時間程度の間隔が空いていれば、全く問題なく受取可能です。ただし10分〜15分間隔で受取を行うとレートリミットがかかりはじめ、1〜2日以内に受信不可能になります。一度受信不能になったデバイスが再度受信可能になるかどうかはわかりませんが、数日間程度置いていても効果がなかったので、再度受信ためには最悪デバイスの完全なワイプとリセットが必要になるかもしれません。
Background Fetchの発生頻度に対しても影響はないようです。

UIBackgroundFetchResultNoDataを渡した時


スナップショットの更新が発生します。
三ヶ月程度様子を見ているところ、Silent Pushの受取り頻度に対してNewDataの場合とほぼ同様に影響はなさそうです。したがってNewDataを返す場合と何が違うのか現在のところわかっていません。

以下、旧記述です。
スナップショットの更新は発生しません。スナップショットの更新に伴って大問題が発生する場合には一番簡単な解決策になります。
ただし毎回毎回UIBackgroundFetchResultNoDataばかりを返却していると、Silent Pushの受取可能頻度およびBackground Fetchの発生頻度に対してペナルティがかかる恐れがあります。何度試行してもデータが取れないので、システムが再試行頻度を下げるのではないかという推測ですが、実際に観測できたわけではありませんので、なにか情報が入り次第また詳しくお伝えします。

UIBackgroundFetchResultFailedを渡した時

スナップショットの更新は発生しません。
こちらもUIBackgroundFetchResultNoDataと同様、UIBackgroundFetchResultFailedばかりを返却していると、Silent Pushの受取可能頻度およびBackground Fetchの発生頻度に対してペナルティがかかる恐れがあります。何度試行してもデータが取れないので、システムが再試行頻度を下げるのではないかという推測ですが、実際に観測できたわけではありませんので、なにか情報が入り次第また詳しくお伝えします。


2014年8月12日火曜日

Apple Push Notification (APN) 使用時の delegate の挙動について、 iOS 7以降 / iOS 6以前の差をまとめた

iOS 7以降とiOS 6以前で、俗にいうリモートPush通知の受け取り方と受け取った際の挙動がまるで違っているので、最近リモートPush通知を実装した時につまづいた箇所をまとめてみました。

使用するdelegate methodの違い

iOS 7以降


  • いかなる種類のPush通知においてもapplication:didReceiveRemoteNotification:fetchCompletionHandler:を使用します。
  • application:didReceiveRemoteNotification:fetchCompletionHandler:とapplication:didReceiveRemoteNotification:が両方実装されている場合も、application:didReceiveRemoteNotification:fetchCompletionHandler:しか呼び出されません。application:didReceiveRemoteNotification:fetchCompletionHandler:のみ考えればOKです。
  • ただし、application:didReceiveRemoteNotification:が実装されており、application:didReceiveRemoteNotification:fetchCompletionHandler:が存在しない場合は、iOS 6以前同様の挙動になります。
  • 以下、長ったらしいのでapplication:didReceiveRemoteNotification:fetchCompletionHandler:を新メソッドと呼称します。

iOS 6以前


  • application:didReceiveRemoteNotification:を使用します。
  • 新メソッドが実装してあっても一切反応しません。
  • 以下、長ったらしいのでapplication:didReceiveRemoteNotification:を旧メソッドと呼称します。

挙動の違いまとめ

以下に表にしてまとめました。ここで、
iOS 7以降とは新メソッドを使用する場合、iOS 6以前とは旧メソッドを使用する場合のことを指します。


iOS 6以前iOS 7以降
初回起動application:didFinishLaunchingWithOptions:に
UIApplicationLaunchOptionsRemoteNotificationKeyが付いて呼び出される。
旧メソッドは呼び出されない。
application:didFinishLaunchingWithOptions:に
UIApplicationLaunchOptionsRemoteNotificationKeyが付いて呼び出される。
その後、UIApplicationStateActiveの状態で、新メソッドも呼び出される。
handlerについては、content-available指定が1の場合のみ付いてくる(未確認)。
起動中UIApplicationStateActiveの状態で、旧メソッドが呼び出される。UIApplicationStateActiveの状態で、新メソッドが呼び出される。
handlerについては、content-available指定が1の場合のみ付いてくる(未確認)。
未起動UIApplicationStateInactiveの状態で、旧メソッドが呼び出される。UIApplicationStateInactiveの状態で、新メソッドが呼び出される。
handlerについては、content-available指定が1の場合のみ付いてくる(未確認)。
データ取得不可能。UIApplicationStateBackgroundの状態で、新メソッドが呼び出される。
handlerは必ず付いてくる。
この通知がアラートや通知センターに表示された場合、ユーザーがそれらとインタラクションしたらさらにもう一回UIApplicationStateInactiveの状態で新メソッドが呼び出される。

バックグラウンドでの通知受け取りとデータ取得

iOS 7から、Push通知のペイロードのapsオブジェクトにcontent-availableというプロパティが新たに追加されました。content-availableプロパティの値を数字の1に設定すると、ユーザーのインタラクションを介さずにバックグラウンドでアプリケーションが起き上がることができ、サーバから最新のデータの取得を行ったりする事が可能です。詳しくは「Push通知 バックグラウンド iOS7 content-available」とかその辺の単語を適当に組み合わせてググってください。山ほど解説しているページがあります。

サイレント通知が届かないときの処方箋

さて、上記バックグラウンドでの通知受け取りとデータ取得について、余り解説が見当たらなかった点についてまとめました。

iOS 7以降のPush通知は、apsオブジェクトのalertプロパティの有無とcontent-availableプロパティの有無によって挙動が変わります。具体的には以下の表のようになります。

content-availableがあるcontent-availableがない
alertがある通常通知
同時にバックグラウンドデータ取得も可能
通常通知
alertがないサイレント通知
バックグラウンドデータ取得のみ可能
送信できない

content-availableがない場合はiOS 6以前のPush通知と全く同じなので説明を割愛します。次にcontent-availableとalert両方が指定されている場合ですが、これはiOS 6以前の通常通知と基本は同じで、異なる点は通知を受け取った瞬間にバックグラウンドでアプリが立ち上がってデータの取得ができるという点だけです。したがいましてちょっと上記に記載しましたが、通知を受け取った瞬間と、ユーザーがインタラクションした瞬間で2回新メソッドが呼び出されるので、必ず新メソッドの中でUIApplicationStateを見て処理を分岐するようにしてください。

問題になってくるのがcontent-availableだけが指定されている通知、サイレント通知についてです。ほかのサイトでもよく紹介されていますが、この通知を使ってバックグラウンドでアプリを起動させて最新のデータをダウンロードしたりすることができます。以下にサイレント通知のペイロードの例を示します。

上が通常のサイレント通知ですが、下のようにサイレント通知と同時にバッジの数を更新することもできます。

さてこのサイレント通知ですが、とにかく「届かない!」というトラブルを耳にします。実際私が試した際にもなかなか届かないで弱りました。そこで調査したところ、サイレント通知のペイロードを工夫することできちんと通知が到達するようになりました。以下に到達率を改善したサイレント通知のペイロードの例を示します。

ご覧のように、空のsound要素を追加することで到達率が劇的に向上します。また2つ目の例のように、priorityというプロパティを10に設定するとこれまた到達率が劇的に向上します。どうやらこのpriorityプロパティはalertプロパティかsoundプロパティが設定されている場合はデフォルト10に、設定されていないサイレント通知の場合はデフォルト5に設定されているようで、それが原因で到達しない事が多いということがわかりました。
参考記事: http://stackoverflow.com/questions/19239737/silent-push-notification-in-ios-7-does-not-work

それから、Xcode上からプロジェクトのCapabilitiesの設定をするとき、Background fetchとRemote notificationsの両方の指定がおそらく必須と思われます。中にはRemote notificationsだけ設定しておけば良いと解説しているところもありますが、どうやらRemote notificationsは通知を受け取る箇所までだけで、肝心のバックグラウンドでのデータの取得はBackground fetchがないと実行できないのではないかと思われます。

UINavigationController.interactivePopGestureRecognizer の挙動を魔改造して変えてみる

注意: 今回の記事は久しぶりの誰得記事です。こちらで紹介されている手法を用いるにはプライベートAPIを操作する必要があるため、おそらく審査に通りません。じこせきにんでお願いします。

iOS 7からUINavigationControllerにinteractivePopGestureRecognizerというプロパティが新たに追加されました。これは「ナビゲーションコントローラの左端から右にスワイプすると前の画面に戻る」機能を司るgesture recognizerで、同じくiOS 7から追加されたUIScreenEdgePanGestureRecognizer(画面端からのみ反応するPan Gesture Recognizer)とUIViewControllerInteractiveTransitioning(ViewController間の画面遷移時に、ユーザーのインタラクションを追加する。例えば画面半分までスワイプしたら50%画面遷移するとか)の仕組みで実装されています。

ここで、例えばこの「ナビゲーションコントローラの左端から右にスワイプすると前の画面に戻る」機能について、見た目はそのままがいいんだけど左端からしか反応しないのが気に入らないから画面の中央でも反応するようにしたいという要望があったとします。自前で実装してもよいのですが、やはりApple純正の動きが良いですよね。

そこで以下のページで紹介されている黒魔術を使ってみたいと思います。
http://blommegard.se/blog/2014/01/31/a-take-on-custom-transitions-with-uinavigationcontroller/
こちらのページにUINavigationController内部でどのようにinteractivePopGestureRecognizerが実装されているかという解説があります。

  • 先述の通り、UIScreenEdgePanGestureRecognizerのインスタンスが使用されている
  • targetは_UINavigationInteractiveTransition型のオブジェクト、調査の結果_cachedInteractionControllerという隠しプロパティがそれに相当することがわかった
  • actionは@selector(handleNavigationTransition:)

ここまでわかったので、あとはtarget/actionを自分で生成したUIGestureRecognizerにセットしてやれば、標準の動きをジャックすることができるというわけです。今回はUIPanGestureRecognizerを使用しました。



このコードを実行してみると確かに画面中央からでもいつものスワイプジェスチャで戻る挙動が可能になっています。やりましたね\(^o^)/