実践 WorkManager

この記事は、DroidKaigi:2019で登壇した「実践 WorkManager」という発表内容を、必要な時に参照しやすいように、また当日発表を見ることができなかった方のためにスクリプトを記事に書き起こして加筆などしたものになります。発表時の資料は以下になります。

登壇時に収録された動画も公開されたので、そちらの方でも同じ内容を把握することができます。お好みに合わせてご利用ください。

また、この記事で扱われている内容の全ては、私が開発をしている「みてね」というアプリに実装されているものになります。是非、ダウンロードして体験してみてください。

WorkManagerを使って解決したかった課題

写真の表示が遅い

みてねは、日本国内に限らず、2017年7月以降は海外のユーザーに向けてのサポートも行なっており、その中で「写真の表示が遅い」といった声がたくさん上がっていました。また、国内においてもネットワーク回線品質の問題で同様の声が少なからず上がっていました。

分析: どのぐらい遅いのか?

このような「遅い」という漠然とした問題は計測し、数字で表現することが重要です。私たちはNew Relic Mobileというサービスを通して各種メトリクスを収集しているのですが、それを用いてUS/JPからの画像リクエストのネットワークレスポンスタイムを比較したものがこちらです。

New Relic Mobileで計測した画像のResponse Time比較

USでは0.71秒、JPでは0.17秒という平均的な数字が出ているのがわかります。この数字を利用して、日本とアメリカのユーザーにおける平均的なUXの差を体感できるように比較したのが以下の動画です。

左側の動画ではUSにおけるレスポンスタイム 0.71sec. を、右側の動画ではJPにおけるレスポンスタム 0.17sec. を画像表示前に、わざと挿入してそのレスポンスタイムを体感できるように再現しています。これが、日本とアメリカにおける平均的なUXの差なわけです。

ただ、これはあくまで平均的な数字に基づいた再現動画なのですが、現実はもっとひどいものでした。次の動画をご覧ください。

これは、New Yorkに出張した際にWeWorkのとても品質の良いWi-Fiネットワークを使用してアクセスした際の実際の挙動を撮影した動画になります。この状況では、はっきり言って「アルバムアプリ」としては使い物にならないぐらいひどい状況であったことがわかると思います(これではさすがにレビューに書きたくもなる…)。

解決したいこと

それでは解決したいことをまとめます。ここまでの情報をもとに、わたしたちは、以下のふたつが解決できることを要求されていたわけです。

  • 平均値0.54秒のUXの差を埋めると言うこと。
  • 世界中のどの国でも日本と同じ0.17秒以下の体験を実現すること、です。

WorkManagerを採用するまでの経緯

それでは、この課題に対して実際にどのような思考をして、WorkManagerを採用するに至ったか?という経緯を簡単にご説明します。

調査によって根本的な問題の原因は把握できていました。

インフラ側の話になってしまいますが、上記画像の通り、アメリカ国内でのCDNとアプリの通信は十分速いのですが、ストレージサーバーが国内に存在しているため、単に距離的に遠いためCDNに画像を受け渡す通信がとても遅いことが根本的な原因でした。

CDNになる早でキャッシュさせれば良いのでは?

そこで、インフラ的な解決としてはアメリカからアップロードされたメディアをアップロード直後にアメリカのCDNに乗せてしまえば良いのでは?という発想もチーム内にあったのですが、実はAWSのCDN(CloudFront)には残念ながらその機能自体が存在していませんでした。よって、この選択を取ることができませんでした。

Androidアプリの起動前に画像をキャッシュしておけば良いのでは?

そこで次に考えたのが、端末側でアプリを見ていない間に画像をキャッシュしてしまい、そうすることで、UI上での待ち時間を減らすことができるのではないか?という発想にいたりました。

では、Androidアプリでそのようなことを行う方針の上で、その実装に対しての大雑把な要求をまとめてみます。

  1. アプリが起動していない時に処理を行うこと。
  2. Wi-Fi接続している時のみ処理を行うこと。
  3. バッテリーが充分にあるときに処理を行うこと。
  4. 新しいキャッシュを取得するために1日に1回くらいは処理を行うこと。
  5. minSdkVersion 19でも動くプログラムであること。

WorkManagerとは?

ここで、ようやくWorkManagerという発想が出てきます。WorkManagerについて、簡単にその内容を振り返ってみます。

  • バックグラウンドジョブを実行・管理するためのAndroid Jetpackコンポーネント
  • アプリが終了しても確実にタスクを終えたい処理を実行させる
  • 様々な制約(Constraints)を組み合わせてジョブの実行を制御することができる
  • API 14以降で同じコードが動く
    API 23+ではJobSchedulerが使われる
    API 14~22ではAlarmManager + BroadcastReceiverが使われる

これらのWorkManagerの実装は、私たちが要求していた条件に合致する部分が多かったため、WorkManagerを画像キャッシュに使おうという結論に至りました。

実装

さて、こういった経緯で私たちはWorkManagerを選択し、実装していくわけなのですが、ここからは実際の実装内容をご紹介します。

私たちのアプリにはSynchronizerと呼ばれているアルバムに表示するすべてのメタデータをクラウド上のデータベースと端末内のSQLiteで同期する仕組みがあります。そこを起点としてWorkManagerを使ってバックグラウンドジョブの実行が行われるように実装を行いました。上記はそのシーケンスをまとめたものになります。このシーケンスのように動くプログラムを書き、アルバムに表示するための多くの画像をバックグラウンドで取得する実装を行いました。このシーケンスの中で重要なのが画像の中でテキストがハイライトされている ジョブをエンキューするという箇所と、ジョブを実行する箇所になります。それぞれ説明をしていきます。

Enqueue Job

先述の要求をまとめた通り、

  • WiFiに接続していること
  • バッテリーが充分にある、もしくは充電中である

という制約を満たした時にJobが動いて欲しいわけで、それを実現するコードがこちらになります。

Constraintsクラスを用いて、NetworkTypeとバッテリー・充電状態をBoolやEnumで指定するだけです。

また、24時間に1回実行させたいという要求もありました。

PeriodicWorkRequest

PeriodicWorkRequestというクラスを用いてWorkManagerにJobの実行をリクエストすると以下のようなことが実現できます。

  • 繰り返し同じJobをInteval毎に実行させる
  • 最初の実行は即時もしくは制約を満たした直後に行われる

https://developer.android.com/reference/androidx/work/PeriodicWorkRequest

そして最後に、先ほど作ったPeriodicWorkRequestを enqueueします。

enqueueUniquePeriodicWork

ここで、このメソッドを使ってWorkerをenqueueすると以下のようなことが実現できます。

  • ユニークな名前の付いたPeriodicWorkRequestを常にひとつだけenqueueされている状態を保証させる。
  • 同時にふたつのJobが並列に動く必要が無い場合に最適なenqueue方法。

https://developer.android.com/reference/androidx/work/WorkManager#enqueueUniquePeriodicWork

Execute Job

EnqueueしたWorkerの実装は単純です。以下の処理を順次実行していくようにプログラムを記述します。

  • SQLiteからレコードを取得
  • URLにアクセスし画像を取得
  • Glideに取得した画像をキャッシュ

実装時のTIPSや注意事項

Workerの実装自体はとてもシンプルに行うことができるのですが、ここからは、Worker実装のTIPSや私がハマった箇所などを中心により実践的な実装時の注意事項などをご紹介したいと思います。

Workerは冪等性を担保した処理を書く

サーバーサイドで非同期Job Queue Workerを実装したことがある方は同様の知見があるかとは思います。

Jobが失敗した時にはほとんどの場合、WorkManagerはそのJobをリトライしてきます。そういったときのために、同じパラメータで複数回Jobがenqueueされ実行されても常に同じ結果・状態が返るように実装するようにしましょう。

例えば、DBにデータを書きながら実行を進めていくようなJobが途中で失敗した場合、再度リトライするときに以前書き込んでしまったレコードが影響して意図しない状態にならないように実装しておくと良いでしょう。

リトライの挙動を理解する

リトライの動きがとても複雑なので、ドキュメントとコードの内部を合わせて読みながらその挙動を理解しておくと良いです。

BackoffPolicy
デフォルトで BackoffPolicy.EXPONENTIALという設定がされているのですが、これは、リトライ間隔を指数関数的に増加させていくような設定です。大抵の場合はこの設定で問題ないですが、最終的に数日後とかにJobがリトライされる可能性があることは理解しておくと良いでしょう。

リトライ回数
また、リトライ回数の最大値を設定することができないのでこちらも注意してください。回数制限したい場合は、Workerクラス内でgetRunAttemptCount を呼ぶと現在の試行回数を取得可能なのでこれを使って、任意の回数に達したらFailureさせてRetryされるのを防ぐと良いでしょう。

意図せぬリトライ予防
そして、そもそも絶対に成功しないパターンなどが分かっている場合は、意図しないリトライが行われないようにエラーハンドリングしておく事も重要です。

これら挙動を理解をしておかないと、例えば意図せずリトライをしすぎてAPI実行しまくってサーバーに負荷をかけてしまうとか、障害を引き起こしてしまう原因にもなりますので注意して下さい。

Workerのコードをアップデートするときには注意

アプリアップデートでWorkerのコードを書き換える時は十分注意してください。

InputDataの型やキーを無闇に変更してしまうと、前のバージョンでenqueueしたJobがアップデート後に実行されて、Workerの実装が変わって意図しないエラーになる。また、Workerのクラス名を変更した場合は、アップデート前にenqueueしたJobは実行時にinitできず、すぐにFailure扱いになり一切実行されなくなってしまいます。

Workerはタイムアウトされる

これは、依存しているJobSchedulerやAlarmManagerの制約でもありますが、Job毎に10分間のタイムアウト値が存在しています、10分を過ぎるとstopのSignalが送られて処理が中断されるので注意してください。

長くなりそうな処理を実行するときは、処理の単位を細分化し複数のWorkerをチェーンするように構成するとよいでしょう。

こちらはドキュメントから引用したものなのですが、AというJobが終了した後にB、Bが終了した後にD, Eがチェーンして実行される、というような構成を簡単に作ることが可能です。

この機構を使ってタイムアウトから解放される、と言ったことも可能ですので考慮すると良いと思います。

Workerをcancelする時の挙動に注意

WorkManagerにWorkerをcancelするインターフェースがいくつかありますが、それらは全てベストエフォートであるとコメントされています。つまり、

  • 動いているWorkerがすぐさま停止されるわけではない。

ということです。キャンセルが要求された際には

  • Worker#onStoppedが実行される
  • Worker#isStoppedでtrueが返るようになる

という動きになりますので、キャンセル時に何か後処理などをしたい場合は以下で実現できないかを考慮しましょう。

QAが難しい

バックグラウンド処理は見えないのでデバッグ画面を用意するなどして工夫するようにしておきましょう。必要に応じてWorkInfoを取得してJobの状態を見えるようにしておき、いつJobが動くのか、リトライ何回目なのか?などの情報をかしかしておくとQAに便利かもしれません。

テストについて

次にテストの書き方についてご説明したいと思います。

どのようにテストを記述するのか

基本的には、Workerを同期実行し、一連のTransactionの内容をテストするのが良いでしょう。同期実行する方法としては、 WorkManagerTestInitHelperというクラスが用意されているのでこれを利用します。また、テストの内容としては、WorkInfo#outputDataを確認したり、依存するライブラリのverifyを行なっていくのが良いでしょう。

WorkManagerTestInitHelperについて

WorkManagerTestInitHelperを使うと以下が実現可能です。

  • Test実行時のContextを使ってシングルトンなWorkManagerをinitさせる。
  • TestDriverを生成し、Constraints(制約)を偽装し、Workerをすぐに実行することができる。

これを使って、例えば以下のように記述します。

一行目で initializeTestWorkManager を実行することでテスト用のWorkManagerのシングルトンなインスタンスが内部で生成されます。

次に二行目で、自分たちが作ったWorkerをenqueueします。

その直後にtestDriverを生成し、setAllConstraintsMetというメソッドを呼ぶことでConstraintsが全て満たされたという状況が偽装されるので、この直後にWorkerのコードが走り始めます。

しかし、実はテストに関しては調べてみたところ、ISSUE TrackerにISSUEが上がっており、WorkManagerのテストはAndroid Instrumentation Testでテスト走らせてくれ、ということでした…!(発表前日の深夜に発覚した)

We never supported Roboelectric. All WorkManager tests need to run as an Android instrumentation test.

成果

ここまでが様々な実装の内容だったのですが、実際にこの実装によってどのようにUXが改善したかを動画を使ってその成果をお見せしたいと思います。

右側は実際にリリースされているバイナリを使った挙動ですが、キャッシュを行なった結果、プログレスが一切表示されないで表示されていることがわかります。

これにより、UI上のロード遅延は一切なくなり、またユーザーが表示したいと思う前にWorkManagerによってキャッシュが生成されているため、レスポンスタイムを意識する必要もなくなったので、世界中で同じ体験を提供できるようになったとも言えます。

さらに、地下鉄などの場所でオフラインであっても写真をより確実に見れるようになるという副次的な効果も実はありました。

残された課題

ここまでで、WorkManagerを使って基本的には大きな成果を上げることはできたと考えているのですが、自分の中では、まだいくつかの課題があると認識しています。最後にそれをご紹介したいと思います。

端末側のリソースに頼りすぎ

この設計・実装はほとんどのユーザーにとってはとても効果的なものではありましたが、その反面、端末のバッテリー・ストレージ・ネットワークリソースに少なからず負荷をかけていることを認識しています。

そのため、バックグラウンドジョブの挙動をユーザーが任意に調整できるようなUIを提供し、少しでも安心してもらえるようにしています。

リクエストに紐付かないCDNへの任意キャッシュ機能が無い問題

CDN側の機能不足はやはり我々にとっては痛手であり、この件はAWS側に機能リクエストをしています。また、その機能を持つAWS以外のCDNを使う選択肢もあるにはありますが、コスト面であったり、インフラの複雑度が増してしまうこともあるので、その選択を取ることはしばらくはないと思っています。

まとめ

この記事では、WorkManagerを使ったUX改善の事例をご紹介しました。また、実践的な背景を踏まえた実装時のポイントや注意したこと・テストの手法などをご紹介しました。

WorkManagerは、発想次第で今回紹介したような細かいUX改善にも活かせるので、この記事がみなさんの課題解決の役に立てたら嬉しいです。