Android のコルーチン(パート III): 実際の処理
2019年7月29日月曜日
この記事は Sean McQuillan (Developer Advocate Android) による Medium Blog の記事 "Coroutines On Android (part III): Real work" を元に翻訳・加筆したものです。詳しくは元記事をご覧ください。
この記事は、Android でのコルーチンの使用に関する連載の一部です。この記事では、ワンショット リクエストを実装することによって、コルーチンを使った実際の問題の解決に焦点を当てます。
Android のコルーチン(パート II): 使ってみる
ここまでは、コルーチンとは何か、およびコルーチンの管理方法に焦点を当ててきました。この記事では、コルーチンを使って実際のタスクを行う方法をいくつか紹介します。コルーチンは関数と同じレベルの、汎用プログラミング言語の機能です。そのため、デベロッパーはコルーチンを使って、関数やオブジェクトで実行可能なあらゆる処理を実装できます。ただし、実際のコードでよく発生し、コルーチンが優れた解決策となるのは以下の 2 種類のタスクです。
コルーチンは上記の両方のタスクに対する優れた解決策です。本稿では、ワンショット リクエストについて詳しく確認し、Android でコルーチンを使ってワンショット リクエストを実装する方法を説明します。
ワンショット リクエストは、呼び出されるとその都度実行されます。結果が利用可能になるとすぐに実行を停止します。
ワンショット リクエストの例として、ブラウザがこのページをどのように読み込んだかを考えてみましょう。あなたがこの記事へのリンクをクリックしたときに、ブラウザはサーバーに対して、ページを読み込むためのネットワーク リクエストを送信しました。ページがブラウザに転送されると、ブラウザはバックエンドとのやり取りを停止します。この時点で、ブラウザは必要なデータをすべて入手しています。サーバー側で記事が変更されても、ブラウザにはその変更は表示されません。変更を表示するにはページの更新が必要になります。
このように、ストリーミング リクエストのライブプッシュは無いものの、ワンショット リクエストは極めて有用です。Android アプリにおいて実行できる処理のなかで、データの取得、保管、更新など、ワンショット リクエストによって解決できるものは数多くあります。このパターンは、リストの並べ替えのような処理にも適しています。
このアプリでは、すべての商品が Room データベースに保存されます。これはユースケースとして取り上げるのに適しています。ネットワーク リクエストを含める必要がなく、パターンに焦点を当てることができるためです。この例はネットワークを使わないため簡単になっていますが、ワンショット リクエストの実装に必要なパターンを示しています。
コルーチンを使ってこのリクエストを実装するために、ViewModel、Repository、および Dao にコルーチンを導入します。それぞれを 1 つずつ見ながら、コルーチンと統合する方法を確認してみましょう。(ソースはこちら)
ProductsViewModel は、UI レイヤからのイベントを受け取った後、リポジトリに更新されたデータがないか問い合わせる役割を担います。ProductsViewModel は LiveData を使って、UI での表示用に現在の並べ替えられたリストを保持します。新しいイベントが届くと、sortProductsBy が新しいコルーチンを開始してリストを並べ替え、結果が利用可能になったら LiveData を更新します。通常、このアーキテクチャのほとんどのコルーチンを開始するのに適した場所は ViewModel です。これは、ViewModel が onCleared 内のコルーチンをキャンセルできるためです。ユーザーが現在の画面から別の場所に移動した場合、通常、未完了の処理には用途がなくなります。
LiveData の使用経験があまりない場合は、LiveData が UI 用のデータを保管する仕組みについて紹介した @CeruleanOtter による以下の記事をご覧ください。
ViewModel: 簡単な例 (medium.com)
これは Android のコルーチンの一般的なパターンです。Android フレームワークは中断関数を呼び出さないため、デベロッパーは UI イベントに応じてコルーチンを調整する必要があります。そのための最も簡単な方法は、イベントが届いたときに新しいコルーチンを開始することです。その処理を実行する最も自然な場所は ViewModel 内です。
一般的なパターンとして、ViewModel 内でコルーチンを開始します。
ViewModel は ProductsRepository を使って実際にデータを取得します。コードは以下のようになります。(ソースはこちら)
ProductsRepository は、商品のデータにアクセスするための相応のインターフェースを提供します。このアプリでは、すべてのデータがローカルの Room データベースに保存されているため、ProductsRepository はそれぞれの並べ替え順に対応した 2 つの関数を持つ @Dao 用の適切なインターフェースを提供します。
リポジトリは Android アーキテクチャ コンポーネント アーキテクチャのオプション部分ですが、アプリ内にリポジトリまたは類似のレイヤがある場合、そうしたリポジトリやレイヤでは標準の中断関数のエクスポーズを優先する必要があります。リポジトリは自然なライフサイクルを持たず、単なるオブジェクトに過ぎないため、処理をクリーンアップする手段を持ちません。その結果、リポジトリ内で開始されたコルーチンはすべて、デフォルトで処理漏れとなります。
処理漏れの回避に加えて、標準の中断関数をエクスポーズすることで、別のコンテキストでリポジトリを簡単に再利用できます。コルーチンの作成方法を知っているものなら何でも、loadSortedProducts を呼び出せます。たとえば、WorkManager ライブラリによってスケジュール設定されたバックグラウンド ジョブは、直接 loadSortedProducts を呼び出すことができます。
リポジトリは、メインセーフである標準の中断関数のエクスポーズを優先する必要があります。
ProductsDao は Room の @Dao であり、2 つの中断関数をエクスポーズします。関数には suspend のマークが付けられているため、Room はそれらの関数がメインセーフになるようにします。つまり、デベロッパーはそれらの関数を Dispatchers.Main から直接呼び出すことができます。
これまでに Room 内でのコルーチンを見たことがない場合は、@FMuntenescu による以下の記事をご覧ください。
データベースに中断を追加する (medium.com)
ただし、これを呼び出すコルーチンはメインスレッド上にある、ということに注意が必要です。そのため、その結果を使って負荷の高い処理(新しいリストへの変換など)を実行する場合は、メインスレッドをブロックしていないことを確認する必要があります。
ViewModel は、コルーチンの開始と、ユーザーが画面から離れた場合にそうしたコルーチンが確実にキャンセルされるようにする役割を担います。ViewModel は負荷の高い処理を実行しません。負荷の高い処理の実行は他のレイヤに依存します。結果を取得したら、LiveData を使ってその結果を UI に送ります。
ViewModel は負荷の高い処理を実行しないため、メインスレッド上でコルーチンを開始します。メインスレッド上で開始することで、結果がすぐに(たとえば、メモリ内キャッシュから)利用できる場合はユーザー イベントにより迅速に応答できます。
Repository は、データにアクセスするための標準の中断関数をエクスポーズします。Repository は通常、長期にわたって実行される独自のコルーチンを開始しません。これは、Repository がそうしたコルーチンをキャンセルする手段を持たないためです。リストの変換のような負荷の高い処理を実行しなければならない場合、Repository は withContext を使ってメインセーフなインターフェースをエクスポーズする必要があります。
データレイヤ(ネットワークまたはデータベース)は常に、標準の中断関数をエクスポーズします。Kotlin コルーチンを使う際はこうした中断関数がメインセーフであることが重要であり、Room と Retrofit はいずれもこのパターンに従っています。
ワンショット リクエストでは、データレイヤは中断関数のエクスポーズのみを行います。そうした関数が新しい値を必要とする場合、呼び出し元はそうした関数を再度呼び出す必要があります。これは、ウェブブラウザの更新ボタンのようなものです。
ワンショット リクエストに関するこうしたパターンを理解しておくと便利です。これは Android のコルーチンにおける通常のパターンであり、頻繁に使用することになります。
デベロッパーは「修正しない - ボタンを何度も素早く押さないこと」としてバグをクローズしたくなりましたが、何か不具合があるのかも知れないと心配になりました。ログ ステートメントを追加し、一度に数多くの並べ替えを呼び出すテストを作成した結果、デベロッパーはついに原因を突き止めました。
表示される結果は「いま行った並べ替えの結果」ではなく、実際には「前回完了した並べ替え」の結果であることがわかりました。ユーザーが何回もボタンを押すと、同時に複数の並べ替えが開始され、順序に関係なく完了する可能性があります。
UI イベントに応じて新しいコルーチンを開始する際は、その 1 回の操作が完了する前にユーザーが同じ操作を新たに開始した場合に何が起きるかを考慮します。
これは並行性バグであり、コルーチンとは一切関係がありません。コールバック、Rx、あるいは ExecutorService を同じように使ったとしても、同様のバグが発生すると考えられます。
この問題は、ViewModel と Repository の両方において、さまざまな方法を使って修正できます。ワンショット リクエストをユーザーの予期する順序で、確実に完了させるためのパターンをいくつか紹介します。
単純な解決策に思えるかも知れませんが、これは極めて良い方法です。この処理はシンプルなコードで実装でき、テストも簡単で、UI において一貫性がある限り問題は完全に解決されます。
ボタンを無効にするには、以下のように、sortPricesBy 内で並べ替えリクエストが発生中であることを UI に伝えます。(ソースはこちら)
これはなかなか良い方法です。sortPricesBy 内でリポジトリへの呼び出しの間じゅう、ボタンを無効にするだけです。
ほとんどの場合、この方法で問題を適切に解決できます。しかし、ボタンを無効にせずにこのバグを修正したい場合は、どうすればよいでしょうか。その場合はやや複雑になります。本稿の残りの部分で、その他の解決方法を見てみましょう。
ここからは、コルーチンを使って、ボタンを有効にしたまま、複数のワンショット リクエストがユーザーの予期した順序で確実に実行されるようにする方法をいくつか紹介します。コルーチンをいつ実行するか(または実行しないか)を制御することで偶発的な同時実行を防ぐことによって、その動作を実現できます。
ワンショット リクエストで一度に 1 つのリクエストだけが実行されるようにする方法として、次の 3 つの基本パターンを使用できます。
こうした解決策を見て行くなかで、その実装がやや複雑であることがわかると思います。実装の詳細ではなくこうしたパターンの使用方法に焦点を当てるために、再利用可能な抽象化として全 3 パターンの実装を含めた gist を作成しました。
直前のリクエストをキャンセルするためには、そのリクエストを何らかの方法で管理することが必要となります。gist 内の関数 cancelPreviousThenRun がその処理を実行します。
この関数を使ってバグがどのように修正されるかを見てみましょう。(ソースはこちら)
gist での cancelPreviousThenRun の実装例を見てみると、進行中の処理を管理する方法がわかります。(ソースはこちら)
簡単に言えば、この関数は常に、メンバー変数 activeTask 内で現在アクティブな並べ替えを管理します。並べ替えが開始されるたびに、現在 activeTask 内にあるものに対して、直ちに cancelAndJoin を実行します。この関数によって、新たな並べ替えを開始する前に、進行中の並べ替えがすべてキャンセルされます。
アドホックの同時実行をアプリケーション ロジックに組み込むのではなく、ControlledRunner<T> のような抽象化を使ってこうしたロジックをカプセル化することをおすすめします。
抽象化を作成して、アドホックの同時実行パターンをアプリケーション コードに組み込むのを避けることを検討してください。
リクエストをキューに入れ、一度に 1 つのリクエストしか実行されないようにする方法です。お店での順番待ちや列のように、リクエストは一度に 1 つずつ、開始された順に実行されます。
今回発生した並べ替えの問題に対しては、キューに入れるよりもキャンセルするほうが良いと考えられますが、この方法は常に有効であることから、ここで紹介します。(ソースはこちら)
この方法では、新しい並べ替えが届くたびに、SingleRunner のインスタンスを使って、一度に実行される並べ替えが 1 つのみとなるようにします。
この方法では Mutex を使います。Mutex は 1 回限りのチケット(ロック)であり、コルーチンがブロックに入るためには Mutex を取得する必要があります。実行中のコルーチンがある状態で新たにコルーチンを実行しようとすると、その新たなコルーチンは Mutex によって、保留中のすべてのコルーチンが完了するまで、自身を中断させます。
Mutex によって、一度に 1 つのコルーチンだけが実行されるようにすることができます。コルーチンは開始された順に完了します。
このパターンは、並べ替え機能に特に適しているわけではありませんが、データを読み込むネットワーク フェッチには自然にフィットします。
今回の在庫管理アプリでは、ユーザーはサーバーから新たな商品の在庫を取得する方法を必要とします。そこで、シンプルな UI として、ユーザー向けに更新ボタンを用意します。ユーザーはこのボタンを押して新しいネットワーク リクエストを開始できます。
並べ替えボタンと同様に、リクエストの実行中は単にこのボタンを無効することが、この問題に対する完全な解決策です。しかし、もしその方法をとらない(あるいは、とれない)場合は、その代わりに既存のリクエストを結合することができます。
この方法が動作する仕組みの例として、gist の joinPreviousOrRun を使ったコードを見てみましょう。(ソースはこちら)
これは、cancelPreviousAndRun の動作を逆さにしたものです。直前のリクエストをキャンセルすることで破棄する代わりに、新しいリクエストを破棄して実行を防ぎます。すでに実行中のリクエストがある場合は、新たにリクエストを実行する代わりに、現在の「実行中の」リクエストの結果を待って、その結果を返します。ブロックが実行されるのは、すでに実行中のリクエストがない場合のみです。
この動作は joinPreviousOrRun の開始部分で確認できます。activeTask にデータがある場合は、そのまま直前の結果を返します。(ソースはこちら)
このパターンは、id による商品の取得などのリクエストに拡張できます。id から Deferred へのマップを追加したうえで、同じ結合ロジックを使って、同じ商品に対する直前のリクエストを管理できます。
直前の処理の結合は、ネットワーク リクエストの繰り返しを避けるための優れた解決策です。
ほとんどのタスクの場合、Android で Kotlin コルーチンを使うために必要なのはこれだけです。このパターンは、本稿で紹介したリストの並べ替えのような、一般的な数多くのタスクに適用できます。このパターンは、ネットワーク上のデータの取得、保存、更新にも使用できます。
次に、発生するおそれのあるわかりにくいバグと、考えられる解決策を紹介しました。この問題を解決する最も簡単な(そして多くの場合最適な)方法は、UI において、並べ替えが進行中の間は並べ替えボタンを無効にすることです。
そして最後に、いくつかの高度な同時実行パターンと、Kotlin コルーチンでのそうしたパターンの実装方法を紹介しました。このコードはやや複雑ですが、いくつかの高度なコルーチンのトピックへの優れた入門編となっています。
次回は、ストリーミング リクエストと、liveData ビルダーの使用方法を紹介します。
Posted by Yuichi Araki - Developer Relations Team
この記事は、Android でのコルーチンの使用に関する連載の一部です。この記事では、ワンショット リクエストを実装することによって、コルーチンを使った実際の問題の解決に焦点を当てます。
このシリーズの他の記事:
Android のコルーチン(パート I): 背景を理解するAndroid のコルーチン(パート II): 使ってみる
コルーチンで現実の世界の問題を解決する
このシリーズのパート I とパート II では、コルーチンを使ったコードの簡素化、Android でのメインセーフティの提供、処理漏れの防止の方法に焦点を当てました。そうした状況から見れば、コルーチンは、バックグラウンド処理と、Android でのコールバック ベースのコードを簡略化する方法の両方に対する優れた解決策のように見えます。ここまでは、コルーチンとは何か、およびコルーチンの管理方法に焦点を当ててきました。この記事では、コルーチンを使って実際のタスクを行う方法をいくつか紹介します。コルーチンは関数と同じレベルの、汎用プログラミング言語の機能です。そのため、デベロッパーはコルーチンを使って、関数やオブジェクトで実行可能なあらゆる処理を実装できます。ただし、実際のコードでよく発生し、コルーチンが優れた解決策となるのは以下の 2 種類のタスクです。
- ワンショット リクエスト: 呼び出されるとその都度実行されるリクエストです。必ず、結果が利用可能になった後で完了します。
- ストリーミング リクエスト: 継続的に変更を監視して呼び出し元に変更を伝えるリクエストです。最初の結果が利用可能になった時点では完了しません。
コルーチンは上記の両方のタスクに対する優れた解決策です。本稿では、ワンショット リクエストについて詳しく確認し、Android でコルーチンを使ってワンショット リクエストを実装する方法を説明します。
ワンショット リクエスト
ワンショット リクエストは、呼び出されるたびに 1 回実行され、結果が利用可能になるとすぐに完了します。このパターンは標準の関数呼び出しと同じです。呼び出され、何らかの処理を実行し、その後リターンします。ワンショット リクエストは関数呼び出しと似ていることから、ストリーミング リクエストよりも理解しやすいといえます。ワンショット リクエストは、呼び出されるとその都度実行されます。結果が利用可能になるとすぐに実行を停止します。
ワンショット リクエストの例として、ブラウザがこのページをどのように読み込んだかを考えてみましょう。あなたがこの記事へのリンクをクリックしたときに、ブラウザはサーバーに対して、ページを読み込むためのネットワーク リクエストを送信しました。ページがブラウザに転送されると、ブラウザはバックエンドとのやり取りを停止します。この時点で、ブラウザは必要なデータをすべて入手しています。サーバー側で記事が変更されても、ブラウザにはその変更は表示されません。変更を表示するにはページの更新が必要になります。
このように、ストリーミング リクエストのライブプッシュは無いものの、ワンショット リクエストは極めて有用です。Android アプリにおいて実行できる処理のなかで、データの取得、保管、更新など、ワンショット リクエストによって解決できるものは数多くあります。このパターンは、リストの並べ替えのような処理にも適しています。
問題: 並べ替えられたリストの表示
並べ替えられたリストをどのように表示するかを例に、ワンショット リクエストを見てみましょう。例を具体的にするために、従業員が店舗で使う在庫管理アプリを構築することにします。このアプリは、最後に補充された日時に基づいて商品を検索するために使用されます。リストは昇順と降順の両方で並べ替えられるようにする必要があります。商品数が多いため、並べ替えには約 1 秒かかると考えられます。そこで、メインスレッドがブロックされるのを回避するためにコルーチンを使います。このアプリでは、すべての商品が Room データベースに保存されます。これはユースケースとして取り上げるのに適しています。ネットワーク リクエストを含める必要がなく、パターンに焦点を当てることができるためです。この例はネットワークを使わないため簡単になっていますが、ワンショット リクエストの実装に必要なパターンを示しています。
コルーチンを使ってこのリクエストを実装するために、ViewModel、Repository、および Dao にコルーチンを導入します。それぞれを 1 つずつ見ながら、コルーチンと統合する方法を確認してみましょう。(ソースはこちら)
ProductsViewModel は、UI レイヤからのイベントを受け取った後、リポジトリに更新されたデータがないか問い合わせる役割を担います。ProductsViewModel は LiveData を使って、UI での表示用に現在の並べ替えられたリストを保持します。新しいイベントが届くと、sortProductsBy が新しいコルーチンを開始してリストを並べ替え、結果が利用可能になったら LiveData を更新します。通常、このアーキテクチャのほとんどのコルーチンを開始するのに適した場所は ViewModel です。これは、ViewModel が onCleared 内のコルーチンをキャンセルできるためです。ユーザーが現在の画面から別の場所に移動した場合、通常、未完了の処理には用途がなくなります。
LiveData の使用経験があまりない場合は、LiveData が UI 用のデータを保管する仕組みについて紹介した @CeruleanOtter による以下の記事をご覧ください。
ViewModel: 簡単な例 (medium.com)
これは Android のコルーチンの一般的なパターンです。Android フレームワークは中断関数を呼び出さないため、デベロッパーは UI イベントに応じてコルーチンを調整する必要があります。そのための最も簡単な方法は、イベントが届いたときに新しいコルーチンを開始することです。その処理を実行する最も自然な場所は ViewModel 内です。
一般的なパターンとして、ViewModel 内でコルーチンを開始します。
ViewModel は ProductsRepository を使って実際にデータを取得します。コードは以下のようになります。(ソースはこちら)
ProductsRepository は、商品のデータにアクセスするための相応のインターフェースを提供します。このアプリでは、すべてのデータがローカルの Room データベースに保存されているため、ProductsRepository はそれぞれの並べ替え順に対応した 2 つの関数を持つ @Dao 用の適切なインターフェースを提供します。
リポジトリは Android アーキテクチャ コンポーネント アーキテクチャのオプション部分ですが、アプリ内にリポジトリまたは類似のレイヤがある場合、そうしたリポジトリやレイヤでは標準の中断関数のエクスポーズを優先する必要があります。リポジトリは自然なライフサイクルを持たず、単なるオブジェクトに過ぎないため、処理をクリーンアップする手段を持ちません。その結果、リポジトリ内で開始されたコルーチンはすべて、デフォルトで処理漏れとなります。
処理漏れの回避に加えて、標準の中断関数をエクスポーズすることで、別のコンテキストでリポジトリを簡単に再利用できます。コルーチンの作成方法を知っているものなら何でも、loadSortedProducts を呼び出せます。たとえば、WorkManager ライブラリによってスケジュール設定されたバックグラウンド ジョブは、直接 loadSortedProducts を呼び出すことができます。
リポジトリは、メインセーフである標準の中断関数のエクスポーズを優先する必要があります。
注: 一部のバックグラウンドの保存オペレーションについては、ユーザーが画面から離れた後も続行することをおすすめします。こうした保存をライフサイクルなしで実行することは合理的です。他のほとんどの場合は、viewModelScope が合理的な選択です。ProductsDao に進みます。コードは以下のようになっています。
ProductsDao は Room の @Dao であり、2 つの中断関数をエクスポーズします。関数には suspend のマークが付けられているため、Room はそれらの関数がメインセーフになるようにします。つまり、デベロッパーはそれらの関数を Dispatchers.Main から直接呼び出すことができます。
これまでに Room 内でのコルーチンを見たことがない場合は、@FMuntenescu による以下の記事をご覧ください。
データベースに中断を追加する (medium.com)
ただし、これを呼び出すコルーチンはメインスレッド上にある、ということに注意が必要です。そのため、その結果を使って負荷の高い処理(新しいリストへの変換など)を実行する場合は、メインスレッドをブロックしていないことを確認する必要があります。
注: Room では独自のディスパッチャを使って、バックグラウンド スレッドでクエリを実行します。デベロッパーのコードでは withContext(Dispatchers.IO) を使って中断の原因となる Room のクエリを呼び出さないようにする必要があります。コードが複雑になり、クエリの実行速度が遅くなります。Room の中断関数はメインセーフであり、カスタム ディスパッチャ上で実行されます。
ワンショット リクエストのパターン
上記の処理は、Android アーキテクチャ コンポーネントにおいて、コルーチンを使ってワンショット リクエストを作成する完全なパターンです。ViewModel、Repository、および Room にコルーチンを追加しました。各レイヤはそれぞれ別の役割を担います。- ViewModel はメインスレッド上でコルーチンを開始します。コルーチンは結果を取得すると完了します。
- Repository は標準の中断関数をエクスポーズし、それらの関数がメインセーフになるようにします。
- データベースとネットワークは標準の中断関数をエクスポーズし、それらの関数がメインセーフになるようにします。
ViewModel は、コルーチンの開始と、ユーザーが画面から離れた場合にそうしたコルーチンが確実にキャンセルされるようにする役割を担います。ViewModel は負荷の高い処理を実行しません。負荷の高い処理の実行は他のレイヤに依存します。結果を取得したら、LiveData を使ってその結果を UI に送ります。
ViewModel は負荷の高い処理を実行しないため、メインスレッド上でコルーチンを開始します。メインスレッド上で開始することで、結果がすぐに(たとえば、メモリ内キャッシュから)利用できる場合はユーザー イベントにより迅速に応答できます。
Repository は、データにアクセスするための標準の中断関数をエクスポーズします。Repository は通常、長期にわたって実行される独自のコルーチンを開始しません。これは、Repository がそうしたコルーチンをキャンセルする手段を持たないためです。リストの変換のような負荷の高い処理を実行しなければならない場合、Repository は withContext を使ってメインセーフなインターフェースをエクスポーズする必要があります。
データレイヤ(ネットワークまたはデータベース)は常に、標準の中断関数をエクスポーズします。Kotlin コルーチンを使う際はこうした中断関数がメインセーフであることが重要であり、Room と Retrofit はいずれもこのパターンに従っています。
ワンショット リクエストでは、データレイヤは中断関数のエクスポーズのみを行います。そうした関数が新しい値を必要とする場合、呼び出し元はそうした関数を再度呼び出す必要があります。これは、ウェブブラウザの更新ボタンのようなものです。
ワンショット リクエストに関するこうしたパターンを理解しておくと便利です。これは Android のコルーチンにおける通常のパターンであり、頻繁に使用することになります。
最初のバグレポート
デベロッパーはこのソリューションをテストした後、本番環境向けにリリースしました。その後数週間ほど問題なく動作していましたが、あるとき奇妙なバグレポートが寄せられました。件名: 🐞 - 並べ替え順が正しくありませんデベロッパーはこのレポートを見て困惑しました。どのような問題が起きているのでしょうか。アルゴリズムは極めてシンプルに思えます。
レポート: 並べ替えボタンを何度も素早く押したときに、並べ替えが正しく行われないことがあります。この問題は毎回発生するわけではありません。🙃
- ユーザーのリクエストした並べ替えを開始する。
- Room ディスパッチャで並べ替えを実行する。
- 並べ替えの結果を表示する。
デベロッパーは「修正しない - ボタンを何度も素早く押さないこと」としてバグをクローズしたくなりましたが、何か不具合があるのかも知れないと心配になりました。ログ ステートメントを追加し、一度に数多くの並べ替えを呼び出すテストを作成した結果、デベロッパーはついに原因を突き止めました。
表示される結果は「いま行った並べ替えの結果」ではなく、実際には「前回完了した並べ替え」の結果であることがわかりました。ユーザーが何回もボタンを押すと、同時に複数の並べ替えが開始され、順序に関係なく完了する可能性があります。
UI イベントに応じて新しいコルーチンを開始する際は、その 1 回の操作が完了する前にユーザーが同じ操作を新たに開始した場合に何が起きるかを考慮します。
これは並行性バグであり、コルーチンとは一切関係がありません。コールバック、Rx、あるいは ExecutorService を同じように使ったとしても、同様のバグが発生すると考えられます。
この問題は、ViewModel と Repository の両方において、さまざまな方法を使って修正できます。ワンショット リクエストをユーザーの予期する順序で、確実に完了させるためのパターンをいくつか紹介します。
最適な解決策: ボタンを無効にする
根本的な問題は、並べ替えが 2 つ実行されていることです。並べ替えが 1 つしか実行されないようにすることで、この問題を修正できます。最も簡単な方法は、並べ替えボタンを無効にして、新しいイベントが起きないようにすることです。単純な解決策に思えるかも知れませんが、これは極めて良い方法です。この処理はシンプルなコードで実装でき、テストも簡単で、UI において一貫性がある限り問題は完全に解決されます。
ボタンを無効にするには、以下のように、sortPricesBy 内で並べ替えリクエストが発生中であることを UI に伝えます。(ソースはこちら)
![]() |
sortPricesBy で _sortButtonsEnabled を使って、並べ替えが実行している間はボタンを無効にします |
これはなかなか良い方法です。sortPricesBy 内でリポジトリへの呼び出しの間じゅう、ボタンを無効にするだけです。
ほとんどの場合、この方法で問題を適切に解決できます。しかし、ボタンを無効にせずにこのバグを修正したい場合は、どうすればよいでしょうか。その場合はやや複雑になります。本稿の残りの部分で、その他の解決方法を見てみましょう。
重要: このコードは、タップに応じてボタンがすぐに無効化されるという、メインスレッド上で開始することの大きな利点を示しています。ディスパッチャを切り替える場合、低速のスマートフォンでユーザーが素早く何度もタップすると、複数回のタップが送信される可能性があります。
同時実行のパターン
ここからは高度なトピックを紹介します。コルーチンを使い始めたばかりの場合は、以下の内容をすぐに理解する必要はありません。発生する問題のほとんどは、単にボタンを無効にすることで適切に解決できます。ここからは、コルーチンを使って、ボタンを有効にしたまま、複数のワンショット リクエストがユーザーの予期した順序で確実に実行されるようにする方法をいくつか紹介します。コルーチンをいつ実行するか(または実行しないか)を制御することで偶発的な同時実行を防ぐことによって、その動作を実現できます。
ワンショット リクエストで一度に 1 つのリクエストだけが実行されるようにする方法として、次の 3 つの基本パターンを使用できます。
- 新たな処理を開始する前に直前の処理をキャンセルする。
- 次の処理をキューに入れて、その処理を開始する前に直前のリクエストが完了するのを待つ。
- すでに実行中のリクエストがある場合は直前の処理を結合し、新たなリクエストを開始せずにその処理の結果を返す。
こうした解決策を見て行くなかで、その実装がやや複雑であることがわかると思います。実装の詳細ではなくこうしたパターンの使用方法に焦点を当てるために、再利用可能な抽象化として全 3 パターンの実装を含めた gist を作成しました。
解決策その 1: 直前の処理をキャンセルする
並べ替えの場合、ユーザーから新たなイベントが届くことは、通常、直前の並べ替えをキャンセルできることを意味します。結果が不要であるとユーザーがすでに伝えてきているのなら、処理を続行する意味はありません。直前のリクエストをキャンセルするためには、そのリクエストを何らかの方法で管理することが必要となります。gist 内の関数 cancelPreviousThenRun がその処理を実行します。
この関数を使ってバグがどのように修正されるかを見てみましょう。(ソースはこちら)
![]() |
cancelPreviousThenRun を使って、並べ替えが一度に 1 つだけ実行されるようにする。 |
簡単に言えば、この関数は常に、メンバー変数 activeTask 内で現在アクティブな並べ替えを管理します。並べ替えが開始されるたびに、現在 activeTask 内にあるものに対して、直ちに cancelAndJoin を実行します。この関数によって、新たな並べ替えを開始する前に、進行中の並べ替えがすべてキャンセルされます。
アドホックの同時実行をアプリケーション ロジックに組み込むのではなく、ControlledRunner<T> のような抽象化を使ってこうしたロジックをカプセル化することをおすすめします。
抽象化を作成して、アドホックの同時実行パターンをアプリケーション コードに組み込むのを避けることを検討してください。
重要: 関連のない呼び出し元同士がお互いをキャンセルすることはできないため、このパターンはグローバル シングルトンでの使用には適していません。
解決策その 2: 次の処理をキューに入れる
並行性バグに対して、常に有効な解決策が 1 つあります。リクエストをキューに入れ、一度に 1 つのリクエストしか実行されないようにする方法です。お店での順番待ちや列のように、リクエストは一度に 1 つずつ、開始された順に実行されます。
今回発生した並べ替えの問題に対しては、キューに入れるよりもキャンセルするほうが良いと考えられますが、この方法は常に有効であることから、ここで紹介します。(ソースはこちら)
この方法では、新しい並べ替えが届くたびに、SingleRunner のインスタンスを使って、一度に実行される並べ替えが 1 つのみとなるようにします。
この方法では Mutex を使います。Mutex は 1 回限りのチケット(ロック)であり、コルーチンがブロックに入るためには Mutex を取得する必要があります。実行中のコルーチンがある状態で新たにコルーチンを実行しようとすると、その新たなコルーチンは Mutex によって、保留中のすべてのコルーチンが完了するまで、自身を中断させます。
Mutex によって、一度に 1 つのコルーチンだけが実行されるようにすることができます。コルーチンは開始された順に完了します。
解決策その 3: 直前の処理を結合する
3 番目に検討する解決策は、直前の処理を結合する方法です。この方法は、新しいリクエストが、すでに途中まで完了しているものと完全に同じ内容の処理を再スタートするものである場合に適しています。このパターンは、並べ替え機能に特に適しているわけではありませんが、データを読み込むネットワーク フェッチには自然にフィットします。
今回の在庫管理アプリでは、ユーザーはサーバーから新たな商品の在庫を取得する方法を必要とします。そこで、シンプルな UI として、ユーザー向けに更新ボタンを用意します。ユーザーはこのボタンを押して新しいネットワーク リクエストを開始できます。
並べ替えボタンと同様に、リクエストの実行中は単にこのボタンを無効することが、この問題に対する完全な解決策です。しかし、もしその方法をとらない(あるいは、とれない)場合は、その代わりに既存のリクエストを結合することができます。
この方法が動作する仕組みの例として、gist の joinPreviousOrRun を使ったコードを見てみましょう。(ソースはこちら)
これは、cancelPreviousAndRun の動作を逆さにしたものです。直前のリクエストをキャンセルすることで破棄する代わりに、新しいリクエストを破棄して実行を防ぎます。すでに実行中のリクエストがある場合は、新たにリクエストを実行する代わりに、現在の「実行中の」リクエストの結果を待って、その結果を返します。ブロックが実行されるのは、すでに実行中のリクエストがない場合のみです。
この動作は joinPreviousOrRun の開始部分で確認できます。activeTask にデータがある場合は、そのまま直前の結果を返します。(ソースはこちら)
このパターンは、id による商品の取得などのリクエストに拡張できます。id から Deferred へのマップを追加したうえで、同じ結合ロジックを使って、同じ商品に対する直前のリクエストを管理できます。
直前の処理の結合は、ネットワーク リクエストの繰り返しを避けるための優れた解決策です。
次のステップ
本稿では、Kotlin コルーチンを使ったワンショット リクエストの実装方法について見てきました。まず、ViewModel 内でコルーチンを開始し、Repository と Room Dao から標準の中断関数をエクスポーズする方法を示したパターン全体を実装しました。ほとんどのタスクの場合、Android で Kotlin コルーチンを使うために必要なのはこれだけです。このパターンは、本稿で紹介したリストの並べ替えのような、一般的な数多くのタスクに適用できます。このパターンは、ネットワーク上のデータの取得、保存、更新にも使用できます。
次に、発生するおそれのあるわかりにくいバグと、考えられる解決策を紹介しました。この問題を解決する最も簡単な(そして多くの場合最適な)方法は、UI において、並べ替えが進行中の間は並べ替えボタンを無効にすることです。
そして最後に、いくつかの高度な同時実行パターンと、Kotlin コルーチンでのそうしたパターンの実装方法を紹介しました。このコードはやや複雑ですが、いくつかの高度なコルーチンのトピックへの優れた入門編となっています。
次回は、ストリーミング リクエストと、liveData ビルダーの使用方法を紹介します。
Posted by Yuichi Araki - Developer Relations Team