アプリが寝てる間に…: Background Transfer Services

ユーザーがアプリをずっと開いておかないといけないとしたら、それはやかんのお湯が湧くのをずっと凝視していないといけないぐらい辛いことです。今回はGwendolyn Westonが、iOSのBackground Transfer Service APIを使ってバックグラウンドでファイルをダウンロードする方法を、よく陥る落とし穴やベストプラクティスなども紹介しながらお教えします。ユーザーの時間を節約しつつ、ユーザーのフラストレーションを簡単に取り除く方法を学んでいきましょう。🍵


はじめに (0:00)

みなさんこんにちは!私の名前はGwendolyn Westonで、PlanGridという会社の開発者です。今日はバックグラウンドでのダウンロードとその実装方法にフォーカスしながら、Background Transfer Servicesについてお話したいと思います。はじめに、NSURLSessionsについてあまり知らない人のために、フォアグラウンドでダウンロードをする方法について紹介します。次に、それをバックグラウンドで動かす方法を紹介し、最後に、よく陥る落とし穴をどうやって回避するのかお見せしたいと思います。

BackGround Transfer Service - やかんの例え (0:14)

Background Transfer ServicesはiOS 7から導入されたAPIで、これのおかげでアプリがサスペンドしたり終了した後でも、ネットワークリクエスト(ダウンロードやアップロード)をバックグラウンドで継続させることができるようになりました。例えば、DropboxはこのAPIのおかげで、ファイルの同期が終了するまでバックグランドで同期処理をし続けることができます。

この便利さを説明するには、やかんの例えがぴったりです。あなたは水をやかんに注ぎ、ボタンを押して水を沸かします。このとき「水を沸騰させるには、あなたはこのやかんの近くに立っていないといけない」と奇妙な制約があります。あなたはやかんの近くに立って、おもむろに電話を取り出して、Facebookを見始めます。友達があなた以外の人たちと遊んでいるのをFacebookでチェックし終えるころ、私は再び立ち止まってあなたに言います「水を沸騰させるには、あなたはこのやかんをずっと凝視していなければなりません」と。なんて馬鹿げたやかんなんだ!と思うかもしれませんが、フォアグランドでダウンロードをさせることは、これとほぼ等しいことなのです。

実際の使用例 (2:02)

私が働くPlanGrid社は言わば「建築設計図のGitHub」です。建築プロジェクトの版管理とプロジェクトマネジメントを可能にするサービスです。

メインのユースケースは以下の様なケースです。請負業者はしばしば建設現場で設計図の修正をしなければなりません。これらの修正を他の請負業者のデバイスに逐次同期させることができれば、変更が起きるたびに新しい設計図をプリントする必要が無くなるので、お金、時間、努力を大きくセーブすることができるわけです。

これは、ユーザーが非常に高精細でサイズの大きな設計図を我々のレポジトリにアップロードすることを意味します。新しいメンバーがこのプロジェクトに参加したとき、全ての設計図を彼らのデバイスに同期させるには何時間もかかるわけです。確実に設計図をダウンロードするために、実は以前はiPhoneのスクリーンがロックしないようにデバイスの設定変更をお願いしたりしていました。これがいかにイライラすることだったか想像できますか?それに重要な設計図を含んだデバイスをロックしない状態で放っておくのはセキュリティー的にも非常に大きな問題です。「ダウンロードを開始さえしてくれれば、後は私達が良きに計らいますので」と言うことができたらいいのに、と思っていました。

Background Transfer Servicesを使わないダウンロードは、水が沸騰するのをずっと凝視するようなものです。一方、Background Transfer Serviceを使ったダウンロードでは、やかんの近くに立つ必要も、凝視する必要もなく、さらにはどんな種類のお茶がほしいか、何分ぐらいお茶っ葉をお湯に浸すべきかをやかんに言いつけておくことさえできるのです。

フォアグラウンドでのダウンロード (4:41)

こちらがサンプルコードです。

let urlstring = "https://remoteteakettle.com/boiledwater.pdf"
let filepath = "Documents/local_teakettle"
if let url = NSURL(string: urlstring) {
  let task = NSURLSession.sharedSession().downloadTaskWithURL(url, completionHandler: {
    (location:NSURL?, response:NSURLResponse?, error:NSError?) in
    if let loc = location, path = loc.path {
      try! NSFileManager.defaultManager().moveItemAtPath(path, toPath:filepath)
    }
  })
  task.resume()
}

1行1行見て行きましょう。1行目は我々のサーバー上のファイルの位置を示しています。remoteteakettle.comサーバーから、boiledwater.pdfという名前のファイルをダウンロードしようとしています。2行目は、そのPDFをダウンロードした時に保存したい場所のファイルパスです。3行目にいくとNSURLSessionフレームワーク関連の様々な部品が登場します。少しずつ読み解いていきましょう。

NSURLSessionはリクエストを生成・管理してくれるクラスです。そして、sharedSessionはいくつかのデフォルト設定を持ったシングルトンセッションで、キャッシュポリシーやタイムアウト間隔などがデフォルトで用意されています。自分好みの動作をするカスタムセッションを作ることもできますが、ここではひとまず単一のシングルトンで動かすことにしましょう。最後の部分が、NSURLSessionのダウンロードタスクで、これが今回私たちがやりたいことです。

NSURLSessionTask (6:00)

残りのコードについてはおいおい説明しますが、ちょっと立ち止まってこのNSURLSessionダウンロードタスクについてお話したいと思います。このダウンロードタスクは実はNSURLSessionTaskのサブクラスで、NSURLSessionTaskには3タイプあります。

  • NSURLSessionDownloadTask
  • NSURLSessionUploadTask
  • NSURLSessionDataTask(認証トークンなど短期間のリクエストのため)

セッションタスクを戻すには、何か便利なイニシャライザを呼んだりするのではなく、セッションオブジェクトを通じて行います。具体的にはセッションURLをフィードします。

これはどういう意味でしょうか?NSURLSession をクッキーモンスター(チョコレートクッキーが大好物)、URLをクッキーだと考えてください。NSURLSessionはクッキーがとても好きですから、私たちが彼にクッキーをあげるたびに、彼は喜びのあまり愛(Love)を返してくれます。この返してくれた愛(Love)が私達が使いたい NSURLSessionTask です。これを何度か繰り返すと、同じNSURLSessionからいくつかのタスクを受け取りことになり、1つのセッションに対して多くのセッションタスクという関係が生まれます。1つのセッションはいくつかのセッションタスクに責任を持ちますが、それぞれのセッションタスクは1つのセッションにしか対応しません。セッションオブジェクトに対してタスクを作るメソッドが存在し、今回のコードではdownloadTaskWithURL(_:completionHandler:)がそれにあたります。

さて、コードに戻って、このcompletionHandlerを見てみましょう。このさて、コードに戻っ���、このcompletionHandlerを見てみましょう。このcompletionには3つの引数があります。

  • 場所(ダウンロードしたファイルを一時保存するファイルパス)
  • レスポンス(私達のリクエストのステータスコードを受け取るため)
  • エラー(何か問題がおきたときのため)

私がCatchもExceptionも書かず、エラー処理を何もしていないことにお気づきでしょう。今回の例では、エラーも例外も全く存在しない素晴らしい国に住んでいると仮定して、このへんは適当にごまかしています。一時保存先から、指定した保存先へとファイルを移動させることにフォーカスするためです。

さて、これでなんとか動くようになりました。でも、ユーザーがもしほかのアプリに切り替えてしまったら?大変残念ですが、お湯を沸騰させることはできません。

バックグラウンドでダウンロードする — NSURLSessionConfiguration (9:23)

この処理をバックグラウンドで行うには、NSURLSessionConfigurationを使って、これらのタスクをバックグラウンド対応にしたい旨をシステムに知らせてあげる必要があります。前に、キャッシュポリシーやタイムアウト間隔などは、カスタムセッションでプロパティを設定できると言いましたよね。Configurationオブジェクトに対して、これらのプロパティを設定し、そのConfigurationでセッションを初期化してみましょう。

“Background Configuration”と呼ばれるカスタムセッションを作成すると、Configurationオブジェクトに1つ設定が追加されます。

let config = NSURLSessionConfiguration.backgroundSessionConfigurationWithIdentifier("i am the batman")
let session = NSURLSession(configuration: config, delegate: self, delegateQueue: nil)

また1行ずつ見て行きましょう。1行目はbackgroundSessionConfigurationWithIdentifier(_:)というメソッドでSession Configurationを作成します。Identifierは何でも構いません。あなたの好きな色でも、Moby Dick(映画)の第一章のタイトルでも、この例の通り”I am the batman”(私がバットマンだ)でも。2行目では、このConfigurationでNSURLSessionを初期化しています。このセッションが受け取るどんなデリゲートメソッドも受け取りたいので、デリゲートはselfにしています。最後はデリゲートキューの設定です。

デリゲートキューは全てのデリゲートメソッドが呼びだされるスレッドを指定することができますが、nilを指定した場合デフォルトのものが自動的に選択されます。このコードを実装する前に強調してお伝えしておきたいのは、これらのIdentifier名は必ずユニークでなければならないということです。それがなぜなのかは、バックグラウンドリクエストのライフサイクルを理解すると分かるようになります。

バッググラウンドリクエストのライフサイクル (11:02)

いま私達がDropboxアプリを使っていて、フォアグラウンドでリクエストを開始したとします。デバイスへのファイル同期が始まり、そしてアプリが終了しました。このリクエストはバックグラウンド処理に対応していますので、最終的にはセッションの全てのリクエストの処理が完了します。このとき、システムはアプリに「さあ、すべてのリクエストの処理が完了しましたよ」と伝えてくれます。そしてみなさんがよくご存知のUIAppDelegateのメソッド処理をはじめます。

通常の場合とのただひとつの違いは、処理完了のお知らせが出ると、application:handleEventsForBackgroundURLSessionという新しいメソッドが呼ばれることです。このメソッド内で行われているのは、システムがIdentifier名をあなたに教えることだけです。たとえば、 “I am the Batman.” がIdentifierだったなら、「もしもし“I am the Batman.” という名前のセッションが完了しましたよ。どうしますか?」という処理をしています。もしリクエストに対するエラーコードやサクセスコードを受け取りたいなら、このメソッド内でセッションを再度作成してやる必要があります。コードで言うと、そのIdentifierでBackground Configurationをもう一つ作成し、そのConfigurationで新しいセッションを作るということになります。

func application(application: UIApplication, handleEventsForBackgroundURLSession identifier: String,
  completionHandler:() -> Void) {
    let config = NSURLSessionConfiguration.backgroundSessionConfigurationWithIdentifier(identifier)
    let session = NSURLSession(configuration: config, delegate: self, delegateQueue: NSOperationQueue.mainQueue())
    session.getTasksWithCompletionHandler { (dataTasks, uploadTasks, downloadTasks) -> Void in
      // yay! you have your tasks!
    }
  }

この芋づる式の処理をさせることで、セッションタスクを生き返らせ、システムから「やぁ、セッションが生き返りましたよ。このセッションに関連するタスクは以下のものです」という連絡を受け取ることができるようになるわけです。

しかし、システムがどのタスクを再生成するかの判断はSession Identifierのみに頼ってますので、もし同じIdentifierのセッションを2つ作ってしまったらどういうことになると思いますか?システムはどのやってどのタスクを再生成するのでしょうか。実はドキュメンテーションには「同じIdentifier名を複数のセッションにつけてしまった場合の行動については定義されていない」という不吉な記述があります。なので、これは避けることにしましょう。では、Background Configurationを作成したコードを加えてみましょう。この2行のコード以外は前と同じです。

let urlstring = "https://remoteteakettle.com/boiledwater.pdf"
let filepath = "Documents/local_teakettle"
if let url = NSURL(string: urlstring) {

  let config = NSURLSessionConfiguration.backgroundSessionConfigurationWithIdentifier((NSUUID().UUIDString))
  let session = NSURLSession(configuration: config, delegate: self, delegateQueue: nil)

  let task = NSURLSession.sharedSession().downloadTaskWithURL(url, completionHandler: {
    (location:NSURL?, response:NSURLResponse?, error:NSError?) in
    if let loc = location, path = loc.path {
      try! NSFileManager.defaultManager().moveItemAtPath(path, toPath:filepath)
    }
  })
  task.resume()
}

落とし穴その1:Completion Handlingが無い (13:03)

残念ながらCompletion Handlerをつけるとバックグラウンド非対応になってしまいます。もしCompletion Handlerをつけてバックグラウンドタスクを作成すると、コンソールから「それはサポートされてません!」と怒鳴られてしまいます。なので、代わりにデリゲートメソッドを使う必要があります。アプリが生き返り、handleEventsForBackgroundURLSession()メソッドの中でセッションを再作成した後、アプリはこのセッションのデリゲートメソッドを全て呼び出しはじめます。特に注目すべきはURLSession(_:downloadTask:didFinishDownloadingToURL:)というメソッドです。これによって、以下の情報を受け取ることができます。

  • セッションオブジェクト
  • 完了したタスク
  • 時的にファイルがダウンロードされた場所

Completion handlerをデリゲートメソッドに移してみましょう。コードはこんな感じになります。

let urlstring = "https://remoteteakettle.com/boiledwater.pdf"
let filepath = "Documents/local_teakettle"
if let url = NSURL(string: urlstring) {

  let config = NSURLSessionConfiguration.backgroundSessionConfigurationWithIdentifier((NSUUID().UUIDString))
  let session = NSURLSession(configuration: config, delegate: self, delegateQueue: nil)

  let task = session.downloadTaskWithURL(url)
  task.resume()
}

func URLSession(session: NSURLSession, downloadTask: NSURLSessionDownloadTask, didFinishDownloadingToURL location:
  NSURL) {
    if let path = location.path {
      try! NSFileManager.defaultManager().moveItemAtPath(path, toPath:filepath)
    }
  }

ダウンロードタスクをCompletion Handlerで初期化する代わりにデリゲートメソッドの中に入れました。残念ながら、これでもまだ動きません。filepathがデリゲートメソッドのスコープの外にあるからです。

落とし穴その2:Auxiliary Request Info(追加のリクエスト情報)が無い (14:49)

違うメソッドの中からファイルパスを取得しなければならない時など、リクエストの情報を参照する必要がでてきますので、リクエストをなんとか保持し続けたいですよね。 taskDescriptionメソッドを一応使うこともできますが、ドキュメンテーションには「ここに格納される文字列はアプリのインターフェースの一部としてユーザーに表示してもいいように、人間が解読可能な文字列にするようにしてください」と書かれています。ここにはファイルパスだけでなく、UUIDやファイル名なども格納したいですから、taskDescriptionを使うのは良い方法とは言えません。NSURLSessionDownloadTaskのサブクラスを作るのはどうでしょう。クッキーモンスターことNSURLSession は予め定義されたクラスしか返せず、自分のサブクラスを返すことはできませんので、これもNGです。

解決策:リクエスト情報を保持する (16:43)

もしシステムが機能を提供してくれないのなら、自分でやるしかありません。自前でデータを保持し続けましょう。

public class HalfBoiledWater: NSObject {
  public let sessionId: String
  public let taskId: Int
  public let filepath: String

  init(sessionId:String, taskId:Int, filepath:String) {
    self.sessionId = sessionId
    self.taskId = taskId
    self.filepath = filepath
  }

  func save() {
    // save to a persistent data store!
  }
}

public func fetchModel(sessionId:String, taskId:Int) -> HalfBoiledWater {
  // fetch from a persistent data store!
}

おそらくやり方は色々あり、みなさんそれぞれ好みのデータ保持方法があると思います。FMDBでもSQLiteでも、Core Dataだっていいです。ご自由に選んで下さい。私はsave() とfetchModel(_:taskId:).という2つのメソッドを使ってやってみたいと思います。パスを格納し、それをデータベースから取ってきていますので、ファイルパスは直列化可能モデルです。sessionIdとtaskIdはこのモデルを保存するためのプライマリキーとして使います。コードに書くとこんな感じになります。

let urlstring = "https://remoteteakettle.com/boiledwater.pdf"
let filepath = "Documents/local_teakettle"
if let url = NSURL(string: urlstring) {

  let config = NSURLSessionConfiguration.backgroundSessionConfigurationWithIdentifier((NSUUID().UUIDString))
  let session = NSURLSession(configuration: config, delegate: self, delegateQueue: nil)

  let task = session.downloadTaskWithURL(url)
  if let sessionId = session.configuration.identifier {
    let persistedModel = HalfBoiledWater(sessionId:sessionId, taskId:task.taskIdentifier, filepath:filepath)
    persistedModel.save()
  }
  task.resume()
}

func URLSession(session: NSURLSession, downloadTask: NSURLSessionDownloadTask, didFinishDownloadingToURL location:
  NSURL) {
  if let sessionId = session.configuration.identifier {
    let persistedModel = fetchModel(sessionId, taskId:downloadTask.taskIdentifier)
    if let path = location.path {
      try! NSFileManager.defaultManager().moveItemAtPath(path, toPath:persistedModel.filepath)
    }
}

ダウンロードタスクを作成した直後、私達が必要とする全ての追加情報を含んだモデルも作成します。そしてデリゲートメソッドにおいて、ファイルパスが必要になったタイミングで、sessionIDとtask identifierをキーにしてこのモデルを取ってくればよいのです。

まとめ (18:35)

以上がバックグラウンドでのダウンロード処理の必要最小限の実装です。おさらいしましょう。最初に、セッションで走る全てのタスクををバックグラウンド対応にさせるため、Background Configurationを作ってシステムに合図が送られるようにします。次に、Completion Handlerからコードをデリゲートメソッドに移動させました。さらに、デリゲートメソッドで必要になるあらゆる追加情報を常に参照可能にするために、その情報を保持できるようにしました。

ここで紹介したのは、このAPIでできることのほんの一部でしかありません。進行状況を表示したり、ダウンロードをキャンセルしたり、エラーが発生してダウンロードが中断した後のダウンロード再開処理などもするこができます。(これらに手を出すと、またさらなる落とし穴を発見することになるかもしれません。)

このTipsがあなたのアプリをバックグラウンドダウンロード対応にするための一助になり、ユーザーが水が沸騰するのを凝視しながら立って待たなくてよくなることを願っています。ありがとうございました。


Gwendolyn Weston

Gwendolyn Weston

Gwendolyn WestonはPlanGridでエンジニアをしており、建築設計図用のバージョンコントロールの開発をしています。 彼女は数学と紫色(#A157E8)が好きで、現在初めてのミュージックアルバムの制作をしています。