この記事は Android DA ソフトウェア エンジニア、Isai Damier による Android Developers Blog の記事 "Working with Multiple JobServices" を元に翻訳・加筆したものです。詳しくは元記事をご覧ください。

複数の JobService を活用する


ユーザー エクスペリエンスを改善する努力が続けられる中、API レベル 26 で Android プラットフォームにバックグラウンド サービスに対する厳しい制限が導入されました。基本的に、アプリがフォアグラウンドで実行されている場合を除き、アプリのバックグラウンド サービスはシステムによって数分以内に停止されます。

このバックグラウンド サービスの制限の結果、JobScheduler のジョブがバックグラウンド タスクを実行する際のデファクト ソリューションになっています。サービスに詳しい方は、JobScheduler を簡単に使うことができます。ただし、そこにはいくつかの例外的なケースもあります。ここではその 1 つをご紹介します。

Android TV アプリを作っているところをイメージしてください。TV アプリではチャンネルが非常に重要です。アプリは、チャンネルに対して少なくとも 5 種類のバックグラウンド操作を行える必要があります。それは、チャンネルのパブリッシュ、チャンネルへのプログラムの追加、チャンネル ログのリモート サーバーへの送信、チャンネルのメタデータのアップデート、そしてチャンネルの削除です。Android 8.0(Oreo)より前では、この 5 つの操作をバックグラウンド サービスとして実装できました。しかし、API 26 以降では、どれを旧来のバックグラウンド Service にするか、どれを JobService にするかをよく考えた上で決めなければなりません。

TV アプリのケースでは、前述の 5 つの操作のうち、旧来のバックグラウンド サービスとして実装できるのはチャンネルのパブリッシュのみです。状況にもよりますが、チャンネルのパブリッシュには 3 つの手順が必要となります。まず、ユーザーが処理を開始するボタンを押します。次に、アプリがバックグラウンド操作を開始してパブリッシュを行います。そして最後に、サブスクリプションを確認する UI がユーザーに提示されます。おわかりのように、チャンネルのパブリッシュにはユーザーのインタラクション、つまり目に見える Activity が必要です。そのため、ChannelPublisherService はバックグラウンド部分を扱う IntentService にすることもできるかもしれません。ここで JobService を使うべきでない理由は、JobService を使うと実行時に遅延が生じる点にあります。通常、ユーザーのインタラクションにはアプリからの即時的なレスポンスが必要です。

一方、その他の 4 つの操作には JobService を使うべきです。この 4 つはすべて、アプリがバックグラウンド状態にあるときに実行されるからです。そのため、この 4 つの操作には、それぞれ ChannelProgramsJobServiceChannelLoggerJobServiceChannelMetadataJobServiceChannelDeletionJobService を使うべきです。

jobId の衝突を防止する


上記の 4 つの JobServiceChannel オブジェクトを扱うため、それぞれの channelIdjobId として使えると便利です。しかし、Android Framework 内での JobService の設計手法の関係で、そうすることはできなくなっています。次に示すのは、jobId についての公式説明です。

Application-provided id for this job. Subsequent calls to cancel, 
or jobs created with the same jobId, will update the pre-existing 
job with the same id. This ID must be unique across all clients 
of the same uid (not just the same package). You will want to 
make sure this is a stable id across app updates, so probably not 
based on a resource ID.

この説明に書かれているのは、たとえ 4 つの異なる Java オブジェクト(-JobService)を使っていたとしても、jobId に同じ channelId を使うことはできないということです。つまり、クラスレベルの名前空間を使うことはできません。

これは確かに大きな問題です。channelId を一連の jobId に関連付ける安定的で拡張可能な方法が必要になります。一番やってはいけないのは、jobId の衝突により、関係ないチャンネルの操作を互いに上書きしてしまうことです。jobId が Integer 型ではなく String 型であれば、この問題は簡単に解決できます。その場合、ChannelProgramsJobService jobId= "ChannelPrograms" + channelId を使い、ChannelLoggerJobServicejobId= "ChannelLogs" + channelId を使うなどの方法が考えられます。しかし、jobId は String 型ではなく Integer 型なので、ジョブに対して再利用可能な jobId を生成する適切な仕組みを考える必要があります。これには、次に示す JobIdManager のような仕組みを利用できます。

JobIdManager クラスは、アプリのニーズに応じて微調整することができます。今回の TV アプリにおける基本的な考え方は、Channel を扱うすべてのジョブに対して単一の channelId を使うというものです。理解を早めるため、まずはこのサンプル JobIdManager クラスのコードを見てみましょう。その後、説明を行います。
public class JobIdManager {

   public static final int JOB_TYPE_CHANNEL_PROGRAMS = 1;
   public static final int JOB_TYPE_CHANNEL_METADATA = 2;
   public static final int JOB_TYPE_CHANNEL_DELETION = 3;
   public static final int JOB_TYPE_CHANNEL_LOGGER = 4;

   public static final int JOB_TYPE_USER_PREFS = 11;
   public static final int JOB_TYPE_USER_BEHAVIOR = 21;

   @IntDef(value = {
           JOB_TYPE_CHANNEL_PROGRAMS,
           JOB_TYPE_CHANNEL_METADATA,
           JOB_TYPE_CHANNEL_DELETION,
           JOB_TYPE_CHANNEL_LOGGER,
           JOB_TYPE_USER_PREFS,
           JOB_TYPE_USER_BEHAVIOR
   })
   @Retention(RetentionPolicy.SOURCE)
   public @interface JobType {
   }

   //16-1 for short. Adjust per your needs
   private static final int JOB_TYPE_SHIFTS = 15;

   public static int getJobId(@JobType int jobType, int objectId) {
       if ( 0 < objectId && objectId < (1<< JOB_TYPE_SHIFTS) ) {
           return (jobType << JOB_TYPE_SHIFTS) + objectId;
       } else {
           String err = String.format("objectId %s must be between %s and %s",
                   objectId,0,(1<<JOB_TYPE_SHIFTS));
           throw new IllegalArgumentException(err);
       }
   }
}

おわかりのように、JobIdManager は単に接頭辞と channelId を組み合わせて jobId を作成しているだけです。しかし、このエレガントなシンプルさは、氷山の一角でしかありません。前提条件と以下のポイントについて考えてみましょう。

1 つ目のポイント: channelId と接頭辞を組み合わせても有効な Java の Integer 型の範囲内に収まるようにするために、channelId を Short 型に強制する必要があります。もちろん、厳密に言うなら、必ずしも Short 型である必要はありません。接頭辞と channelId を組み合わせてオーバーフローしない Integer 型を得られさえすればよいのです。しかし、健全なエンジニアリングにはマージンが欠かせません。そのため、どうしても他に選択肢がない場合以外は、Short 型を強制するとよいでしょう。実際に、リモート サーバー上に大きな ID を持つオブジェクトに対してこれを行うには、ローカル データベースやコンテンツ プロバイダでキーを定義し、そのキーを使って jobId を生成します。

2 つ目のポイント: アプリ全体で 1 つの JobIdManager クラスを使う必要があります。そのクラスで、アプリのすべてのジョブの jobId を生成します。ジョブが Channel を扱うのか User を扱うのか、それとも CatDog を扱うのかは関係ありません。サンプルの JobIdManager クラスは、この点に対処しています。すべての JOB_TYPEChannel 操作が関係しているわけではありません。ジョブタイプの 1 つはユーザー プリファレンスを扱っており、別の 1 つはユーザーの動作を扱っています。JobIdManager は、ジョブタイプごとに別の接頭辞を割り当てることによって、それらすべてに対応しています。

3 つ目のポイント: アプリ内の各 -JobService について、一意かつ final な JOB_TYPE_ 接頭辞が必要です。ここでも、網羅的な 1 対 1 の関係が存在する必要があります。

JobIdManager の使用


次に示す ChannelProgramsJobService のコード スニペットは、プロジェクトで JobIdManager を使用する方法の一例です。新しいジョブをスケジュールする必要がある場合は、常に jobId を生成する必要があります。その際に、JobIdManager.getJobId(...) を使用します。
import android.app.job.JobInfo;
import android.app.job.JobParameters;
import android.app.job.JobService;
import android.content.ComponentName;
import android.content.Context;
import android.os.PersistableBundle;

public class ChannelProgramsJobService extends JobService {
  
   private static final String CHANNEL_ID = "channelId";
   . . .

   public static void schedulePeriodicJob(Context context,
                                      final int channelId,
                                      String channelName,
                                      long intervalMillis,
                                      long flexMillis)
{
   JobInfo.Builder builder = scheduleJob(context, channelId);
   builder.setPeriodic(intervalMillis, flexMillis);

   JobScheduler scheduler = 
            (JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE);
   if (JobScheduler.RESULT_SUCCESS != scheduler.schedule(builder.build())) {
       //todo what? log to server as analytics maybe?
       Log.d(TAG, "could not schedule program updates for channel " + channelName);
   }
}

private static JobInfo.Builder scheduleJob(Context context,final int channelId){
   ComponentName componentName =
           new ComponentName(context, ChannelProgramsJobService.class);
   final int jobId = JobIdManager
             .getJobId(JobIdManager.JOB_TYPE_CHANNEL_PROGRAMS, channelId);
   PersistableBundle bundle = new PersistableBundle();
   bundle.putInt(CHANNEL_ID, channelId);
   JobInfo.Builder builder = new JobInfo.Builder(jobId, componentName);
   builder.setPersisted(true);
   builder.setExtras(bundle);
   builder.setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY);
   return builder;
}

   ...
}

脚注: 貴重なフィードバックを寄せてくれた Christopher Tate と Trevor Johns に感謝いたします


Reviewed by Yuichi Araki - Developer Relations Team