本文へスキップ
すべての記事

2026年のWorkManager: Androidでユニークワーク、チェイニング、バックグラウンドジョブのテスト

AndroidのWorkManagerに関する実践ガイド — ユニークワークのポリシー、チェイニング、expedited work、そして出荷前にバックグラウンドジョブを実際にテストする方法。

MFKAPPS 2 分で読めます

実際に見かけるWorkManagerのコードのほとんどは、キューに入れられたまま放置された単一の OneTimeWorkRequest です。それはユーザーがボタンを二回タップするか、ジョブの途中でアプリのプロセスが落ちるか、レビュアーがジョブが本当に実行されたとどうやってわかるのか尋ねるまでは機能します。WorkManagerの本当の価値は「これを後で実行する」ことではなく、周囲の世界が信頼できないときにバックグラウンドジョブを正しく保つ、一意性チェイニングリトライに関する保証です。その価値の多くは使われないままです。それを支えるAPIの面を見過ごすのが簡単だからです。

私は3つのアプリで3つの異なるジョブにWorkManagerを使っています — Sublyでの夜間エクスポート、Stockyでのパントリーデータの再計算、そしてGranynでのCSVバックアップの書き込みです。そして私が出荷してきたバグはすべて、この3つのうちのどれかを省略したことに起因します。

ユニークワーク: 重複したジョブに対する防御

WorkManagerで最もよくあるバグはクラッシュではなく、重複です。最初のタップのスピナーが表示される前にユーザーが「エクスポート」を二回タップすると、同一のエクスポートジョブが二つキューに入ってしまいます。enqueue() だけではこれを防ぐために何もしません — 両方とも喜んでキューに入れてしまいます。

enqueueUniqueWork がその修正であり、人々が間違えるのはポリシー引数の部分です。

fun scheduleExport(context: Context) {
    val request = OneTimeWorkRequestBuilder<ExportWorker>()
        .setConstraints(
            Constraints.Builder()
                .setRequiredNetworkType(NetworkType.NOT_REQUIRED)
                .build(),
        )
        .build()

    WorkManager.getInstance(context).enqueueUniqueWork(
        "monthly_export",
        ExistingWorkPolicy.KEEP,
        request,
    )
}

3つの ExistingWorkPolicy の値はそれぞれ異なる意図に対応しており、間違ったものを選ぶことが本当のバグです。

  • KEEP — この名前のワークがすでに保留中または実行中であれば、新しいリクエストを破棄します。「エクスポート」に対しては正しい選択です — 二回目のタップは最初のものを再開したり複製したりすべきではありません。
  • REPLACE — 既存のワークをキャンセルして新しく始めます。新しいリクエストが古いものを陳腐化させる更新済みの入力データを持っている場合に正しい選択です — ユーザーが途中でエクスポートの日付範囲を変更した場合など。
  • APPEND_OR_REPLACE — まだ開始していなければ既存のワークにチェインし、そうでなければ新しいチェーンを開始します。まれなケースで、主にリクエストされた順序で実行されなければならない連続的なジョブのためのものです。

enqueueUniquePeriodicWork は定期的なジョブに対して同じポリシー引数を取り、同じ考え方が当てはまります。夜間の再計算ジョブはほぼ常に KEEPUPDATE であるべきで、REPLACE ではありません — 置き換えると定期スケジュールの基準時刻がリセットされ、ジョブが実行されるタイミングが静かにずれてしまいます。

チェイニング: 手作りのコールバックなしに順序付ける

バックアップフローは一つのステップであることはめったにありません — データをシリアライズし、圧縮し、ディスクに書き込み、書き込みを検証する。WorkRequest をチェインすることで、これをネストしたコールバックではなくデータとして表現できます。

val serialize = OneTimeWorkRequestBuilder<SerializeWorker>().build()
val compress = OneTimeWorkRequestBuilder<CompressWorker>().build()
val write = OneTimeWorkRequestBuilder<WriteToDiskWorker>().build()
val verify = OneTimeWorkRequestBuilder<VerifyBackupWorker>().build()

WorkManager.getInstance(context)
    .beginUniqueWork("full_backup", ExistingWorkPolicy.REPLACE, serialize)
    .then(compress)
    .then(write)
    .then(verify)
    .enqueue()

各workerの出力は、Data を通じて自動的に次のworkerの入力になります。

class SerializeWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
    override suspend fun doWork(): Result {
        val path = serializeToTempFile()
        return Result.success(workDataOf("serialized_path" to path))
    }
}

class CompressWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
    override suspend fun doWork(): Result {
        val path = inputData.getString("serialized_path") ?: return Result.failure()
        val compressedPath = compress(path)
        return Result.success(workDataOf("compressed_path" to compressedPath))
    }
}

Data は意図的に小さく作られています — 汎用のペイロードチャネルではなく、サイズが制限された内部データベースに裏打ちされています。ファイルの内容そのものではなく、ファイルパスやIDを渡してください。チェーン内のあるステップが失敗した場合、Result.failure() は下流のすべての実行を停止します。チェーンは欠けた入力のまま静かに続行することはありません。

リトライとバックオフ: デフォルトに驚かされないように

Result.retry() はWorkManagerにworkerを再試行するよう指示し、デフォルトでは30秒待機した後、指数関数的にバックオフし、5時間で上限に達します。実際のネットワーク依存があるジョブでは、デフォルトで通常は問題ありません。ローカルで一時的な失敗 — ファイルロックや一時的なストレージ不足の状態 — が原因で再試行しているジョブでは、ユーザーが実際に待っているものにとって30秒はしばしば長すぎます。

デフォルトを黙って信頼するのではなく、ポリシーを明示的に設定してください。

OneTimeWorkRequestBuilder<WriteToDiskWorker>()
    .setBackoffCriteria(
        BackoffPolicy.LINEAR,
        10, TimeUnit.SECONDS,
    )
    .build()

そして、worker自体の中で Result.retry()Result.failure() を意図的に区別してください — ここは人々が逆にしてしまう部分です。

override suspend fun doWork(): Result {
    return try {
        writeBackupFile()
        Result.success()
    } catch (e: IOException) {
        if (runAttemptCount < 3) Result.retry() else Result.failure()
    } catch (e: SecurityException) {
        // Permission won't fix itself by retrying.
        Result.failure()
    }
}

5時間にわたって5回再試行された権限エラーは回復力ではなく、ユーザーが気づく前に5回静かに失敗しているジョブです。時間が実際に解決できる失敗モードだけをリトライしてください。

Expedited work: ユーザーが見ているジョブのために

すべてのバックグラウンドジョブがWorkManagerのスケジューラがいつ実行するかを決めるのを待てるわけではありません。ユーザーが「今すぐエクスポート」をタップして、システムのDozeのヒューリスティックが許可するタイミングではなく、数秒以内に開始することを期待している場合は、expeditedとしてマークしてください。

OneTimeWorkRequestBuilder<ExportWorker>()
    .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
    .build()

expedited workは(アプリの1日あたりの実行クォータの範囲内で)すぐに実行され、実行中にアプリがバックグラウンドに移行しても短い猶予期間が与えられます。OutOfQuotaPolicy 引数は、アプリがその日のクォータを使い切った後に何が起こるかを決定します。RUN_AS_NON_EXPEDITED_WORK_REQUEST は例外をスローする代わりに通常のスケジューリングにフォールバックします。これは本当にユーザーが開始し、ユーザーに見える作業にのみ使ってください — ルーチンのバックグラウンド同期にこれを使うと、WorkManagerの存在意義であるバッテリーに優しいスケジューリングが台無しになります。

テスト: ほぼ全員が省略するステップ

WorkManagerは、workerが検証のために実行中のアプリを必要としないように、専用のテストアーティファクトを提供しています。ほとんど誰もそれを使いません。それが、コードレビューで入力データのバグを見つけるのと、ユーザーのバグレポートから見つけるのとの違いです。

@RunWith(AndroidJUnit4::class)
class ExportWorkerTest {

    @Before
    fun setup() {
        val config = Configuration.Builder()
            .setExecutor(SynchronousExecutor())
            .build()
        WorkManagerTestInitHelper.initializeTestWorkManager(
            ApplicationProvider.getApplicationContext(),
            config,
        )
    }

    @Test
    fun exportWorker_writesFile_onSuccess() {
        val request = OneTimeWorkRequestBuilder<ExportWorker>().build()
        val workManager = WorkManager.getInstance(
            ApplicationProvider.getApplicationContext(),
        )

        workManager.enqueue(request).result.get()
        val info = workManager.getWorkInfoById(request.id).get()

        assertThat(info.state).isEqualTo(WorkInfo.State.SUCCEEDED)
    }
}

SynchronousExecutor は、ジョブをバックグラウンドスレッドではなくインラインで実行させるため、テストは完了を待つためのsleepやlatchを必要としません。これは、チェイニングとリトライロジックが手動テストで特に隠しやすい2つのバグを捕捉します。例外を静かに飲み込みながらも success() を返してしまうworkerと、inputData から間違ったキーを読み取るチェーンのステップです。

実際に重要だったこと

WorkManager上で実行している3つのジョブを通して、うまくいったパターンは巧妙さではなく、一貫性でした。すべてのユニークなジョブに明示的に名前を付け、その ExistingWorkPolicy を意図的に選ぶこと、複数ステップのジョブをコールバックのネストではなくチェインすること、時間が実際に解決できる失敗だけをリトライすること、そして本番でチェーンを信頼する前に WorkManagerTestInitHelper のテストを書くこと。これらはどれもエキゾチックなAPIではありません。デフォルトのままにしておきやすいWorkManagerの部分であり、そのデフォルトは重要になるほど頻繁に間違っているのです。