はじめに
今回は、Androidで大きなファイルをダウンロードするときなどに使用するDownloadManagerをRxJavaで処理して少し幸せになったので、そのお話をしようかなと思います。
まずはじめに、DownloadManagerはAPI Level9から追加された、大きい容量のファイルなどをダウンロードする際に使用するHTTP Downloadのロングハンドリングクラスです。進捗の通知など、ダウンロードをするのに便利な機能が色々と揃っています。
身近な例だと、Kindleの書籍ダウンロードなどがこれに該当すると思います。
さて、そんなDownloadManagerなのですが、同期的にダウンロード結果を処理したりする場合に厄介なことがあります。それは、ダウンロード状態の変化をReceiver経由でしか受け取れないため、ダウンロード終了後に何か処理をする場合や、ダウンロード失敗時の処理などが面倒ということです。
そのため、ここではこれらのダウンロード状態の変化をRxJavaで処理することで同期的に処理しやすいようにします。
参考リンクなど
Reactiveプログラミングって?、RxJavaって?という人は下の資料とか参考にしてみてください。
英語が読める方は、こちらもオススメです。
- https://speakerdeck.com/jakewharton/demystifying-rxjava-subscribers-oredev-2015
- https://speakerdeck.com/jakewharton/exploring-rxjava-2-for-android-gotocph-october-2016
DownloadManagerについては、こちらを参考にさせていただきました。
- http://techbooster.jpn.org/andriod/application/2199/
- http://y-anz-m.blogspot.jp/2010/12/androidandroid23-downloadmanager.html
コード
コードは全てKotlinで書いていますので、もしJavaじゃないとダメだという方はコメントいただければJavaコードもコメントに書こうと思いますので言ってください。
下準備
まずは、Permissionの許可をManifestに追加してください。また、外部ストレージに保存する場合は、その許可も追加しましょう。(targetSDKVersionが23移行だと、Mパーミッション対応も必要なので気をつけてください)
<uses-permission android:name="android.permission.INTERNET"/>
<!-- 外部ストレージに保存する場合 -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
また、RxJavaとRxAndroidを使用しますので、以下のリンクの通りbuild.gradleにて設定をしてください。RxJavaは1系を想定しています。もし、2系をお使いの方はご自身で置きかえていただければと思います。
dependencies {
compile 'io.reactivex:rxjava:1.2.1'
compile 'io.reactivex:rxandroid:1.2.1'
}
RxDownloaderの使い方
- DownloadManager用のRequestクラスを作成します。Requestクラスに、それぞれダウンロード先のファイル名や、ダウンロード先、タイトルや説明などをお好みで設定します。DownloadManagerの仕様に沿ってください。
- RxDownloaderクラスを作成します
- 1.で作成したRequestをqueueに貯めます。複数貯めても大丈夫です。
- execute()を呼び出すとObservableが返ってくるので、subscribeして実行しましょう。
import android.app.DownloadManager
import android.net.Uri
import android.os.Bundle
import android.support.v7.app.AppCompatActivity
import rx.Observable
import rx.Subscriber
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
/* === Step1 Create request object === */
// Uri for download target uri
val uri = Uri.parse("https://dl.dropboxusercontent.com/u/31455721/bg_jpg/150501.jpg")
val request = DownloadManager.Request(uri).apply {
setAllowedNetworkTypes(
DownloadManager.Request.NETWORK_MOBILE or DownloadManager.Request.NETWORK_WIFI
)
setTitle("Sample Download")
setDescription("sample of using download manager")
// and so your request settings...
}
/* === Step2 Create rxdownload === */
val rxDownloader = RxDownloader(this)
/* === Step3 Enqueue request === */
rxDownloader.enqueue(request)
/* === Step4 Execute and subscribe === */
rxDownloader.execute()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(object : Subscriber<RxDownloader.DownloadStatus>() {
override fun onNext(status: RxDownloader.DownloadStatus?) {
// Handling status
}
override fun onError(e: Throwable?) {
// Error action
}
override fun onCompleted() {
// Complete all request
}
})
}
}
さて、こう書くと非常に冗長ですが、Kotlinにはextentionという素晴らしい機能があるので、requestやrequestの配列に対してexecuteメソッドを生やしたextentionを用意しました。なので、以下のようにStep2 ~ Step4を短縮できます。
配列に対しても同じexecuteだけで呼び出せます。
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
/* === Step1 Create request object === */
// Uri for download target uri
val uri = Uri.parse("https://dl.dropboxusercontent.com/u/31455721/bg_jpg/150501.jpg")
val request = DownloadManager.Request(uri).apply {
setAllowedNetworkTypes(
DownloadManager.Request.NETWORK_MOBILE or DownloadManager.Request.NETWORK_WIFI
)
setTitle("Sample Download")
setDescription("sample of using download manager")
// and so your request settings...
}
/* === Step2~4 Kotlin extention === */
request.execute(this)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(object : Subscriber<RxDownloader.DownloadStatus>() {
override fun onNext(status: RxDownloader.DownloadStatus?) {
// Handling status
}
override fun onError(e: Throwable?) {
// Error action
}
override fun onCompleted() {
// Complete all request
}
})
}
}
挙動について、RxDownloader.DownloadStatusは以下の5種類のものを返します。
これらが、どのタイミンで返されるかはDownloadManagerの仕様と同等ですのでそちらを見ていただければと思います。
// ダウンロードが成功した状態
Successful(id: Long)
// ダウンロード中の状態
Running(id: Long)
// ダウンロードがまだ処理されていない状態
Pending(id: Long)
// ダウンロードが止まっている状態
Paused(id: Long, reason: String)
// ダウンロードが失敗した状態
Failed(id: Long, reason: String)
RxDownloaderは複数リクエストの処理を前提としているため、一つ一つのリクエスト結果はonNextが呼び出され、全てのリクエストが処理されるとonCompleteが呼び出されます。
また、エラーが発生した場合はそのタイミングでonErrorが呼び出されます。(複数ダウンロードをする場合は、このタイミングで他のリクエスト結果の受取も終わってしまうため、注意してください。)
RxDownloaderの中身
使い方をざっくりと、掴んだところでRxDownloaderの中身の話に移っていきます。
RxDownloaderは、コンストラクタでcontextとRequestのListを受けられます。Requestのリストをここで設定しなくても、空のリストで初期化されるだけなので、お好みで使って大丈夫です。
主に、処理をしているのはexecuteメソッドの中で、ここで実際にリクエストをDownloadManagerのenqueueに追加しています。
DownloadManagerの完了イベントの通知は、BroadcastReceiverを介して行われるので、処理を開始する前にまずContext.registerReceiverを使用してレシーバーを登録します。
その後、DownloadManagerにrequestをenqueueします。この時、queue1つごとにタスクのIDを割り振られるのでこれを保存しておきます。リクエストのキャンセルや、このリクエスト内の処理かどうかの判定に、このタスクのIDを使用します。
これらに関連して、Observableをunsubscribeするタイミングでreceiberの解除と、タスクのキャンセルを行います。
それぞれのStatusの通知のハンドリングなどを見たい方は、resolveDownloadStatus
メソッドを見てみてください。それぞれの通知内容ごとにreasonを決めたりしています。
また、今回は非同期なイベント通知となるため、Observable.fromEmitter
を使用して、Observableを作成します。
/**
* DownloadManagerをRxで処理するためのクラス
*/
class RxDownloader(
private val context: Context,
// Downloadするリクエストのリスト
private val requests: ArrayList<DownloadManager.Request> = ArrayList<DownloadManager.Request>()) {
companion object {
const val TAG = "RxDownloader"
}
private val manager: DownloadManager =
context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
// Requestで行ったダウンロードの一覧を管理しておくためのクラス
private val queuedRequests: HashMap<Long, DownloadManager.Request> =
HashMap<Long, DownloadManager.Request>()
// 最後にUnsubscriveしないと行けないため、ここで所持しておく
private var receiver: BroadcastReceiver? = null
fun enqueue(request: DownloadManager.Request): RxDownloader = apply {
requests.add(request)
}
fun execute(): Observable<DownloadStatus> =
if (requests.isEmpty()) Observable.empty()
else Observable.fromEmitter({ emitter ->
receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
intent ?: return
if (DownloadManager.ACTION_DOWNLOAD_COMPLETE.equals(intent.action)) {
val id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1)
if (!queuedRequests.contains(id)) {
// このリクエストのセットの中のものでなければ処理をしない
return
}
// チェッキングStatus
resolveDownloadStatus(id, emitter)
// 未処理のリクエスト一覧からリクエストを削除する
queuedRequests.remove(id)
// 全てのリクエストが終わっていたらonCompleteを投げる
if (queuedRequests.isEmpty()) {
emitter.onCompleted()
}
}
}
}
context.registerReceiver(receiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE))
// 実際のリクエストを行う
requests.forEach {
val downloadId = manager.enqueue(it)
// このObservableの処理待ちのRequestとして追加する
queuedRequests.put(downloadId, it)
Log.d(TAG, "ID: ${downloadId}, START")
}
emitter.setCancellation {
// Unsubscribeされたタイミングでリクエストを全て破棄する
queuedRequests.forEach {
manager.remove(it.key)
}
// これ以上イベントは来ないのでReceiverを解除する
receiver?.let {
context.unregisterReceiver(it)
}
}
}, AsyncEmitter.BackpressureMode.BUFFER)
private fun resolveDownloadStatus(id: Long, emitter: AsyncEmitter<in DownloadStatus>) {
val query = DownloadManager.Query().apply {
setFilterById(id)
}
val cursor = manager.query(query)
if (cursor.moveToFirst()) {
val status = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS))
val reason = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_REASON))
val requestResult: RequestResult = createRequestResult(id, cursor)
Log.d(TAG, "RESULT: ${requestResult.toString()}")
when (status) {
DownloadManager.STATUS_FAILED -> {
val failedReason = when (reason) {
/** reasonの割り当て **/
}
Log.e(TAG, "ID: ${id}, FAILED: ${failedReason}")
emitter.onNext(DownloadStatus.Failed(requestResult, failedReason))
emitter.onError(DownloadFailedException(failedReason, queuedRequests[id]))
}
DownloadManager.STATUS_PAUSED -> {
val pausedReason = when (reason) {
/** reasonの割り当て **/
}
Log.d(TAG, "ID: ${id}, PAUSED: ${pausedReason}")
emitter.onNext(DownloadStatus.Paused(requestResult, pausedReason))
}
DownloadManager.STATUS_PENDING -> {
Log.d(TAG, "ID: ${id}, PENDING")
emitter.onNext(DownloadStatus.Pending(requestResult))
}
DownloadManager.STATUS_RUNNING -> {
Log.d(TAG, "ID: ${id}, RUNNING")
emitter.onNext(DownloadStatus.Running(requestResult))
}
DownloadManager.STATUS_SUCCESSFUL -> {
Log.d(TAG, "ID: ${id}, SUCCESSFUL")
emitter.onNext(DownloadStatus.Successful(requestResult))
}
}
}
cursor.close()
}
fun createRequestResult(id: Long, cursor: Cursor): RequestResult =
RequestResult(
id = id,
remoteUri = cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_URI)),
localUri = cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)),
mediaType = cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_MEDIA_TYPE)),
totalSize = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)),
title = cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_TITLE)),
description = cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_DESCRIPTION))
)
sealed class DownloadStatus(val result: RequestResult) {
class Successful(result: RequestResult) : DownloadStatus(result)
class Running(result: RequestResult) : DownloadStatus(result)
class Pending(result: RequestResult) : DownloadStatus(result)
class Paused(result: RequestResult, val reason: String) : DownloadStatus(result)
class Failed(result: RequestResult, val reason: String) : DownloadStatus(result)
}
// 再リクエストできるようにRequestを持たせるようにする
class DownloadFailedException(message: String, val request: DownloadManager.Request?) : Throwable(message)
ダウンロード結果をCursorから取り出すのは結構めんどうなので、こちらでやってしまいます。
data class RequestResult(
val id: Long,
val remoteUri: String,
val localUri: String,
val mediaType: String,
val totalSize: Int,
val title: String?,
val description: String?
)
おわりに
コードはここにありますので、適当に見ていただければと思います。
コードの質問でも、なんでも受け付けておりますので、ご質問やリクエストがあればお気軽にどうぞ〜