2018/10/23

Android: DownloadManagerのタスク管理と競合

タスクの状態

開発者はダウンローダへの要求をアクションとして表現します. アクションには ダウンロード削除 の2種類があり, ダウンローダはこれらをタスクとして処理していきます.
ExoPlayer downloaderはタスクの状態を2種類定義しています.
1つはTaskStateとして外部に公開される状態で下記の状態と遷移を持ちます.

状態リスト:

State Description
queued 開始待機状態
started 開始済み状態
completed 完了状態
canceled キャンセル状態
failed 失敗状態

状態遷移:

もう1つは外部に公開されない, DownloadManagerの内部管理用の状態です.
主に, “キャンセル中”や”停止中”といった状態遷移中の状態が定義されています.

内部状態リスト:

State Description
queued 開始待機状態
started 開始済み状態
completed 完了状態
canceled キャンセル状態
failed 失敗状態
queued_canceling 開始待機キャンセル中状態
started_canceling 実行キャンセル中状態
started_stopping 実行停止中状態

内部状態遷移:

ExoPlayer downloaderを使う上では前者の公開用状態を把握しておけば十分なのですが、ダウンローダの振る舞いを把握するには後者の内部状態を把握しておいた方が理解が進みます。

DownloadManagerのタスク管理と競合

DownloadManagerはタスクの状態変更を DownloadManager.onTaskStateChange 検知します.
ここでタスクの状態がアクティブではない場合, タスクの開始が試みられます.
タスクが下記の条件を満たす場合にはアクティブであると判断されます.

    /** Returns whether the task is started. */
    public boolean isActive() {
      return currentState == STATE_QUEUED_CANCELING
          || currentState == STATE_STARTED
          || currentState == STATE_STARTED_STOPPING
          || currentState == STATE_STARTED_CANCELING;
    }

要するにタスクが何かしらのアクション中であればアクティブと判断されます.

DownloadManagerは次の条件が全て満たされていることを確認してタスクを開始します.

  1. タスクがまだ開始されていないこと(taskstate == queued)
  2. 既にあるタスクと競合しないこと
  3. ダウンロードタスクの場合, 先行するダウンロードタスクが保留中ではない. かつ, アクティブダウンロード数の上限に達していないこと

1はわかりやすいですね. 既に開始済みのタスクを再び開始することはできないということです.
DownloadManagerは, タスクの状態に関わらずタスクリストの先頭から順番に開始を試みます. 既に開始済みのタスクはここで除外されます.

3の”アクティブダウンロード数の上限”はDownloadManagerのコンストラクタ引数 maxSimultaneousDownloads が参照されます.
また, これはダウンロードタスクに課せられる条件で, 削除タスクはこの条件に該当しません.

2は少し複雑です. DownloadManagerは次の2点をチェックしてタスク(アクション)の競合を検知します.

  1. 同じコンテンツに対するアクションか
  2. いずれかのアクションが削除アクションであるか

DownloadManagerはタスクリストの先頭から順番に開始を試みる過程で, 同タスクリスト内の他タスクと競合していないかを検知するためにタスク同士を比較します.
タスクが扱うコンテンツの同一性は DownloadAction.isSameMedia を使って判定され, タスクに紐づくアクションが持つ uri が同じかどうかで判断されます.

  /** Returns whether this is an action for the same media as the {@code other}. */
  public boolean isSameMedia(DownloadAction other) {
    return uri.equals(other.uri);
  }

同じコンテンツに対するアクションがタスクリスト内に複数見つかった場合, 比較元あるいは比較先のタスクのいずれかが削除アクションである場合は “競合した” と判定されます.
例えば, とあるコンテンツAをダウンロード中に, 同コンテンツに対して削除アクションを投げるとこの状態になります.

タスクが競合すると, 比較元が削除アクションであれば比較対象のタスクがキャンセルされ, 比較元の削除タスクもスキップされます(キャンセルはされません).
比較対象のタスクが削除アクションであれば比較元のタスクはスキップされます(キャンセルはされません).

タスクがキャンセルされるとタスクの状態が変化するので DownloadManager.onTaskStateChange が呼ばれます. DownloadManager.onTaskStateChange ではタスクの状態がアクティブではない場合にタスクの開始を試みるので, ここで再びタスクの開始が試みられます. スキップされた削除タスクはアクティブにはなっておらず, また競合していたタスクはキャンセルされているので競合は発生しなくなります.

これによって, とあるコンテンツAをダウンロード中に, 同コンテンツに対して削除アクションを投げるとダウンロード処理はキャンセルされて, ダウンロードのキャンセル処理が終了した後に削除処理が行われることになります.

ダウンローダの開始と停止

DownloadServiceが起動されるとRequirementsHelperを登録してデバイス状態の監視を始めます.
デバイスの状態がダウンロード開始条件を満たした場合, いよいよダウンロード処理が開始されます.

DownloadManagerはダウンローダ停止状態を示す downloadsStopped フィールドを内部に持っています.
このフィールドが false の時, ダウンロードアクション/タスクが追加されたとしても新しくダウンロードが開始されることはありません.
ダウンローダを開始するには DownloadManager.startDownloads を呼び出して, このフィールドを true にする必要があります.

ダウンローダの状態はダウンローダ停止状態がデフォルトですが, 開発者が明示的に DownloadManager.startDownloads を呼び出してダウンローダを開始する必要はありません.
その理由として, まずDownloadServiceにダウンロードアクションを登録すると, タスクの開始を試みる処理が実行されます.
しかし, このタイミングではダウンローダが停止状態なので, ダウンロードタスクは一旦スキップされます.
スキップされたタスクがどのようにして実行されるのかというと, DownloadServiceがアクション/タスクを追加した後, RequirementsHelperを登録してデバイス状態の監視を始めます.
デバイス状態がダウンロード開始の条件を満たした時, DownloadServiceはDownloadManager.startDownloadsでダウンローダを開始状態にします.
ダウンローダの開始時にはタスクの実行を試みるようになっているので, ここでスキップされたタスクが実行されることになります.

“ダウンロードの開始/停止条件はRequirementsで表現される”と過去の投稿で言いましたが, 厳密には Requirementsはダウンローダの開始条件 になります.
ExoPlayerのダウンローダは開始済みであれば全てのタスクを処理しようとします.
このダウンローダは全てのタスクが消化されたとしても, Requirementsの条件が満たされている限り停止しません.
開発者が手動でDownloadManager.startDownload/stopDownloadを呼び出すときは, Requirementsとの整合性が崩れる可能性がある点に注意しなければいけません.

ちなみに, downloadsStopped はダウンロードアクションに対するものであり, 削除アクションはダウンローダの開始状態に依存しないため, このフィールドが false であっても削除アクションは実行されます.
つまり, Requirementsを満たしていない状態でもコンテンツの削除は可能です.

2018/10/19

Android: ExoPlayer DownloadManager, DownloadService

ExoPlyerのDownloadManager, DownloadServiceを調べた時のメモ.

中断されたアクションを読み込むタイミング

保存されたActionFileの読み込みタイミングはDownloadManagerを初期化したタイミングとなります.

プロセスの強制終了などでActionを完遂できなかった場合, 保存されたActionFileをプロセス再開後に読み込んでダウンロード処理を再開する必要があります.
保存されたActionFileは DownloadManager.loadActions によってバックグラウンドスレッド上で読み込まれ, これはDownloadManagerのコンストラクタで実行されます.

コンテンツの削除はバックグラウンド?

コンテンツを削除するにはremove flagをtrueにしたアクションを発行します.
アクションはDownloadServiceで実行されるので, フォアグラウンドサービスとして実行することが可能です.
削除中の通知も DownloadService.getForegroundNotification で返すNotification objectをカスタマイズすることができます.
また, 通知の雛形として DownloadNotificationUtil.buildProgressNotification が用意されています.
buildProgressNotification は引数のタスクステートに応じて通知の表示内容を変えるため, ダウンロード中通知や削除中通知もこのメソッドで生成できます.

ダウンロード進捗率を取得する

DownloadManager.Listenerの onTaskStateChanged コールバック引数のTaskStateから間近のダウンロード進捗率が取得できます.
TaskState.downloadPercentage がダウンロード進捗率を格納したフィールドです.
ダウンロード進捗率が未定/不明 あるいは 削除タスクである場合は com.google.android.exoplayer2.C#PERCENTAGE_UNSET がセットされます.
DownloadServiceでは, このコールバックを受けて通知の進捗率を更新するようになっています.

タスクの実行順序とダウンロードのキャンセル

タスクはDownloadManagerによってArrayListで管理されており, 新しいタスクはタスクキュー(タスクリスト)の最後尾に追加され, 先頭から順に実行されます.
DownloadManager.handleAction は新しいダウンロード/削除アクションアクションからタスクを生成してタスクキューの最後尾に追加します.
新しく削除アクションがリクエストされた場合, 既に同じメディアファイルのダウンロードタスクがタスクキューに存在するなら, そのダウンロードを即座にキャンセルします.
つまり, ダウンロード中やダウンロードリクエストをキューイングした後にこれをキャンセルしたい場合は削除アクションを投げるとキャンセルできます.

ダウンロードの開始条件

サービスによっては”従量制ネットワーク接続時にはダウンロードしたくない”といった要件があるかもしれません.
あるいは, “NWが瞬断されてダウンロード中断されたけれど, NW接続が回復したら自動再開したい” 要件があるかもしれません.
ExoPlayer Downloaderではデバイスの状態を監視して, こうした要件に応える機能があります.

RequirementsHelper はデバイスの状態を監視し, 特定の条件を満たした場合にダウンロードを開始/再開するヘルパークラスです. ここで指定できる”特定の条件” は Requirements クラスで表現され, Requirementsに指定できる条件は次の通りです.

  1. ネットワーク種別 ( NW接続済み, 従量制NWに接続済み, ローミング中, etc. )
  2. 充電中かどうか
  3. アイドル状態かどうか

また, JobSchedulerによる監視もサポートされています.
JobSchedulerを使用する場合は, Requirementsの情報がJobInfoに変換されてスケジューリングされます.

API Lv.によっては判定できるNW種別の種類や, アイドル状態と判定する条件に差異があるので, JobSchedulerRequirementsのコードを確認した方がよいです.

ダウンロードの開始プロセス

ダウンロードを開始するにはDownloadManagerを初期化して, DownloadServiceを起動し, タスク(アクション)を追加する必要があります.
DownloadServiceは起動されるとRequirementsHelperを起動してデバイス状態を監視し始めます.
デバイスの状態がダウンロード開始条件を満たした場合, いよいよダウンロード処理が開始されます.

RequirementsHelperとスケジューラの生存区間

アプリのプロセスが生きている間はRequirementsHelperが動的ブロードキャストレシーバーを使ってデバイス状態を監視し, ダウンロードを開始/再開させます.
デバイスの監視はDownloadServiceによって開始されますが, ダウンロードが中断されてDownloadServiceが停止してもこの監視は続きます.
これは, デバイスの状態を監視するRequirementsHelperをDownloadServiceのstaticフィールドで保持しているためです.

DownloadServiceがgetSchedulerでスケジューラを指定している場合は, スケジューラでもデバイス状態が監視されます.
スケジューラはAndroid標準のJobSchedulerを使用することができ, これによってアプリのプロセスが停止している場合にもダウンロードを開始/再開させることが可能になります.
スケジューラはダウンロード開始条件が満たされていないと判断された場合にスケジューリングされます.

ダウンロード開始条件が満たされた場合, RequirementsHelperによる監視が続いている(アプリのプロセスが生きている)状態であればRequirementsHelperがダウンロードを開始/再開させて, スケジューラのスケジューリングをキャンセルします.
RequiermentsHelperによる監視がされていない(アプリのプロセスが停止している)状態であればスケジューラによる開始/再開が行われます.

スケジューラだけでデバイス監視しないのは, RequirementsHelper(staticフィールドと動的ブロードキャストレシーバー)を使った方がデバイス状態の検知からダウンロードの開始/再開までを素早く行えるというメリットがあります.

RequirementsとSchedulerとDownloadService

RequirementsとSchedulerは, DownloadServiceのgetSchedulergetRequirementsをオーバーライドして指定します.

  @Override
  protected Requirements getRequirements() {
    return new Requirements(Requirements.NETWORK_TYPE_ANY, false, false);
  }

  @Override
  protected PlatformScheduler getScheduler() {
    return Util.SDK_INT >= 21 ? new PlatformScheduler(this, JOB_ID) : null;
  }

JobScheduler を使ったデバイスの監視を実現するため PlatformScheduler クラスが用意されています. PlatformSchedulerを使うにはAndroidManifest.xmlに次の定義を追加します.

<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>

<service android:name="com.google.android.exoplayer2.util.scheduler.PlatformScheduler$PlatformSchedulerService"
    android:permission="android.permission.BIND_JOB_SERVICE"
    android:exported="true"/>

あるいはFirebaseJobDispatcherを使ったスケジューラ JobDispatcherScheduler も用意されています. JobDispatcherSchedulerを使うにはAndroidManifest.xmlに次の定義を追加します.

  <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>

  <service
      android:name="com.google.android.exoplayer2.ext.jobdispatcher.JobDispatcherScheduler$JobDispatcherSchedulerService"
      android:exported="false">
    <intent-filter>
      <action android:name="com.firebase.jobdispatcher.ACTION_EXECUTE"/>
    </intent-filter>
  </service>