今回はiOS7以降におけるバックグラウンドダウンロードの仕組みについて基礎とハマりどころなどを紹介します。
バックグラウンドダウンロードというのは、データのダウンロードが始まった後にユーザーが他のアプリを起動したり、端末をスリープさせたりしても、裏でダウンロードをおこない続けることです。
データのダウンロードをあまり意識せず、待つ時間を有効に利用できます。ユーザーにとっては非常に優しい機能ですので、是非試してみてください。
また、実際にバックグラウンドダウンロード機能を実装するにあたって、よくはまるところも共有したいと思います。
概要
今回はじめてiOSのダウンロード機能に触れる方もいるかもしれませんので、ここでまず簡単なおさらいをします(以下のコードはすべてObjective-Cベースですが、Swiftでも基本的に扱うAPIは同じです)。
バックグラウンドダウンロードはiOS7で追加されたNSURLSession
がサポートする機能です。ただし、backgroundSessionConfiguration
で作ったsessionの利用が前提です。
データはNSURLSessionDownloadTask
を通してダウンロードします。
資料
iOS Developer Library - Using NSURLSession
NSURLSession
に関するプログラミングガイド
簡単なサンプルや仕様の説明が書いてあります。まずこれを読んでください。
※そのページの左側のリンクからいろんなページにとんで、さらに資料を入手できます。
はまった時にここで解決策を探したり、開発チームに質問したりするのがおすすめです。
WWDC2014 - What's New in Foundation Networking
結構前の発表ですが、意外と役に立ちます。(後述)
※画面の左下の「Resource」ボタンを押して、PDFをダウンロードするのがおすすめです。
ゴール
バックグラウンドで約1GBのデータをダウンロードすることです。
さあ、やってみましょう。
コードを組む前に、まず考えてなければならないことがいくつかあります。
- ダウンロード対象の1GBのデータをどういうふうに置くか
- ダウンロードのフローをどうするか
- 例外が発生した時にどう対応するか
正直、自分は最初にプログラミングガイドしか読んでいなかったので、何となくいけそうな設計を出しました。
当初の環境
- iOS7.0
- xcode 6.2
設計案
- 1GB = 2MB/zip * 500zip
- ダウンロードのフローは
- ダウンロードするファイルの一覧をリストに格納
NSURLSessionDownloadTask
を二つずつ作って、前のダウンロードが終わった時に新たなNSURLSessionDownloadTask
を作るという循環でリストをどんどん消化
- 例外に関しては
- 1zipにはたかが2MBなので、たとえダウンロード失敗したり、ファイルが壊れてしまったりしても、それを捨ててもう一度ダウンロードすればよいという考え
上記の案に基づいて実装しましたが、見事にほぼ全部の地雷を踏みました。
まず、こちらのフローを説明します。
DownloadManager
を定義し、必要なdelegateを継承します。- (id)init
- 中でユニークなidで
NSURLSession
を生成 - ここで注意すべきなのは、
[NSURLSessionConfiguration backgroundSessionConfigurationWithIdentifier:]
を使用すること - また、一つのidでは一つのsessionしか作れない
- 中でユニークなidで
CreateDownloadTask:(NSString *)sUrl
- ここでは
NSURLSessionDownloadTask
を作成 - ターゲットとなるURLを設定
uuid
をファイル名としてtaskDescription
に設定- 最後の
[task resume]
を呼んで、ダウンロードを開始
- ここでは
- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didFinishDownloadingToURL:(NSURL *)location
- ダウンロードが完了した後に呼ばれる
- ここでパブリックな領域にダウンロードしたファイルを一度プライベートな領域にコピー(パブリックな領域はOSが管理し、いつデータが消されるか分からないし、悪用の改竄を防ぐ意味でもコピーが必要)
- ファイル名はタスクを作った時に設定した
taskDescription
から取得 - 新しい
NSURLSessionDownloadTask
を作成(CreateDownloadTask
を呼ぶ)
- (void)URLSessionDidFinishEventsForBackgroundURLSession:(NSURLSession *)session
- この関数は
- (void)application:(UIApplication *)application handleEventsForBackgroundURLSession:(NSString *)identifier completionHandler:(void (^)())completionHandler
から呼ばれる - アプリがバックグラウンドになった時のみ呼ばれる
- ダウンロードのコールバックを全部処理した後にすぐ
completionHandler
を呼ぶ必要がある
- この関数は
一見正しそうに見えますし、実際に実行してみると確かにダウンロードがおこなわれます。
でも何か変だな、と気付きました
何回もテストしてわかったこと: アプリがバックグラウンドに行った時に
- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didFinishDownloadingToURL:(NSURL *)location
- リジュームしたタスクがすべてダウンロード完了するまでこの関数は一度も呼ばれない
- ファイルコピーができなくなり、新しいタスクを生成できなくなる
- ★極端な例として、1GBのデータのうち0.99GBがダウンロードできて、最後にアプリが何かしらの原因でクラッシュしてしまった場合、ダウンロードできた全てのデータを失う可能性がある
- この関数の中で新しく作ったタスクは何故かダウンロードが非常に遅い
- 最初に遭遇した現象は、ダウンロードが終わってもこの関数が呼ばれないままずっとハングアップ状態になるというもの。これはOS側のバグかと考えてiOS8.2へアップデートしたところ、ずっと放置したら呼ばれることがわかった。
- ダウンロードのテストに利用したサーバ側のログを参照して測定した結果、10時間かけてパッケージ15個(30MB)しかダウンロードできていなかった
NSURLSessionConfiguration
の中身を掘り下げてdiscretionaryというパラメーターを発見(つまりバックグラウンドで開始したデータ転送は常にシステムがコントロールすることが分かった)- ※WiFi環境 + 電源ケーブルが接続された状態では若干改善できる
もう一度、設計案へ立ち戻ってまずいポイントを抽出します
- 1GB = 2MB/zip * 500zip
- ダウンロードのフローは
- ダウンロードするファイルの一覧をリストに格納
NSURLSessionDownloadTask
を二つずつ作って、前のダウンロードが終わった時に新たなNSURLSessionDownloadTask
を作るという循環でリストをどんどん消化 ❌
- 例外に関しては
- 1zipにはたかが2MBなので、たとえダウンロード失敗したり、ファイルが壊れてしまったりしても、それを捨ててもう一度ダウンロードすればよいという考え ❌
バックグラウンドでタスクを作るループではダウンロード速度の面で実用性が乏しい点と、ダウンロード済みデータを回収する仕組みを改めて考えざるを得ない場面になりました。さらに、アプリが途中でクラッシュした場合、次回は未ダウンロードのパッケージのみをダウンロードする必要があります。
新しい案
- 1GB = 2MB/zip * 500zip
- ダウンロードのフローは
- ダウンロードするファイルの一覧をリストに格納
- アプリはフォアグランドになった時に
NSURLSessionDownloadTask
を二つずつ作って、前のダウンロードが終わった時に新たなNSURLSessionDownloadTask
を作るという循環でリストをどんどん消化 - アプリがバックグラウンドに行く前に、待ち行列にあるすべてのURLからタスクを大量に作って、全部リジューム
- 例外に関しては
- ダウンロードの進捗をsqliteに書き込んで、すでにダウンロード済みのものはもう一度ダウンロードさせない
- 起動するたびに前回残ったタスクの状況を調べて、ダウンロード完了のものを全部回収し、途中までダウンロードしたものはキャンセル
変更点:
init()
の中でcheckPrevious
を呼ぶ-(void)onAppWillEnterBackground
- 残ったURLからタスクを作成し、リジューム
-(void)checkPrevious
- いま生きているタスクを調べて、完了していなければ、キャンセル
- アプリケーションのクラッシュ後に再び起動した場合、
- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didFinishDownloadingToURL:(NSURL *)location
が呼ばれるので、ここで未完成のタスクを削除する(必要に応じて処理を追加してください) - ※ホームボタンをダブルクリックし、アプリを意図的に終了した場合は途中データの回収をしない
パッケージ10個でテストした限りでは特に問題ありませんでした。
が、また問題が発生しました
パッケージ数を500個まで増やしてテストした場合、
-(void)onAppWillEnterBackground
の処理は制限時間内に終わらず、アプリがOSに落とされる。(Xcodeに繋いで実行している場合は警告が出力されるだけで、落とされない)- むりやりタスク生成処理を
backgroundTask
に回して落ちないようにしたら、今度はコールバックがおかしくなって失敗
その後に調べた結果、ここの返答にAppleのデベロッパーがこう書いています。「バックグラウンドセッションは相対的に少数の大きいサイズのファイルのための設計です。」
また、WWDC2014 - What's New in Foundation Networkingからダウンロードした707_whats_new_in_foundation_networking.pdfの中身を読むと、こんなアンチパターンと推奨の使い方が書いてあります。
ここで、もう一度最初の設計案を見てみます
- 1GB = 2MB/zip * 500zip ❌
- ダウンロードのフローは
- ダウンロードするファイルの一覧をリストに格納
NSURLSessionDownloadTask
を二つずつ作って、前のダウンロードが終わった時に新たなNSURLSessionDownloadTask
を作るという循環でリストをどんどん消化 ❌
- 例外に関しては
- 1zipにはたかが2MBなので、たとえダウンロード失敗したり、ファイルが壊れてしまったりしても、それを捨ててもう一度ダウンロードすればよいという考え ❌
まさか見事にアンチパターンを全部踏んだのでは?
最終的な案
- 1GB = 50MB/zip * 20zip
- ダウンロードのフローは
- ダウンロードするファイルの一覧をリストに格納
- アプリがフォアグランドになった時に
NSURLSessionDownloadTask
を二つずつ作って、前のダウンロードが終わった時に新たなNSURLSessionDownloadTask
を作るという循環でリストをどんどん消化 - アプリがバックグラウンドに行く前に、待ち行列にあるすべてのURLからタスクを大量に作って、全部リジューム
- 例外に関しては
- ダウンロードの進捗をsqliteに書き込んで、すでにダウンロード済みのものはもう一度ダウンロードさせない
- 起動するたびに前回残ったタスクの状況を調べて、ダウンロード完了状態のものを全部回収し、途中までダウンロードしたものはキャンセル
まとめ
バックグラウンドダウンロードは実に便利かつユーザーに優しい機能なので、多少実装・検証コストかかっても入れたほうがいいと思います。実は今回一つの課題が残っています。クラッシュから再開した時に途中までのタスクをキャンセルせずに途中からダウンロードするいわばRangeDownloadの対応です。いつか対応しようと思っています。
バックグラウンドダウンロードを取り入れる際には以下の方針に沿ってやりましょう
- 少数の大きなサイズのファイルなら、バックグラウンドダウンロード化を検討する
- バックグラウンドからデータの転送開始をしない
- バックグラウンド状態のままでアプリがクラッシュする可能性があるため、データ回収の仕組みをきちんと用意する
Kyo Shinyuu