[Android]Workでバックグラウンド処理を定期実行する

投稿者:

イントロダクション

Androidアプリを開発していると、ユーザがアプリを閉じても何らかの処理をバックグラウンドで定期実行させたくなる場面が出てくる。具体的にはサーバーからデータを受信したり、逆に送信したり、不要なデータを削除したり、といったケースである。

やり方としてはいくつかあるので箇条書きにすると

  1. Serviceを起動してそこからTimerクラスで定期実行
  2. AlermManegerを使う(android4.0以降)
  3. JobSchedulerを使う(Android5.0以降)
  4. Workで定期実行(Android4.0以降)

1は初期のAndroidでは定番の方法だったようだが、Androidのバージョンが上がるたびにバッテリーとメモリの管理が厳しくなり、遅延が生じたり、システムがServiceを強制停止させたり、と動作が安定しなくなった。筆者も簡単なサンプルを作って動作を検証してみたが、アプリ起動中は意図した通りに動くけど、アプリを終了させるとServiceも何かのタイミングで落ちてしまった。

AlarmManegerは一定間隔でプログラムを実行するだけでなく、「何時何分に実行」と時刻を指定することができる。個人的には時計アプリを作った時に使用したことがある。スケジュールで起動→メインの処理を実行→再スケジュール、という処理を延々と繰り返すわけだ。これもAndroidにバッテリー消費を抑えるDozeモードが導入されてから実行のタイミングが不安定になった(とはいえ、なにがなんでも指定した時刻に実行するよう指定することもできる)。

JobSchedulerは比較的長い間隔で定期実行するのに向いているサービス。というのも実行間隔を15分未満には設定できないのでそれより短いスパンで実行したい場合には使えない。ネットワークの状態やバッテリーの状態を起動条件に指定できるので、自前でそれらのチェックをしなくて済むのは助かる。

Workは、JobSchedulerに似た仕組みで実行間隔を15分未満にはできない。起動条件も指定できる。使えるのがAndroid4.0以降と互換性が高いが、それもそのはずで、内部的にはAndroid5.0以降ではJobSchedulerを、Android4.0以上5.0未満ではAlarmManagerを使って機能を実現している。

この記事ではWorkを使って処理をバックグラウンドで定期実行する方法を記す。

Workの概要

事前準備

アプリbuild.gradleのdependenciesに次の一文を追加する。

プログラムをJavaで記述する場合

dependencies {
    
    implementation "androidx.work:work-runtime:2.4.0"

}

プログラムをkotlinで記述する場合

dependencies {
    
    implementation "androidx.work:work-runtime-ktx:2.4.0"

}

バージョン番号はこの記事を書いている時点での最新バージョンなので適時変更してください。

Workのオブジェクト

クラス説明
WorkManagerWorkの実行状況を監視する
Workerバックグラウンド処理の定義
WorkRequest実行条件やタイミングを記録。単体では使わず次の継承クラスを使う。
OneTimeWorkRequest一度だけ処理を実行するWorkRequest
PeriodicWorkRequest定期的に処理を実行するWorkRequest
Constraints実行条件の定義
DataWorkerに渡すデータを保存

処理の定義

バックグラウンドでどんな処理をさせるかはWorkerクラスを継承したクラスを作成し、doWork()メソッドをオーバーライドして、そこに処理を記述する。
doWork()の戻り値は以下の通り

戻り値説明
Result.success()処理成功
Result.failure()処理失敗
Result.retry()処理再実行
doWrok()メソッドの戻り値

制約(起動条件)

Workerを実行するのに、必ずネットワークに繋がっていなければならないとか、デバイスがアイドル状態である(ユーザが操作していない)とか、起動するための前提条件があるときはConstraintクラスにフラグをセットしておく。

よく使うと思われる条件は次の通り

Constraintのメソッド説明
setRequiredNetworkTypeネットワークの状態
setRequiresBatteryNotLowバッテリー残量が少なくないか
setRequiresCharging充電中であるかどうか
setRequiresDeviceIdleデバイスがアイドル状態か
setRequiresStorageNotLowストレージの残り容量が少なくないか

制約をセットしておけば、自前でデバイスの状態をチェックしなくてもよいため、コーディング量が減って可読性が高くなるので積極的に活用するべきだ。

データ

Workerは基本的にアクティビティなどのアプリ本体とは無関係に、システムがインスタンスを作成するのでコンストラクタ引数でデータを渡すことはできない。データを渡したい場合はDataクラスのインスタンスをWorkRequestにsetInputdata()メソッドで登録しておき、Workerが実行されたときにgetInputdate()メソッドで取得する。

スケジューリング

Workerをいつ実行するかはWorkRequestクラスのインスタンスを作成するときに指定する。
一回だけ実行する場合はOneTimeWorkRequestクラス、繰り返し実行する場合はPeriodicWorkRequestクラスを使用する。
制約やデータがあればWorkRequestにセットしてWrokManagerクラスのenqueue()メソッドでキューに登録してスケジューリングする。

WorkRequestのキャンセル

キューに登録されたWorkRequestはIDやタグを使ってキャンセルすることができる。IDはWorkRequestクラスのgetId()メソッドで取得でき、タグはあらかじめ任意の文字列をaddTag()メソッドで登録しておく。

WorkerManagerのメソッド説明
cancelWorkById(uuid)IDを指定してキャンセル
cancelWorkByTag(tag)タグを指定してキャンセル

複数のWorkRequestに同じタグが登録されていたらその全てがキャンセルされる。

サンプルプログラム

以上のことを踏まえてWorkerでバックグラウンド処理を実行するアプリのサンプルを作ってみよう。

簡単な仕様

  • アクティビティ上のボタンでバックグラウンド処理を起動。
  • バックグラウンド処理では20分間隔でログを出力する
  • アクティビティ上のボタンでバックグラウンド処理を停止。
レイアウト
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <Button
        android:id="@+id/button1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerInParent="true"
        android:text="Worker Start"/>

    <Button
        android:id="@+id/button2"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerHorizontal="true"
        android:layout_below="@id/button1"
        android:text="Worker Stop"/>
</RelativeLayout>
JAVAファイル
MainActivity.java
public class MainActivity extends AppCompatActivity implements View.OnClickListener {
    //定数
    final private static String LOG_TAG = "worker_sample";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        Button button1 = findViewById(R.id.button1);
        button1.setOnClickListener(this);
        Button button2 = findViewById(R.id.button2);
        button2.setOnClickListener(this);
    }

    /* Workerを起動 */
    private void startWrok (int num) {
        //Workerに渡すデータを作成
        Data data =  new Data.Builder()
                .putInt("num", num)
                .build();

        //制約(起動条件)を設定
        //  このサンプルでは制約なしでもいいが
        //  とりあえずバッテリー容量が少ないときはNGとした
        Constraints constraints = new Constraints.Builder()
                .setRequiresBatteryNotLow(true)
                .build();

        //WorkRequestの作成
        // PeriodicWorkRequestを使って20分毎に定期実行
        PeriodicWorkRequest request = new PeriodicWorkRequest.Builder(
                SampleWorker.class, 20, TimeUnit.MINUTES)
                .setConstraints(constraints)
                .setInputData(data)
                .addTag(SampleWorker.WORK_TAG)
                .build();

        //キューに登録(スケジューリング)
        WorkManager manager = WorkManager.getInstance(this);
        manager.enqueue(request);

        Log.d(LOG_TAG, "Worker scheduled");

    }

    /* Workerを停止 */
    private void stopWork() {
        WorkManager manager = WorkManager.getInstance(this);
        manager.cancelAllWorkByTag(SampleWorker.WORK_TAG);
        Log.d(LOG_TAG, "Worker stopped");
    }


    @Override
    public void onClick(View v) {
        switch (v.getId()) {
            case R.id.button1:
                startWrok(2);   //Workerを起動
                break;
            case R.id.button2:
                stopWork();     //Workerのキャンセル
                break;
            default:
                break;
        }
    }
}
SampleWorker,java
public class SampleWorker extends Worker {
    //定数
    final private static String LOG_TAG = "worker_sample";
    final public static String WORK_TAG = "SampleWorkerTAG";

    //コンストラクタ
    public SampleWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
        super(context, workerParams);
    }

    @NonNull
    @Override
    public Result doWork() {
        //データを取得
        Data data = getInputData();
        int num = data.getInt("num", 1);

        //出力テキストの作成
        //numの個数だけ"Beautiful"というテキストを出力
        StringBuilder sb = new StringBuilder();
        for (int i=0; i<num; i++) {
            sb.append("Beautiful ");
        }
        sb.append(getId().toString());
        String output = sb.toString().trim();

        //出力
        Log.d(LOG_TAG,output);  //ログに出力

        return Result.success();
    }
}

上のサンプルアプリを起動すると次のような画面が表示される

「WORKER START」ボタンをクリックするとWorkerがスケジューリングされ、20分毎にデバッグ用ログが出力される。「WORKER STOP」ボタンをクリックするとスケジューリングされているWorkerがキャンセルされる。ログはアクティビティ上には表示されないため何も動いていないように見えるが裏ではログが吐き出されている。

実行結果は次のようになった

13:37:15.860 5976-5976/net.fineblue206.workersample D/worker_sample: Worker scheduled
13:37:15.991 5976-6140/net.fineblue206.workersample D/worker_sample: Beautiful Beautiful 9077cb01-604f-4998-9009-a7b3067b2b61
13:57:16.072 5976-6310/net.fineblue206.workersample D/worker_sample: Beautiful Beautiful 9077cb01-604f-4998-9009-a7b3067b2b61
14:17:16.198 5976-6398/net.fineblue206.workersample D/worker_sample: Beautiful Beautiful 9077cb01-604f-4998-9009-a7b3067b2b61
14:21:22.545 5976-5976/net.fineblue206.workersample D/worker_sample: Worker stopped

スケジューリングされて即最初の処理が実行されて、その後20分毎に実行して、最後にキャンセルしているのがわかる。定期処理はアプリが終了しても実行されるので、それを望まないときは適切なタイミングでキャンセルしておく。

また、「WORKER START」を続けてクリックしたときは、バックグラウンド処理が複数走ってしまう。これを利用して疑似的に5分間隔とか15分より短いスパンで実行させることもできるが、実行間隔はあくまでも目安なので、デバイスの状態次第で遅延することもある。なのできっちり5分間隔にできるとは期待しないほうがいい。いろいろ実験してみたところ、デバイスがスリープ状態になると、定期実行は完全に止まり、スリープ解除された時点でキューに溜まっていた処理が一気に実行されるようである。すなわち2件バックグラウンド処理が登録されていたら、時間差で起動していたとしてスリープから復帰した時点で2つ同時に処理が走ってしまう。同じ処理をするプロセスが同時に動くとアプリクラッシュを引き起こしかねないので、重複して起動しないようチェックする必要がありそうだ。

というわけで、Workを使ってバックグラウンドで処理を実行する方法を紹介してみた。

重複起動の問題については別記事にするつもりだ。

参考資料
https://developer.android.com/topic/libraries/architecture/workmanager?hl=ja

Google公式ドキュメント

3件のコメント

  1. PeriodicWorkRequest request = new PeriodicWorkRequest.Builder( SampleWorker.class, 20, TimeUnit.MINUTES)
    の「SampleWorker.class」でエラーが出ます。
    デバックでは、実行可能なプログラムがありませんと表示されますが、何か対処方法はありませんでしょうか。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください