1. Qiita
  2. 投稿
  3. iOS

URLSessionDownloadTask でちょっと大きめなデータでも寝ている間にダウンロード

  • 12
    いいね
  • 0
    コメント
に投稿

URLSession

近年のアプリはサーバーとのやりとりなしで、アプリ単体で利用し続ける状況は相当まれになったと言えると思います。コンテンツやお知らせ、ログインやユーザーのアイテム情報のやりとりなど、何らかの形でサーバーとのやりとりが必要になっているかと思います。

今回はその中でも、ダウンロードのデータ量の大きな場合のダウンロードの話をしたいと思います。ちなみに、今回の話は以下の勉強会で発表した内容をベースに構成されています。

URLSession vs Alamofire

みなさん Alamofire 使っていますか?使ってますよね。あまり使っていない私が言うのも何ですが、なんかやっぱりカッコよくかけますよね。

Alamofire
Alamofire.request(.GET, "https://domain.com/contents")
    .responseJSON { response in
        // your code here!!
    }
URLSession
let configuration = URLSessionConfiguration.default
let session = URLSession(configuration: configuration)
let url = URL(string: "https://domain.com/contents")!
let task = session.dataTask(with: url) { (data, response, error) in
    if let data = data, let json = try? JSONSerialization.jsonObject(with: data, options: []) {
        // your code here!!
    }
}
task.resume()

さて、今回は電子書籍で大量のコンテンツをダンロード、もしくはニュースアプリやゲームなどで比較的大きなデータをダウンロードする必要がある場合です。ちなみに上記どちらのコードも大きなデータのダウンロードに向いているとは言えません。なぜなら、バックグランドでのダウンロードに制限されるからです。もちろんバックグランドフェッチなどを使う手もありますが、時間制限などがあり、やはり大きなデータのダウンロードには向いていません。よって、アプリを起動した状態でダウンロードのプログレスバーを眺める事になります。

URLSessionDownloadTask

そこで、今回は URLSessionDownloadTask を一押ししたいと思います。 もっとも、ログインやお知らせなどのちょっとしたサーバーとのやりとりには適していません。先ほど書いたようなちょっとした大きさのコンテンツをダウンロードする場合です。URLSessionDownloadTask の一つの特徴はバックグランドでダウンロードしてくれる事です。アプリがバックグランドにまわってキルされても、ダウンロードは継続されます。そしてダウンロードが終了した後にアプリをバックグランドで起動してくれたり、そして、何より次回アプリを起動した時にはダウンロードが終わっていたりするわけです。

しかし課題もあります。downloadTask では、ダウンロード後に呼ばれるクロージャーにダウンロード後の処理を書いても、バックグランド中にキルされると当然ながらクロージャーが呼ばれる事はありません。そこで、URLSessionDownloadDelegate を経由して delegate を実装する事になります。

class MyContentsManager: NSObject {

    lazy var configuration: URLSessionConfiguration = {
        return URLSessionConfiguration.background(withIdentifier: "some unique identifier")
    }()

    lazy var session: URLSession = {
        return URLSession(configuration: self.configuration, delegate: self, delegateQueue: nil)
    }()

    static let shared = MyContentsManager()

    private override init () {
    }

    func startDownloading() {
        let task = self.session.downloadTask(with: URL(string: "https://your.domain.com/some_large_data.dat")!)
        task.resume()
    }
}

extension MyContentsManager: URLSessionDownloadDelegate {

    func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
        // download done or error
    }

    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
        // download complete
    }

}

サーバーなどは架空のものですが、startDownloading() が呼ばれると、ダウンロードが開始され、ダウンロードが完了すると、urlSession(_:downloadTask:didFinishDownloadingTo:) が呼ばれ、location の URL にダウンロードされたファイルが用意されています。そして、ダウンロードに失敗しても成功しても、urlSession(_:task:didCompleteWithError:) が呼ばれます。

ちなみに、バックグランドの間にキルされても、再起動後、同じ id のセッションを復帰させると、delegate に「キルされている間にこんなんダウンロードしました」とばかりにダウンロードしたファイルを受け取る事ができます。

さて、実際のダウンロードはもう少し複雑です。ダウンロードしたデータが電子書籍であれば解凍されどこかのフォルダに保存されたり、画像であればどこかで表示を待っているViewがいたりして適切に表示させたり、ダウンロードのリクエストも結構な数になっているかもしれません。そうですダウンロード後の後処理は意外と面倒だったりします。

クロージャー内でデータを受け取る場合は、ダウンロードまでのコンテクストがあるので、この流れに沿って適切にダウンロード後のデータを処理する事が容易ですが、delegate が呼ばれた時点で、このファイルは誰が何のためにダウンロードしたかを URL からだけで判断する事は容易ではありません。そこで、URLSessionTask のドキュメントを見るとこんなプロパティがあります。

taskIdentifier です。これは、URLSessionTask にユニークに与えられるID ですが ただの Int ですので、自分で値が決められるわけでもなく、またどの ID はなんの為にダンロードしたのかと管理表をアプリが管理が必要になりなんだか面倒な予感がします。

taskDescription はどうでしょう。String でアプリが任意に設定できそうです。しかし、Int よりも情報量が多いといえただの String です。実際 著名なデベロッパーの Gwen さんは以下のプレゼンの中で、taskDescription はただの String で、保存先のパス程度を保存するには十分かもしれないけれども、さらにモデルIDを付加させたり実際のアプリはもっと多くの情報を必要として、もっといい方法があるはずだとの趣旨の発表をしています。(ビデオ内の16分頃)

While Your App Was Sleeping: Background Transfer Services - Nov 2015

https://realm.io/news/gwendolyn-weston-ios-background-networking/

このプレゼンを聞いて、「自分なら JSON を突っ込んでやる。JSON って String だよね。」と思っていましたが、自分がダウンロードに真剣に取り組むまでは放置していました。JSON の中に、「型」だったり、「保存するパス」だったり、「ファイル名」だったり、解凍するなりの処理方法を記述したり、Notification を投げる ID を埋め込んだり、ダウンロードを投げたクライアントコードへたどる為のIDを埋め込んだり、Core Data の Managed Object ID を埋め込んだり、JSON だったら結構融通が利くのではないかと考えました。例はちょっと雑ですが、こんな感じのコーディングが可能になります。

let task = session.downloadTask(with: URL(string: "https://domain.com/books/large_book102.dat")!)
let dictionary = ["type": "Book", "path": "contents/book102", "compress": "zip"]
let jsonData = try! JSONSerialization.data(withJSONObject: dictionary, options: [])
let jsonString = String(bytes: jsonData, encoding: .utf8)
task.taskDescription = jsonString
task.resume()
extension MyContentsManager: URLSessionDownloadDelegate {
    // ...snip...
    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
        if let taskDescription = downloadTask.taskDescription,
           let jsonData = taskDescription.data(using: .utf8),
           let dictionary = try? JSONSerialization.jsonObject(with: jsonData, options: []) as? NSDictionary {
            // what whould you do with this download...
        }
    }

}

dictionary の中に先ほど挙げたような情報などが入って入れば、何とかなりそうですね。

UIApplicationDelegate

ちなみに、Application Delegate に application(_:handleEventsForBackgroundURLSession:completionHandler:) を記述すれば、ダウンロードが完了した時に呼ばれます。アプリがキルされていた場合もバックグランドで起動されこの delegate が呼ばれます。何らかの形で先ほどのセッションの回復ができれば、URLSessionDownloadTask の delegate が呼ばれ、結果的にダウンロードしたファイルの処理ができます。そして処理が終わった後は completionHandler() を呼んであげてください。

class AppDelegate: UIResponder, UIApplicationDelegate {

    let contentsManager = MyContentsManager.shared // make sure MyContentsManager is instantiated every launch

    // ..snip..

    func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) {
        // do something to do with downloaded materials
        completionHandler()
    }
}

ZDownloader

もう少し何とかならないか、という人の為にちょっとだけ便利になるコードを書いてみました。コードは Gist より取得可能です。

ZDownloader

https://gist.github.com/codelynx/806913e8b4122d5ea0997fad386b97a0

登場人物は次の三者です。

Screen Shot 2016-12-14 at 22.35.34.png

ZDownloadable

ダウンロードを対象となるモデルを指します。例えば電子書籍の号だったり、何かのカタログ情報だったり、はたまた画像だったりです。ZDownloadable は JSON に出力可能な Dictionary をプロパティと持ち、自分自身を特定可能な一意な情報や、ダウンロード後の処理方法などを情報として持つ事とします。そして、ダウンロードが完了した時に、呼ばれるメソッドも用意します。

ZDownloadable
public protocol ZDownloadable: class {

    var downloadableInfo: NSDictionary { get } // should be JSON serializable
    func downloadDidComplete(info: NSDictionary, response: URLResponse?, location: URL?, error: Error?)

}

ZDownloder

実際にダウンロードを司る部分です。ダウンロードのリクエストを URLSession に投げて、URLSession の delegate メソッドに耳をすませます。実際に ダウンロードが完了すると、taskDescription から JSON を取り出して、ZDownloderDelegate にダウンロードデータを必要とする ZDownloadable オブジェクトを特定してもらいます。そして、ZDownloadable にダウンロードされたファイルを通知します。

public class ZDownloader: NSObject {

    // ..snip..

    public func download(request: URLRequest, downloadable: ZDownloadable)
    public func download(url: URL, downloadable: ZDownloadable)

}

クライアントコードはこの download() メソッドを呼び出す事でダウンロードを開始でき、ダウンロードが完了すると、当該 ZDownloadable の delegate が呼ばれる事になります。

ZDownloaderDelegate

ZDownloaderDelegate は taskDescription 内の JSON から ZDownlodable を特定できる事とします。もし該当の ZDownlodable がインスタンス化されていない場合は、インスタンス化できる事とします。

public protocol ZDownloaderDelegate: class {
    func downloadable(with dictionary: NSDictionary) -> ZDownloadable?
}

ZDownloaderSample

ZDownloader の実際のサンプルコードも用意しました。コードは GitHub から入手可能です。プロジェクトの名前は「ImageDownloader」になります。

https://github.com/codelynx/ZDownloaderSample

ImageDownloader は予め、用意された9000弱の画像の URL からいくつかをランダムで 256選んで、ダウンロードを開始し、ダウンロード完了後に画像を表示します。ダウンロードはバックグランドでも一定時間、さらにアプリがキルされた状態でもOSがダウンロードを継続し、ダウンロード終了後にアプリを再起動すると、ダウンロード済みの画像は速やかに画面に表示されます。

このサンプルでは、一つの画像に対して、Core Data のモデルが割り当てられていて、Core Data の NSManagedObjectID をベースに JSON を生成し、ZDownloaderDelegate は JSON から NSManagedObjectID の URI を取得し、Core Data のモデルオブジェクトを復元させて、画像を保存させています。

managedObject.objectID.uriRepresentation().absoluteString

ちなみにサンプルプロジェクトで利用している画像のリストは、ImageNet の膨大な URL から一部を抽出し、一部のリソースにアクセスが集中しないように、ランダムで 256 を抽出して利用しています。

おことわり

今回の記事は必要に迫られて、短期間に猛勉強した副産物であり。実際のアプリで展開されているわけではないので、記述や認識が実際と異なる場合もあるので、そんな場合はご指摘いただけるとありがたく思います。Advent Calendar で後ろが決まっていると、いろいろ検証したりしている時間がないです。

環境に対する表記

Xcode Version 8.2 (8C38)
Apple Swift version 3.0.2 (swiftlang-800.0.63 clang-800.0.42.1)

上記の表記は執筆時点のものであり、実際のサンプルはそれ以前のバージョンになります。

この投稿は iOS その2 Advent Calendar 201614日目の記事です。
Comments Loading...