先日、ドリーム・アーツでは、売場ノートというアプリを Android に対応させた。
全面的に Coroutine を使って非同期処理を書いたので、そのとき理解のために実験した内容について備忘がてら書いておく。
前提
Kotlin 1.4.21
Android Studio 4.1.1
dependencies {
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9'
}
Coroutine の動作をおさらいする
launch とか async/await がどういう順番で呼び出されるかといったことはここでは話題とはせず、Coroutine の利用シーンに絞って、Android アプリ内でどのようにコードが動くのかといったことを記述する。
Coroutine の典型的な利用シーンとして、何らかのユーザーの操作をトリガーとして HTTP をリクエストしてそのレスポンスでビューを更新するというものがある。
AsyncTask による旧来の非同期処理パターン
Android において従来この手のパターンは、伝統的に AsyncTask
を利用してきた。
System.out.println("1. Begin: thread=${Thread.currentThread().name}")
object : AsyncTask<Void, Int, String?>() {
override fun doInBackground(vararg params: Void?): String? {
System.out.println("2-1. AsyncTask started: thread=${Thread.currentThread().name}")
Thread.sleep(1000)
return "OK"
}
override fun onPostExecute(result: String?) {
System.out.println("2-2. AsyncTask ended: thread=${Thread.currentThread().name}")
}
}.execute(null)
System.out.println("3. End: thread=${Thread.currentThread().name}")
これを実行してみた結果、以下のように Logcat に出力される。
I/System.out: 1. Begin: thread=main
I/System.out: 3. End: thread=main
2-1. AsyncTask started: thread=AsyncTask #1
2-2. AsyncTask ended: thread=main
AsyncTask#doInBackground
が実行されるのは、ThreadPoolExecutor
で管理されるバックグラウンドスレッドであって、メインスレッドで動くUIの操作に影響を及ぼさない。
onPostExecute(result)
をオーバーライドすると、このメソッドはメインスレッドで呼び出されるのでここでUIを操作することができるのである。
この方法の問題点は、AsyncTask
のライフサイクルが、アクティビティやフラグメントのライフサイクルと切り離されているため、タスク完了時にはすでにビューは存在しないといった場合もあり、そういった状態を適切に扱うのは細心の注意が必要である。
Coroutie による非同期処理パターン
画面の回転などによる設定の変更に追随するために、アクティビティやフラグメントといったUIコントローラーでデータを保持するのではなく、ViewModel
でデータを保持することが一般的だ。androidx.lifecycle:lifecycle-viewmodel-ktx
によって、ViewModel にはインスタンスごとに専用の CoroutineScope
である viewModelScope
が提供されている。
この CoroutineScope
を利用すれば、画面のライフサイクルにあわせて自動的に
System.out.println("1. Begin: thread=${Thread.currentThread().name}")
viewModel.viewModelScope.launch(Dispatchers.Main) {
// 上の行で Dispatchers.Main を指定しているが、指定しなくてもメインスレッドになる
withContext(Dispatchers.IO) {
System.out.println("2-1. AsyncTask started: thread=${Thread.currentThread().name}")
Thread.sleep(1000)
}
System.out.println("2-2. AsyncTask ended: thread=${Thread.currentThread().name}")
}
System.out.println("3. End: thread=${Thread.currentThread().name}")
これを実行してみた結果は以下の通りである。
I/System.out: 1. Begin: thread=main
I/System.out: 3. End: thread=main
I/System.out: 2-1. AsyncTask started: thread=DefaultDispatcher-worker-1
I/System.out: 2-2. AsyncTask ended: thread=main
ViewModel
の仕組みと、Coroutine
によって、非同期処理用にクラスやインスタンスを作成せずとも自然な流れで処理を記述することが可能になる。
suspend 関数とは何なのか
上の Coroutine のコードを suspend 関数を用いて別メソッドに切り出してみる。
private fun testCoroutine() {
System.out.println("1. Begin: thread=${Thread.currentThread().name}")
viewModel.viewModelScope.launch {
asyncTask() // suspend
System.out.println("2-2. AsyncTask ended: thread=${Thread.currentThread().name}")
}
System.out.println("3. End: thread=${Thread.currentThread().name}")
}
private suspend fun asyncTask() {
System.out.println("2-1. AsyncTask started: thread=${Thread.currentThread().name}")
Thread.sleep(1000)
}
このコードを実行すると以下のようにログに出力され、メインスレッドはブロッキングしてしまいUIが固まる。
I/System.out: 1. Begin: thread=main
I/System.out: 2-1. AsyncTask started: thread=main
I/System.out: 2-2. AsyncTask ended: thread=main
I/System.out: 3. End: thread=main
suspend を付けたからといって、自動的にバックグラウンドスレッドになるわけではない。
バックグラウンドスレッドでの実行にしたい場合はやはり明示的にコンテキストを指定する必要がある。以下のように書くことができる。
private suspend fun asyncTask() = withContext(Dispatchers.IO) {
System.out.println("2-1. AsyncTask started: thread=${Thread.currentThread().name}")
Thread.sleep(1000)
}
では、suspend 関数とは何なのか?
suspend 関数とは非同期処理のための仕組みであって、**「別の suspend 関数を呼び出すために使用する」**のである。
HTTP でサーバー側のAPIを呼び出すようなケースを考えてみる。
典型的なコードは以下のようなものになるだろう。
private suspend fun callAPI(): SomeData? {
// HTTPリクエストのためのパラメーターを生成する
val req = buildRequest()
// HTTPリクエストを発行する
val res = sendRequest(req) // suspend 関数
// HTTPレスポンスで取得したデータ(JSONなど)を解析する
return parseResponse(res)
}
これらの処理のうち、HTTPでの通信や、ストレージへの保存、ストレージからの読み込みはIOを伴う処理で、CPUの計算に比べて比較にならないくらい遅い。
だから非同期で処理したい。
従来はスレッドプール内のワーカースレッドにこのコードブロック全体を渡してそこで処理して、Future
などで結果を受け取るようなやり方か、Rxな仕組みで非同期をどうしても意識せざるを得ない書き方を強制されてきた。
実はCoroutine を用いると、suspend 関数の内部では、別の suspend 関数を呼び出す箇所まででタスクを分割して、そのタスクを順次処理していくということが行われる。
private suspend fun callAPI(): SomeData? {
// -------- コードブロック[1] ここから
// HTTPリクエストのためのパラメーターを生成する
val req = buildRequest()
// HTTPリクエストを発行する
val res = sendRequest(req) // suspend 関数
// -------- コードブロック[1] ここまで
// -------- コードブロック[2] ここから
// HTTPレスポンスで取得したデータ(JSONなど)を解析する
return parseResponse(res)
// -------- コードブロック[2] ここまで
}
一連の流れのように見えるが、細かいタスクに分割されて Coroutine のコンテキストスコープのスレッドで順次実行される。
この様子は実際に上のようなコードを書いてみてデバッガーでブレークポイントを置いてみるとわかり易い。
suspend 関数の呼び出しを超えて次の行へステップ実行できないはずだ。
そして呼び出した先の suspend 関数は、さらに別の suspend 関数を呼び出すときに同様にブロックに分割されて、そこで処理を保留する。(suspend する)
呼び出していった先、これ以上別の suspend 関数を呼び出さないところまでたどり着いて、そのブロックが終わったらコールスタック上で保留されているブロックを順番に実行していく。
そしてそれらが終わったらさらに上の suspend 関数で保留されたブロックも実行する。
つまり、Rx なら then とかで書いていたブロックをそのままひと続きの流れで書くことが可能になる。
Coroutine を使った標準パターン
以上のことを理解すれば、以下のコードでメインスレッドをブロックすることなく、時間のかかる呼び出しの結果をメインスレッドでそのまま扱うことができることがわかる。
private fun testCoroutine() {
viewModel.viewModelScope.launch {
// ここはメインスレッドで呼び出される
// asyncTask() は suspend 関数なので、呼び出してもメインスレッドをブロックしない
val result = asyncTask()
// asyncTask() が終了したら、メインスレッドでこのブロックが実行される
viewModel.textValue.value = result
}
}
private suspend fun asyncTask(): String = withContext(Dispatchers.IO) {
// ここは DefaultDispatcher-worker-X というバックグラウンドスレッドで呼び出される
Thread.sleep(1000)
return@withContext "RESULT"
}
まとめ
Coroutine を使うことで、やっていることに集中できるコードになり、AsyncTask とか Rx とか何をやってたのだろうという気分になれたのはよかった。