Android
WorkManager

I/O 2018 Codelabs で WorkManager を学習したメモ


注意

本記事は WorkManagerのバージョン1.0.0-alpha01をもとに執筆されました。

この注記を書いた時点でのバージョンは1.0.0-alpha03ですので、新機能や破壊的変更がある可能性があります。


はじめに

Google I/O 2018ではCodelabsというのがあり、実際にコードを書いて新登場の技術を試せて、しかも現地ではチューター(Googler)から助言等をもらうこともできます。

I/OのCodelabsはイベント終了後もオンラインで挑戦することができます。今回 DroidKaigi Codelabs Tour なるイベントがあり、そのライブ配信を見ながらWorkManagerのCodelabsをやってみたので、どんな知見を得られたかを書き残しておきます。

めざすところとしては、「将来自分が業務で必要になったときに見返して思い出せるようにする」という具合です。


WorkManagerとはなにか

Codelabsの1ページ目より...

Android Jetpack のひとつであり、適宜実行されたり実行保証が必要なバックグラウンドタスクを行う Architecture Component です。

WorkManagerはシンプルで、しかし"驚くほど柔軟性の高い"ライブラリであり、多くの利点があります。


  • 1回限りの非同期処理にも、定期的なタスクにも対応

  • ネットワーク状況、ストレージ容量、充電中かどうかといった「制約(Constraints)」に対応

  • Work1 リクエストの複雑なチェーンに対応(並列作業の実行も)

  • Workの出力結果を次のWorkの入力として使える

  • API 14以上から対応(JobScheduler, FirebaseJobDispatcher, AlarmManager をAPIレベルやPlayServiceの有無などによって適切に使い分ける)

  • (バッテリー持ちや負荷といった)システムヘルスのベストプラクティスに従った実装

  • LiveDataを用いて、UI上にWorkのリクエストの状態を表示できる

使いどころとしては、ユーザーがアプリから離れていても完了して欲しいようなタスクに適しています。例えば、「ログの送信」、「画像にフィルタを適用して保存する」、「端末にあるデータとクラウド上のデータで定期的に同期をとる」。

ただし、すべての処理がWorkManagerにマッチしているわけではなく、メインスレッドをブロックしてしまうタスクすべてがWorkManagerに相応しいというわけではありません(いまRetrofitで書いているところをWorkManagerで書き直すのは違うし用途ともマッチしてないよね、ということ)。

使いどころに関する詳細は、"Guide to background processing"に記載されています(比較対象としてThread-poolやForeground Serviceが挙げられています)。


導入方法

(Codelabs No.3)

def archWork = "1.0.0-alpha01" // 執筆時点でのバージョン

dependencies {
// Other dependencies
implementation "android.arch.work:work-runtime:$archWork"
}

./gradlew app:dependencies で見れる依存関係グラフで、LifecycleとRoomに依存していることが分かります。

+--- android.arch.work:work-runtime:1.0.0-alpha01

| +--- android.arch.lifecycle:extensions:1.1.0
| \--- android.arch.persistence.room:runtime:1.0.0

Codelabsではガン無視されているんですが、以下のライブラリも提供されています。適宜入れていく感じです。

// optional - Firebase JobDispatcher support

implementation "android.arch.work:work-firebase:$work_version"

// optional - Test helpers
androidTestImplementation "android.arch.work:work-testing:$work_version"

// ktx
implementation "android.arch.work:work-runtime-ktx:$work_version"


基本的な構成要素

(Codelabs No.4 前半)



  • Worker


    • 実際にバックグラウンドで処理されるロジックを実装する

    • Workerを継承して、doWork()メソッドをoverrideする




  • WorkRequest


    • 何らかのWorkのリクエストを表す

    • Workerを使ってWorkRequestを作り、WorkManagerに渡すことで実行キューに積まれる


    • Constraints を指定することでWorkerがいつ実行されるかを指定できる




  • WorkManager


    • WorkRequestをスケジュールして実行する

    • 指定されたConstraintsを満たしつつ、負荷分散するようにWorkRequestをスケジュールする




基本的な実装例

(Codelabs No.4 後半)

※説明の為に略してますが、実際のCodelabsではもっとちゃんとしたものが順を追ってつくれます

まずWorkerを継承したクラスをつくります

import androidx.work.Worker

class HeavyWorker : Worker() {
override fun doWork(): WorkerResult {
val success = Utils.runHeavyTaskSync(applicationContext)

return if (success) {
WorkerResult.SUCCESS
} else {
WorkerResult.FAILURE
}
}
}

つぎに、WorkManagerを取得します(CodelabsではViewModelで取得してました)

private val workManager = WorkManager.getInstance()

さいごに、Workerを継承したクラスを与えWorkRequestをつくり、WorkManagerにenqueueします

workManager.enqueue(OneTimeWorkRequest.from(HeavyWorker::class.java)


Workerに値を与える

(Codelabs No.5)

見ての通りWorkRequestにWorkerのインスタンスではなくクラスを渡している為、引数を渡す仕組みがないように見えます。

しかしそこはWorkRequest側からDataを渡す形で、ちゃんと存在しています。IntentのExtraみたいなノリだなと思いました。

「これは(key-valueをもってWorkerに値を渡す為の)軽量コンテナなので、データストアとして見なすべきではない」などとJavaDocに書かれています。実際、MAX_DATA_BYTES以上のデータを入れるとException吐くらしいです(試してない)。

val req = OneTimeWorkRequest.Builder(HeavyWorker::class.java)

.setInputData(
Data.Builder().putString("video_uri", videoUri.toString()).build()
)
.build()

workManager.enqueue(req)

IntentのExtraみたいなノリなので値を受け取るほうもそんな感じで受け取れます

class HeavyWorker : Worker() {

override fun doWork(): WorkerResult {
// ↓ getInputData().getString... と同義
val videoUri = inputData.getString("video_uri", null) ?: run {
throw IllegalArgumentException()
}
// ↑

val success = Utils.runHeavyTaskSync(applicationContext, videoUri)

return if (success) {
WorkerResult.SUCCESS
} else {
WorkerResult.FAILURE
}
}
}


Workを繋ぐ

(Codelabs No.6)

ここまでは「ほーん」って感じでCodelabsを進めてたのですが、

ここからがWorkManagerの真骨頂です。

Worker同士を繋ぐことが出来ます。

具体的には、「tempファイルの削除」「動画の永続化」「重い加工処理」のようなことがらそれぞれをWorkerで実装し、それらを繋いでWorkerの流れをWorkRequestで表現することができます。

これまで同様のことを実現するという発想はなかったはずで、おおよそJobDispatcherやServiceに実現したい処理をすべて記述する必要があったはずです。

加えて、何らかの処理は複数繰り返すことでマルチパス処理させることも出来ますし、そのようなWorkRequestを作成することも出来ます。

というわけでやってみます。

まず「tempファイルの削除」に相当するWorkerを作成。

import androidx.work.Worker

import timber.log.Timber

class CleanupWorker : Worker() {
override fun doWork(): WorkerResult {
try {
// something cleanup logic
return WorkerResult.SUCCESS
} catch (e: Throwable) {
Timber.e(e, "Failed to cleanup temp space")
return WorkerResult.FAILURE
}
}
}

次に「重い加工処理」つまり HeavyWorker の結果を出力する部分を加筆。

class HeavyWorker : Worker() {

override fun doWork(): WorkerResult {
val videoUri = inputData.getString("video_uri", null) ?: run {
throw IllegalArgumentException()
}

val resultUri: String? = Utils.runHeavyTaskSync(applicationContext, videoUri)

return if (resultUri != null) {
// ↓ setOutputData(...) と同義
outputData = Data.Builder().putString("video_uri", resultUri.toString()).build()
// ↑
WorkerResult.SUCCESS
} else {
WorkerResult.FAILURE
}
}
}

そして「動画の永続化」の部分を作成します。inputDataを使ってUriを受け取るのを忘れないように。

class SaveToFileWorker : Worker() {

override fun doWork(): WorkerResult {
try {
val videoUri = inputData.getString("video_uri", null) ?: run {
throw IllegalArgumentException()
}

// something save logic

return WorkerResult.SUCCESS
} catch (e: Throwable) {
Timber.e(e, "Failed to save file")
return WorkerResult.FAILURE
}
}
}

さてお待ちかねのChain作成です。

WorkManager#beginWith(OneTimeWorkRequest)からWorkContinuationを得ます

var continuation = workManager.beginWith(OneTimeWorkRequest.from(CleanupWorker::class.java))

for (i in 0..4) {
val workerReqBuilder = OneTimeWorkRequest.Builder(HeavyWorker::class.java)

if (i == 0) {
workerReqBuilder.setInputData(
Data.Builder().putString("video_uri", videoUri.toString()).build()
)
}

continuation = continuation.then(workerReqBuilder.build())
}

continuation = continuation.then(
OneTimeWorkRequest.Builder(SaveToFileWorker::class.java).build()
)

continuation.enqueue()

おもしろポイントとしては SaveToFileWorker の WorkRequestには setInputData してないにもかかわらず、ちゃんとSaveToFileWorkerのgetInputDataは機能していることです。

つまりWorkManagerが適切に値を横流ししてくれているということです。

あと、"video_uri"で引数を与えるわけですが、チェーンの頭(この場合CleanupWorkerのWorkRequest)から値を与えなくても良いというのが、個人的に興味深かったです。

注意点は、「HeavyWorkerのWorkerRequestの最初のひとつだけに値をセットする。他のWorkerRequestにはセットしない」ってことです。

ちなみに(今回の件ではフィットしないのでアレですが)チェーンで書くことも出来ます。


Workの重複実行を防ぐ

(Codelabs No.7)

例えば、ローカルデータとクライドデータの同期を取るWork Chainがあって、新しいChainを実行する前に(今行っている)データの同期は終わらせておきたい、というニーズはわりとありがちだと思います。

workManager.beginWithworkManager.beginUniqueWith にすると実現できます。

var continuation = workManager.beginUniqueWork(

"unique_work_id",
ExistingWorkPolicy.REPLACE,
OneTimeWorkRequest.from(CleanupWorker::class.java)
)

第1引数 uniqueWorkName でWorkChainを一意に特定するキーとします。

第2引数の ExistingWorkPolicyREPLACE KEEP APPEND があります(それぞれ効能はJavaDocに書いてあります)。


Work Statusの表示

(Codelabs No.8, 9)

説明する前にまず実装を。

continuation = continuation.then(

OneTimeWorkRequest.Builder(SaveToFileWorker::class.java)
.addTag("tag_saveToFile") // ←
.build()
)

こうして、以下のようにすると WorkStatus を取得できます。

private val savedWorkStatus: LiveData<List<WorkStatus>> = workManager.getStatusesByTag("tag_saveToFile")

...
savedWorkStatus.observe(this, Observer { workStatuses ->
if (workStatuses == null || workStatuses.isEmpty()) return@Observer

val workStatus = workStatuses.first()

Timber.d("Status: $workStatus isFinished: ${workStatus.state.isFinished}")
})

WorkStatusはWorkRequestの現在の状態を表すデータです。

WorkStatus#getState() でWorkRequestの状態を示す State を得られます。

BLOCKED, CANCELLED, ENQUEUED, FAILED, RUNNING, SUCCEEDED という状態がありますが詳しくはJavaDocに任せます。

また、WorkRequestが無事に終了した場合、getOutputData() でデータを回収することも出来ます。

これを活用することで、Workerが実行中の時に画面上にプログレスを出したり、結果を表示したりすることが出来るようになります。


Worker のキャンセル

(Codelabs No.10)

mWorkManager.cancelUniqueWork("unique_work_id")

特に言うことはないんですが、beginUniqueWork()で指定したIDと同じIDを渡すとWork Chain全体をキャンセルさせることが出来ます。


Workの制約(Constraints)

(Codelabs No.11)

ここまででもWorkManagerは魅力的ですが、Constraintsでさらによくなっている感があります。

ネットワークに繋がっているかどうかをConnectivityManagerを使ってやるのは調べるのも憂鬱な気持ちになりますが、そのあたりもやってくれるようです。

val constraints = Constraints.Builder()

.setRequiredNetworkType(NetworkType.UNMETERED)
.setRequiresBatteryNotLow(true)
.setRequiresCharging(true)
.setRequiresStorageNotLow(true)
.setRequiresDeviceIdle(true) // api >= 23
.build()

continuation = continuation.then(
OneTimeWorkRequest.Builder(SaveToFileWorker::class.java)
.setConstraints(constraints) // ←
.addTag("tag_saveToFile")
.build()
)

実際はConstraintsの条件は実際のニーズに合わせて調整する必要があるかと思います。

例えば電力を消費する動画加工系はバッテリー残量が欲しいところ(あとストレージも欲しいかも)ですし、変換後の動画を転送する場合は非従量課金ネットワークに繋がっているのがユーザーにとってよいことです(NetworkTypeはいろいろ定義されているんですが、本当に従量課金とそうでないネットワーク見分けつくのか!?などワクワクがあります)。


感想・疑問など


  • Codelabsやると気づきがすごい


    • 「今回はこう書いたけど実際は無駄なことしてるよね、でも学習の為にこう書いてるんだよ」みたいなことが書かれていて、WorkManager以外の知識もちょびっと溜まる

    • ちなみに僕はこのCodelabs完走するのにダラダラやって3時間強かかりました



  • 記事投稿後に気が向いたら試すのですが、たぶんConstraintsで引っかかったWorkerはStatusがBLOCKEDになるんじゃないかなと。なので、BLOCKED状態を拾うことが出来れば、ユーザーになぜ作業が止まっているのかの説明をすることができそうです


    • ただ、ユーザーがアプリから離れているときにBLOCKEDになったタイミングで通知とかでお知らせしたい場合、どうすればいいのか今の所答えが思いつかないです。今はそういうの存在しませんが、ConstraintsにFallbackのWorkerとか登録できたらアツいかもしれないですね



  • 最初に述べたとおりRoomを使っているらしいがCodelabsではあまりそのことには触れられていなかった。


    • こちらの資料が詳しい: WorkManager // Speaker Deck

    • 詳しいどころの話じゃなくて、このQiita記事に書いてあることの上位互換みたいな感じなので、もしプロダクションで使う気があるなら上記スライドは絶対読んで欲しいゾ







  1. 作業内容を記述したクラスないしは作業単位。Workerというクラスを継承して使いますが後述します。