はじめに
コルーチンの学習として、Android Studio のコルーチンの概要という、タイマーアプリをサンプルとして作成しながら、具体的にコルーチンの基礎を学べるCodelabを進めて、コルーチンに対する理解を深めます。
本文を記入しつつ、つまづいた部分や気になった部分は本文の間に挟む形で、メモ書き程度にここに記入していきます。
この記事がコルーチンの理解の手助けになれば幸いです。
1.始める前に
前の Codelab では、コルーチンについて学習しました。Kotlin プレイグラウンドを使用して、コルーチンで同時実行コードを記述しました。この Codelab では、Android アプリ内のコルーチンとそのライフサイクルに関する知識を利用します。新しいコルーチンを同時に起動するためのコードを追加し、それらのコルーチンをテストする方法を学習します。
<前提条件>
- Kotlin 言語の基本(関数やラムダを含む)に関する知識
- Jetpack Compose でレイアウトを作成できること
- Kotlin で単体テストを作成できること(ViewModel Codelab の単体テストを作成するを参照)
- スレッドと同時実行の仕組みに関する知識
- コルーチンと CoroutineScope に関する基本的な知識
<作成するアプリの概要>
2 人のプレーヤー間のレースの進行状況をシミュレートする Race Tracker アプリを作成します。このアプリを通して、コルーチンのさまざまな側面についてテストし、学習を深めることができます。
2.アプリの概要
Race Tracker は、2 人のプレーヤーによる競走をシミュレートするアプリです。アプリ UI は、[Start / Pause] と [Reset] の 2 つのボタンと、ランナーの進行状況を示す 2 つの進行状況バーで構成されています。レースは、プレーヤー 1 と 2 がそれぞれ異なるスピードで「走る」という設定です。実際のレースでは、プレーヤー 2 がプレーヤー 1 の 2 倍の速さで進みます。
アプリでコルーチンを使用して、次のことを確認します。
- 両方のプレーヤーが同時に「競走」する。
- アプリの UI がレスポンシブで、ランナーの進行に合わせて進行状況バーが伸びていく。
スターター コードには、Race Tracker アプリ用の UI コードが含まれています。Codelab のこのパートは主に、Android アプリ内の Kotlin コルーチンに慣れることに焦点を当てます。
3.レースの進行状況を実装する
run() 関数を使用してプレーヤーの currentProgress を maxProgress と比較し、レースの全体的な進行状況を表示します。また、suspend 関数(delay())を使用して、進行状況バーを伸ばすタイミングにわずかな遅延を追加します。この関数は別の suspend 関数 delay() を呼び出しているため、suspend 関数である必要があります。また、この関数は、Codelab の後半でコルーチンから呼び出します。関数を実装する手順は次のとおりです。
- スターター コードの一部である RaceParticipant クラスを開きます。
- RaceParticipant クラス内で、run() という名前の新しい suspend 関数を定義します。
class RaceParticipant(
...
) {
var currentProgress by mutableStateOf(initialProgress)
private set
suspend fun run() {
}
...
}
レースの進行状況をシミュレートするには、currentProgress が値 maxProgress(100 に設定される)に達するまで実行される while ループを追加します。
class RaceParticipant(
...
val maxProgress: Int = 100,
...
) {
var currentProgress by mutableStateOf(initialProgress)
private set
suspend fun run() {
while (currentProgress < maxProgress) {
}
}
...
}
currentProgress の値は initialProgress(0)に設定されています。参加者の進行状況をシミュレートするには、while ループで progressIncrement プロパティの値ずつ currentProgress の値を増やしていきます。progressIncrement のデフォルト値は 1 です。
class RaceParticipant(
...
val maxProgress: Int = 100,
...
private val progressIncrement: Int = 1,
private val initialProgress: Int = 0
) {
...
var currentProgress by mutableStateOf(initialProgress)
private set
suspend fun run() {
while (currentProgress < maxProgress) {
currentProgress += progressIncrement
}
}
}
レースの進行状況バーをさまざまな間隔で伸ばすシミュレーションを行うには、suspend 関数 delay() を使用します。progressDelayMillis プロパティの値を引数として渡します。
suspend fun run() {
while (currentProgress < maxProgress) {
delay(progressDelayMillis)
currentProgress += progressIncrement
}
}
次の図に示すように、コルーチンが遅延時間中に待機している間、メインスレッドはブロックされません。
コルーチンは、目的の間隔値を指定して delay() 関数を呼び出した後、実行を一時停止します(ただし、ブロックしません)。遅延時間後、コルーチンは実行を再開して currentProgress プロパティの値を更新します。
<メモ開始>
suspend fun run() {
while (currentProgress < maxProgress) {
delay(progressDelayMillis)
currentProgress += progressIncrement
}
}
delayがあるおかげで、1秒づつタイマーが進んでいく。なので、delayをなくすと一瞬でプログレスバーが100%になる。
<メモ終了>
4.レースを開始する
ユーザーが [Start] ボタンを押したとき、2 つのプレーヤー インスタンスでそれぞれ suspend 関数 run() を呼び出して「レースを開始」する必要があります。そのためには、run() 関数を呼び出すコルーチンを起動します。
レースを開始するためのコルーチンを起動するときは、両方のプレーヤーについて次の点を確認する必要があります。
- [Start] ボタンをクリックすると(コルーチンが起動すると)、すぐに走行が開始する。
- [Pause] ボタンまたは [Reset] ボタンをクリックすると、それぞれレースを一時停止するかリセットする(コルーチンがキャンセルされる)。
- アプリを閉じると、キャンセルが適切に処理される(すべてのコルーチンがキャンセルされ、ライフサイクルにバインドされる)。
最初の Codelab で、suspend 関数は別の suspend 関数からしか呼び出せないことを学習しました。コンポーザブル内から suspend 関数を安全に呼び出すには、LaunchedEffect() コンポーザブルを使用する必要があります。LaunchedEffect() コンポーザブルはコンポジションに残っている限り、指定された suspend 関数を実行します。コンポーズ可能な関数 LaunchedEffect() を使用すると、次のすべてを行うことができます。
- LaunchedEffect() コンポーザブルを使用すると、コンポーザブルから suspend 関数を安全に呼び出すことができます。
- LaunchedEffect() 関数は、コンポジションに入ると、コードブロックがパラメータとして渡されたコルーチンを起動します。コンポジションに残っている限り、指定された suspend 関数を実行します。ユーザーが Race Tracker アプリの [Start] ボタンをクリックすると、LaunchedEffect() はコンポジションに入り、コルーチンを起動して進行状況を更新します。
- LaunchedEffect() がコンポジションを出ると、コルーチンはキャンセルされます。アプリでユーザーが [Reset] ボタンまたは [Pause] ボタンをクリックすると、LaunchedEffect() がコンポジションから削除され、それが起動したコルーチンもキャンセルされます。
RaceTracker アプリの場合、ディスパッチャを明示的に提供する必要はありません(LaunchedEffect() によって処理されるため)。
レースを開始するには、プレーヤーごとに run() 関数を呼び出して、次の手順を行います。
- com.example.racetracker.ui パッケージ内の RaceTrackerApp.kt ファイルを開きます。
- RaceTrackerApp() コンポーザブルに移動し、raceInProgress の定義の後の行に LaunchedEffect() コンポーザブルの呼び出しを追加します。
@Composable
fun RaceTrackerApp() {
...
var raceInProgress by remember { mutableStateOf(false) }
LaunchedEffect {
}
RaceTrackerScreen(...)
}
- playerOne または playerTwo のインスタンスが別のインスタンスに置き換えられた場合、LaunchedEffect() は起動したコルーチンをいったんキャンセルして再起動する必要があります。そのためには、playerOne オブジェクトと playerTwo オブジェクトを key として LaunchedEffect に追加します。テキスト値が変更されたときに Text() コンポーザブルが再コンポーズされる仕組みと同様に、LaunchedEffect() のいずれかの重要な引数が変更されると、それが起動したコルーチンはいったんキャンセルされた後、再起動されます。
LaunchedEffect(playerOne, playerTwo) {
}
playerOne.run() 関数と playerTwo.run() 関数の呼び出しを追加します。
@Composable
fun RaceTrackerApp() {
...
var raceInProgress by remember { mutableStateOf(false) }
LaunchedEffect(playerOne, playerTwo) {
playerOne.run()
playerTwo.run()
}
RaceTrackerScreen(...)
}
- LaunchedEffect() ブロックを if 条件でラップします。この状態の初期値は false です。ユーザーが [Start] ボタンをクリックし、LaunchedEffect() が実行されると、raceInProgress 状態の値が true に更新されます。
if (raceInProgress) {
LaunchedEffect(playerOne, playerTwo) {
playerOne.run()
playerTwo.run()
}
}
<メモ開始>
LaunchedEffect に追加します。テキスト値が変更されたときに Text() コンポーザブルが再コンポーズされる仕組みと同様に、LaunchedEffect() のいずれかの重要な引数が変更されると、それが起動したコルーチンはいったんキャンセルされた後、再起動されます。
どうゆう意味かわからない。 ここの記述を読む限り、playerOne.run()が呼ばれるたびに引数のplayerOneが更新されるので、LaunchedEffect(Coroutine Scope)自体がキャンセルされてもう一度最初から再起動されるという意味?
<メモ終了>
- raceInProgress フラグを false に更新してレースを終了します。この値は、ユーザーが [Pause] ボタンをクリックした場合でも false に設定されます。この値が false に設定されると、LaunchedEffect() により、起動されたすべてのコルーチンは確実にキャンセルされます。
LaunchedEffect(playerOne, playerTwo) {
playerOne.run()
playerTwo.run()
raceInProgress = false
}
- アプリを実行し、[Start] ボタンをクリックします。プレーヤー 2 がレースを開始する前に、プレーヤー 1 がレースを完了していることがわかります。次の動画をご覧ください。
公平なレースではないようです。次のセクションでは、同時実行タスクを起動して、両方のプレーヤーが同時に走行できるようにする方法を学習し、コンセプトを理解してこの動作を実装します。
5.構造化された同時実行
コルーチンを使用してコードを記述する方法は、構造化された同時実行と呼ばれます。この種類のプログラミングにより、コードの読みやすさが改善し、開発時間が短縮します。構造化された同時実行とは、コルーチンに階層があるということです。つまり、タスクによってサブタスクが起動され、次いでサブタスクが起動されます。この階層の単位はコルーチン スコープと呼ばれます。コルーチン スコープは常にライフサイクルに関連付ける必要があります。
コルーチン API は、この構造化された同時実行に準拠した設計となっています。suspend としてマークされていない関数から suspend 関数を呼び出すことはできません。この制限により、launch などのコルーチン ビルダーから suspend 関数を確実に呼び出すことができます。これらのビルダーは、今度は CoroutineScope に関連付けられます。
<メモ開始>
親コルーチン、子コルーチンが呼ばれていく様子は以下の画像の図を見るとよりイメージが湧く。
<メモ終了>
6.同時実行タスクを起動する
- 両方の参加者を同時に走行させるには、2 つのコルーチンを別々に起動し、run() 関数の各呼び出しをこれらのコルーチン内に移動します。playerOne.run() の呼び出しを launch ビルダーでラップします。
LaunchedEffect(playerOne, playerTwo) {
launch { playerOne.run() }
playerTwo.run()
raceInProgress = false
}
- 同様に、playerTwo.run() 関数の呼び出しを launch ビルダーでラップします。この変更により、アプリは同時に実行される 2 つのコルーチンを起動します。両方のプレーヤーが同時に走行できるようになりました。
LaunchedEffect(playerOne, playerTwo) {
launch { playerOne.run() }
launch { playerTwo.run() }
raceInProgress = false
}
- アプリを実行し、[Start] ボタンをクリックします。レースが開始される見込みでしたが、予想とは異なりボタンのテキストが [Start] に戻ります。
両方のプレーヤーが走行を完了したら、Race Tracker アプリは [Pause] ボタンのテキストを [Start] にリセットする必要があります。ただしここでは、プレーヤーのレースの完了を待つのではなく、コルーチンが起動されるとすぐにアプリが raceInProgress を更新します。
LaunchedEffect(playerOne, playerTwo) {
launch {playerOne.run() }
launch {playerTwo.run() }
raceInProgress = false // This will update the state immediately, without waiting for players to finish run() execution.
}
次の理由により、raceInProgress フラグがすぐに更新されます。
- launch ビルダー関数は、playerOne.run() を実行するコルーチンを起動し、すぐにコードブロックの次の行を実行します。
- playerTwo.run() 関数を実行する 2 番目の launch ビルダー関数でも、同じ実行フローが発生します。
- 2 番目の launch ビルダーが返されると、すぐに raceInProgress フラグが更新されます。この場合、ボタンはすぐに [スタート] ボタンとなり、レースは開始されません。
coroutineScope suspend 関数は CoroutineScope を作成し、指定された suspend ブロックを現在のスコープで呼び出します。スコープは、LaunchedEffect() スコープから coroutineContext を継承します。
<メモ開始>
launch ビルダー関数は、playerOne.run() を実行するコルーチンを起動し、すぐにコードブロックの次の行を実行します。
if (raceInProgress) {
LaunchedEffect(playerOne, playerTwo) {
launch {
playerOne.run() ①
}
playerTwo.run() ②
②の処理が終わってから以下処理が呼ばれる
println("println start")
raceInProgress = false
println("println end")
}
}
上記プログラムのように、launch関数は、launch内の処理とその次の行の処理を同時実行する。 それ以下の処理は同期的に動く ①、②同時に動く。
<メモ終了>
このスコープは、指定されたブロックとそのすべての子コルーチンが完了するとすぐに返されます。RaceTracker アプリの場合、両方の参加者オブジェクトが run() 関数の実行を終了すると、返されます。
- raceInProgress フラグを更新する前に playerOne と playerTwo の run() 関数の実行が完了するように、両方の起動ビルダーを coroutineScope ブロックでラップします。
LaunchedEffect(playerOne, playerTwo) {
coroutineScope {
launch { playerOne.run() }
launch { playerTwo.run() }
}
raceInProgress = false
}
-
[Start] ボタンをクリックします。プレーヤー 2 はプレーヤー 1 よりも速く走行します。レースが終了すると(両方のプレーヤーが 100% の進行状況に達すると)、[Pause] ボタンが [Start] ボタンに変わります。[Reset] ボタンをクリックすると、レースをリセットしてシミュレーションを再実行できます。レースを次の動画で示します。
<メモ開始>
なぜreset関数をよんだらしたらcanselされるのか?
// Resetボタンを押した時の処理
var raceInProgress by remember { mutableStateOf(false) }
if (raceInProgress) {
LaunchedEffect(playerOne, playerTwo) { リセットボタンを押すとこの子ルーチンがキャンセルされる
launch {
playerOne.run()
}
launch {
playerTwo.run()
}
raceInProgress = false
}
}
RaceTrackerScreen(
playerOne = playerOne,
playerTwo = playerTwo,
isRunning = raceInProgress,
onRunStateChange = { raceInProgress = it }
)
RaceControls(
isRunning = isRunning,
onRunStateChange = onRunStateChange,
onReset = {
playerOne.reset()
playerTwo.reset()
onRunStateChange(false)
}
)
上記コードを見ると、raceInProgressの値が変わるとifの判定が変化し、その中の処理が中断されていることがわかる。
分かりやすい例で考えると、これはDialogの表示をif文で制御するのと同じ原理な感じがした。
var isShowDialog by remember { mutableStateOf(false) }
if (isShowDialog) {
Dialog() ここで表示時に5秒間通信が合ったりした時途中でクローズしたらキャンセルされるのと似ている
}
Screen(
closeDialog = { isShowDialog = false }
ShowDialog = { isShowDialog = true }
)
<メモ終了>
- LaunchedEffect() ブロックが実行されると、制御は coroutineScope{..} ブロックに移ります。
- coroutineScope ブロックは、両方のコルーチンを同時に起動し、実行が完了するのを待ちます。
- 実行が完了すると、raceInProgress フラグが更新されます。
coroutineScope ブロックは、ブロック内のすべてのコードの実行が完了した後にのみ、実行を継続します。ブロックの外のコードでは、同時実行の有無は実装の詳細にすぎません。このコーディング スタイルは、同時実行プログラミングに対する構造化されたアプローチを提供するもので、構造化された同時実行と呼ばれます。
レースの完了後に [Reset] ボタンをクリックすると、コルーチンがキャンセルされ、両方のプレーヤーの進行状況が 0 にリセットされます。
ユーザーが [Reset] ボタンをクリックしたときにコルーチンがどのようにキャンセルされるかを確認するには、次の手順を行います。
- 次のコードに示すように、run() メソッドの本体を try-catch ブロックでラップします。
suspend fun run() {
try {
while (currentProgress < maxProgress) {
delay(progressDelayMillis)
currentProgress += progressIncrement
}
} catch (e: CancellationException) {
Log.e("RaceParticipant", "$name: ${e.message}")
throw e // Always re-throw CancellationException.
}
}
- アプリを実行し、[Start] ボタンをクリックします。
- 進行状況の数値が増えたら、[Reset] ボタンをクリックします。
- 次のメッセージが Logcat に出力されていることを確認します。
Player 1: StandaloneCoroutine was cancelled
Player 2: StandaloneCoroutine was cancelled