LoginSignup
3
2

【Android】WorkManager でファイルのダウンロードの進捗を監視する

Last updated at Posted at 2023-12-23

MIXI DEVELOPERS Advent Calendar 2023 24日目の記事です

WorkManager の Long-running worker を使用すると、時間のかかるタスクをバックグラウンドで実行できます。

数年前まではフォアグラウンドサービスを自力で実装する方法が一般的でしたが、現在では WorkManager を使って進捗状況の監視処理の制約などの高度な要件にも簡単に対応できるようになっています。

この記事では WorkManager でファイルのダウンロード処理を実装する例を紹介します。
ダウンロードの進捗を UI に表示したり、ネットワークに接続されるまで処理を延期させたりする方法についても紹介します。

WorkManager を導入する

まずは WorkManager の依存関係を追加します。
リリースノートで最新のバージョンを確認してください。
現時点 (2023/12/24) の最新バージョンは v2.9.0 です。

build.gradle.kts
dependencies {
    ...
    implementation("androidx.work:work-runtime:x.y.z")
}

Android 14 ではフォアグラウンドサービスタイプの宣言が必須になりました。
以下のようにして AndroidManifest にサービスタイプを宣言すると、Long-running worker が内部的にフォアグラウンドサービスを起動できるようになります。
ついでにファイルのダウンロードと通知の表示に必要なパーミッションも宣言しておきます。

AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />

    <application>
        ...
        <service
            android:name="androidx.work.impl.foreground.SystemForegroundService"
            android:foregroundServiceType="dataSync"
            tools:node="merge" />
    </application>

</manifest>

dataSync のサービスタイプは将来的に非推奨になるため、今後のバージョンでは別の API に置き換えられる可能性があります。
バックグラウンドタスク関連の API は頻繁に仕様や制限が変わるため、今後も注視していくことになりそうです。

注: Android の将来のバージョンでは、このフォアグラウンド サービス タイプは非推奨になります。WorkManager またはユーザー開始型データ転送ジョブを使用するように移行することをおすすめします。

Worker を作成する

ダウンロード用の Worker を作成します。
実際のダウンロード処理の実装については後述するとして、まずは実行中の通知を表示できることを確認します。

class DownloadWorker(private val appContext: Context, params: WorkerParameters) : CoroutineWorker(appContext, params) {
    override suspend fun doWork(): Result {
        createNotificationChannel() // 注1

        val url = inputData.getString(INPUT_URL) ?: return Result.failure()
        val fileName = Uri.parse(url).lastPathSegment ?: return Result.failure()
        val file = File(appContext.filesDir, fileName)

        download(file = file, url = url, onProgress = ::notifyProgress)
        return Result.success()
    }

    private suspend fun download(
        file: File,
        url: String,
        onProgress: suspend (Float) -> Unit
    ) = withContext(Dispatchers.IO) {
        // TODO download
        repeat(100) {
            onProgress(it / 100f)
            delay(50)
        }
    }

    private suspend fun notifyProgress(progress: Float) {
        // UI に progress を表示するため (後述)
        setProgress(workDataOf(PROGRESS to progress))

        // 通知を表示
        val notification = NotificationCompat.Builder(appContext, NOTIFICATION_CHANNEL_ID)
            .setSmallIcon(R.drawable.ic_downloading)
            .setContentTitle(appContext.getString(R.string.downloading))
            .setProgress(MAX_PROGRESS, (progress * MAX_PROGRESS).toInt(), false)
            .build()

        val foregroundInfo = ForegroundInfo(
            NOTIFICATION_ID,
            notification,
            FOREGROUND_SERVICE_TYPE_DATA_SYNC // 注2
        )
        setForeground(foregroundInfo)
    }

    private fun createNotificationChannel() {
        val notificationManager = appContext.getSystemService(NOTIFICATION_SERVICE) as NotificationManager
        val channel = NotificationChannel(NOTIFICATION_CHANNEL_ID, appContext.getString(R.string.download), IMPORTANCE_DEFAULT)
        notificationManager.createNotificationChannel(channel)
    }

    companion object {
        const val PROGRESS = "progress"
        private const val NOTIFICATION_CHANNEL_ID = "notification_channel_id"
        private const val NOTIFICATION_ID = 0
        private const val INPUT_URL = "input_url"
        private const val MAX_PROGRESS = 100

        fun createDownloadRequest(url: String) = OneTimeWorkRequestBuilder<DownloadWorker>()
            .setInputData(workDataOf(INPUT_URL to url))
            .build()
    }
}

注1: NotificationChannel の作成は Android 8.0 以降で必要です。

注2: フォアグラウンドサービスタイプの指定は Android 10 以降で利用可能です。

このように CoroutineWorker を継承した Worker を作成すると、doWork() の中で suspend 関数を実行できます。

Worker を利用する

画面上のダウンロードボタンを押したときに Worker を起動させる例を紹介します。
この例ではダウンロードするファイルの URL をテキストフィールドで入力できるようにしています。

class DownloadActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MaterialTheme {
                Column {
                    var url by remember { mutableStateOf("") }

                    TextField(
                        value = url,
                        onValueChange = { url = it },
                        modifier = Modifier.fillMaxWidth()
                    )
                    Button(onClick = {
                        // 注3
                        val request = DownloadWorker.createDownloadRequest(url)
                        val workManager = WorkManager.getInstance(applicationContext)
                        workManager.enqueue(request)
                    }) {
                        Text(text = stringResource(id = R.string.download))
                    }
                }
            }
        }
    }
}

注3:
Long-running worker の実行中には通知が表示されますが、Android 13 以降では通知表示用のランタイムパーミッションが必要です。
Jetpack Compose でアプリを構築している場合は Accompanist を使ってパーミッションを取得すると良いでしょう (この記事では具体的な実装を割愛します) 。

ダウンロード処理を実装する

DownloadWorker の download() 関数の中身を実装します。
ここでは Http クライアントとして Ktor を使うことにします。

DownloadWorker.kt
private suspend fun download(
    file: File,
    url: String,
    onProgress: suspend (Float) -> Unit
) = withContext(Dispatchers.IO) {
    onProgress(0f)
    val response = HttpClient().get(url) {
        onDownload { downloadedBytes, totalBytes ->
            launch {
                onProgress(downloadedBytes.toFloat() / totalBytes)
            }
        }
    }
    val body: ByteArray = response.body()
    file.writeBytes(body)
}

この例では コールバック引数 onProgress を渡すように実装していますが、進捗状況を出力する Kotlin Flow を返すようにしても良いでしょう。

キャンセルやエラーをハンドリングする

ダウンロード中の通知にキャンセルボタンを追加し、Worker をキャンセルできるようにします。

DownloadWorker.kt
private suspend fun notifyProgress(progress: Float) {
    ...
    val notification = NotificationCompat.Builder(appContext, NOTIFICATION_CHANNEL_ID)
        ...
        .addAction(
            R.drawable.ic_cancel,
            appContext.getString(android.R.string.cancel),
            WorkManager.getInstance(appContext).createCancelPendingIntent(id)
        )
        .build()
    ...
}

キャンセルボタンが押されると doWork() のコルーチンの中で CancellationException が発生します。
これをキャッチしてファイルの削除やユーザーへの通知などを行うと良いでしょう。
また、IOException をキャッチしてダウンロードのエラーも適切にハンドリングします。

DownloadWorker.kt
return try {
    download(...)
    Result.success()
} catch (error: IOException) {
    // ダウンロードのエラーが発生した
    file.delete()
    Result.failure()
} catch (error: CancellationException) {
    // Worker がキャンセルされた
    file.delete()
    Result.success()
}

進捗状況を UI から監視する

ダウンロードの進捗状況を UI から監視し、LinearProgressIndicator として表示できるようにします。
先月リリースされた WorkManager v2.9.0 では Worker の情報を Flow として取得する API が追加されたため、それを使ってみます。

DownloadActivity.kt
Column {
    var url by remember { mutableStateOf("") }
    val coroutineScope = rememberCoroutineScope()
    var progress by remember { mutableFloatStateOf(0f) }

    TextField(value = url, onValueChange = { url = it })
    LinearProgressIndicator(
        progress = progress,
        modifier = Modifier.fillMaxWidth()
    )
    Button(onClick = {
        val request = DownloadWorker.createDownloadRequest(url)
        val workManager = WorkManager.getInstance(applicationContext)
        coroutineScope.launch {
            workManager.getWorkInfoByIdFlow(request.id)
                .collect { workInfo ->
                    progress = workInfo.progress.getFloat(DownloadWorker.PROGRESS, 0f)
                }
        }
        workManager.enqueue(request)
    }) {
        Text(text = stringResource(id = R.string.download))
    }
}

ネットワークやストレージの制約を追加する

特定の条件を満たすまで Worker の処理を延期させたい場合は制約を追加します。

DownloadWorker.kt
fun createDownloadRequest(url: String) = OneTimeWorkRequestBuilder<DownloadWorker>()
    .setInputData(workDataOf(INPUT_URL to url))
    .setConstraints(
        Constraints.Builder()
            .setRequiredNetworkType(NetworkType.CONNECTED)
            .setRequiresStorageNotLow(true)
            .build()
    )
    .build()

このようにすると、ネットワークに接続されていてストレージの空き容量があるときのみ Worker を実行させることができます。

まとめ

この記事ではファイルのダウンロードを例にして WorkManager の使用方法を紹介しました。
ファイルのアップロードやローカルでのファイル変換など、その他の時間のかかる処理も同様の方法で実装できます。

私が業務で携わっているプロジェクトでは、自前で実装したフォアグラウンドサービスを WorkManager に移行したことでコードを非常にシンプルな形にリファクタリングできました。
リリース後も特に新しいクラッシュや不具合報告などが出ておらず、安定して動作しています。

WorkManager を活用したい方の参考になれば幸いです。

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2