2018年10月30日にリリースされた Kotlin 1.3 で、 Kotlin Coroutines が stable になりました。概念などの説明は省きますが、非同期的な処理を同期的にすっきり書くことができる仕組みになります。
活用の幅が広そうなコルーチンですが、「Web APIやDBを簡単に扱いたい」というのが、Android 開発における大きなニーズなのではないかと思います。
この記事では、Android で、 コルーチンと Retrofit を使って Web API 叩く方法をパターン別に紹介します。また、JUnit と mockk を使ってテストを書く方法にも触れます。RxJava を使っている人向けに各例が RxJava の何に相当しそうかということも書いたりしていますが、使っていない方は読み飛ばしていただいても大丈夫です。
公式のドキュメントなどを読めば自明なことばかりかと思いますが、「コルーチンの概要的な部分はなんとなく分かった気がするが、まだ導入できていない」方などに、例の1つとして参考にしていただければ幸いです。
APIを叩く
APIを叩いて、その結果をUIに反映したりしたいときの処理です。RxJava では Single
や Completable
を使ったりして書くことが多いと思います。
GlobalScope.launch(Dispatchers.Main) {
val list = withContext(Dispatchers.Default) {
API呼び出し() // List<Item> を返す
}
updateUI(list) // メインスレッドで実行
}
API呼び出しの結果を返す withContext は関数に切り出すこともできます。
suspend fun 非メインスレッドでリストを取得(): List<Item> =
withContext(Dispatchers.Default) {
API呼び出し() // List<Item> を返す
}
GlobalScope.launch(Dispatchers.Main) {
val list = 非メインスレッドでリストを取得()
updateUI(list) // メインスレッドで実行
}
簡易的な説明
GlobalScope.launch
でコルーチンを開始できます。(説明のためまず GlobalScope
で説明しますが、実際には画面やモデルなどと結びつけて、ライフサイクルに合わせてキャンセル、破棄する実装をしたほうが良いことが多いと思います。後述)
GlobalScope.launch(Dispatchers.Main)
は、ラムダの内容を非同期的にメインスレッドで実行します。 Dispatchers に関しては、 Dispatchers.Main
のほかに、ワーカースレッドで実行させる Dispatchers.Default
や、IO処理に使う Dispatchers.IO
があります。
withContext(Dispatchers.Default)
は、ラムダの内容が実行し終わるまで、呼び出し元の処理を中断し、返り値がある場合は返却します。
そのため、上記の処理は以下のようになります。
- GlobalScope.launch が呼び出され、以下を非同期にどこかで実行することを予約する
- API呼び出し()を ワーカースレッドで実行し、 list に代入する
- updateUI(list) をメインスレッドで実行する
withContext のような、呼び出し元の処理を中断する関数を「中断関数(suspending function)」と呼びます。 withContext の定義は以下のようになっています。
public suspend fun <T> withContext(
context: CoroutineContext,
block: suspend CoroutineScope.() -> T
): T
中断関数であることをコンパイラに明示するために、中断関数には suspend キーワードをつける必要があります。
API A を叩いて、結果を待って API B を叩く
RxJava の flatMap
などで実現できる、「あるAPIを叩き、その返り値が返ってくるのを待ってから別のAPIを叩く」ような処理です。
suspend fun 非メインスレッドでリストを取得1(): List<Item> = List<Item> =
withContext(Dispatchers.Default) {
API呼び出し(list) // List<Item> を返す
}
suspend fun 非メインスレッドでリストを取得2(list: List<Item>): List<Item> =
withContext(Dispatchers.Default) {
API呼び出し(list) // List<Item> を返す
}
GlobalScope.launch(Dispatchers.Main) {
val list = 非メインスレッドでリストを取得1()
val list2 = 非メインスレッドでリストを取得2(list2)
updateUI(list2) // メインスレッドで実行
}
ただ並べて呼び出すだけで待ち合わせ処理を実現することができます。
API A と API B を並列で叩いて待ち合わせ
非メインスレッドで、APIとかを「複数並列」で叩いて、両方の結果が帰ってきたら、その結果をUIに反映したりしたいときの処理です。RxJava ではObservable.zip()
などで実装するかと思います。
並列で行われる処理を書きたいときは async
を使います。返り値は Deferred<T>
です。
fun async非メインスレッドでリストを取得1(): Deferred<List<Item>> = GlobalScope.async(Dispatchers.Default) {
API呼び出し() // List<Item> を返す
}
fun async非メインスレッドでリストを取得2(): Deferred<List<Item>> = GlobalScope.async(Dispatchers.Default) {
API呼び出し() // List<Item> を返す
}
async は、呼び出しが行われたときに指定されたコンテキストで処理を開始し、Deferred<T>
を返します。Deferred<T>
の結果は、返ってきたときに .await()
で T
を取り出すことができます。
suspend fun 待ち合わせパターン() {
val deferred1 = async非メインスレッドでリストを取得1()
val deferred2 = async非メインスレッドでリストを取得2()
return 返り値を使う関数(deferred1.await(), deferred2.await())
}
この関数の処理は.await()
の行で、結果が返ってくるまで中断されます。
エンドポイントA→B→C… と1秒の間隔を開けながら、APIをN回叩く
RxJava なら concatWith()
を使うかもしれません。コルーチンを使う場合は for や repeat などで実行すれば良いです。
GlobalScope.launch {
urls.forEach { url ->
delay(1_000)
withContext(Dispatchers.Default) { fetch(url) }
}
// 終わったときの処理
}
ループ1秒間隔などで実行したい場合などは、 delay を使うと、指定した時間(ミリ秒)処理を中断することができます。
リトライ
repeat でリトライ回数を指定し、成功した場合にループを抜ければ良いです。
GlobalScope.launch(Dispatchers.Main) {
repeat(5) {
try {
val list = 非同期実行する処理()
updateUI(list) // メインスレッドで実行
return@launch
} catch (e: Exception) {
}
}
// エラー時の処理
}
catch の中で、 e がリトライしてもしょうがない内容であれば、 return@repeat したほうが良いと思います。
エラーハンドリング
Exception が throw された場合は、 catch する必要があります コンテキストを切り替えたり、supervisorScope などを使用しない限りは、launchで呼び出したコルーチンで発生した例外は、呼び出し元のlaunchまで伝播します。
job = GlobalScope.launch(Dispatchers.Main) {
try {
val list = 非同期実行する処理()
updateUI(list) // メインスレッドで実行
} catch (e: Exception) {
// エラー処理
}
}
エラーハンドリング処理はだいたい共通の処理になりがちになると思うので、interface のデフォルト実装にエラーを渡したり、 CoroutineExceptionHandler を launch の引数に与えたりすることで、処理を共通化することもできます。
val errorHandler = CoroutineExceptionHandler { _, exception ->
// exception のハンドリング
Timber.e(exception)
}
GlobalScope.launch(errorHandler) {
// 何かexceptionの起こるかもしれない処理
}
タイムアウト
タイムアウト時間を設定するには、中断関数 withTimeout を使います。実装は以下のようになっています。
public suspend fun <T> withTimeout(timeMillis: Long, block: suspend CoroutineScope.() -> T): T {
if (timeMillis <= 0L) throw TimeoutCancellationException("Timed out immediately")
return suspendCoroutineUninterceptedOrReturn { uCont ->
setupTimeout(TimeoutCoroutine(timeMillis, uCont), block)
}
}
タイムアウトした場合は TimeoutCancellationException が発生します。
try {
val result = withTimeout(TIMEOUT_MILLISECONDS) {
api.post(PostModel(text)).await()
}
// result を使った何か
} catch (e: TimeoutCancellationException) {
// タイムアウトのときにする処理
}
withTimeoutOrNull を使えばタイムアウトした場合に null を返すことができるので、特別タイムアウトしたときに何もしたくない場合に便利です。
val result = withTimeoutOrNull(TIMEOUT_MILLISECONDS) {
api.post(PostModel(text)).await()
}
処理のキャンセル
Android で使用する場合は、 RxJava などと同じように、画面のライフサイクルに合わせて、処理をキャンセルしたいということが多いと思います。
GlobalScope.launch
は返り値として Job を返すので、 これに生えている cancel() を呼べば、処理をキャンセルできます。たとえば、Activity の onDestroy() で処理をキャンセルしたい場合は、以下のようにします。
class MainActivity : AppCompatActivity() {
private lateinit var job: Job
override fun onCreate() {
job = GlobalScope.launch(Dispatchers.Main) {
val list = 非同期実行する処理()
updateUI(list) // メインスレッドで実行
}
}
override fun onDestroy() {
job.cancel()
}
}
ところで、GlobalScope.launch を使う場合、生存期間はアプリ終了までになります。そのためラムダの処理が終了しない場合は、アプリの終了までインスタンスが生存し続けることになってしまいます。
そのため今までの例のように GlobalScope.launch でも問題なく動くパターンもあると思うのですが、実際には GlobalScope.launch ではなく、 ActivityにCoroutineScope を実装して、job + Dispatchers.Main
をコンテキストに指定したほうが良いと思います。Activityにjobが紐付いている事も明示できるというメリットもあります。
class MainActivity : AppCompatActivity(), CoroutineScope {
private val job = Job()
override val coroutineContext = job + Dispatchers.Main
override fun onCreate() {
this.launch {
val list = 非同期実行する処理()
updateUI(list)) // メインスレッドで実行
}
this.launch {
val list = 別の非同期実行する処理()
update別のUI(list)) // メインスレッドで実行
}
}
override fun onDestroy() {
job.cancel()
}
}
Retrofit
WebAPI との通信には Retrofit を使っている人が多いと思うのですが、 RxJavaのCallAdapterFactoryを使っている場合は、かわりに JakeWharton/retrofit2-kotlin-coroutines-adapter を使うことができます。
val retrofit = Retrofit.Builder()
.baseUrl("https://example.com/")
.addCallAdapterFactory(CoroutineCallAdapterFactory())
.build()
interface MyService {
@GET("/user")
fun getUser(): Deferred<User>
}
addCallAdapterFactory に指定するだけで、Deferred で返してくれるようになります。
また、 Completable, Maybe, Single, Observable, Flowable をコルーチンと相互変換してくれるユーティリティ(kotlinx-coroutines-rx2)を使用することができます。
AdapterFactory を置き換えると使用しているところすべてに影響が出てしまいますが、定義は RxJava のまま置いておき、使用する際にコルーチンに変換すれば RxJava から一部だけコルーチンに置き換えることができます。
テスト
mockk というモックライブラリにコルーチンのサポートがあります。mockk 自体は以下のように使います。
val car = mockk<Car>()
every { car.drive(Direction.NORTH) } returns Outcome.OK
car.drive(Direction.NORTH) // returns OK
verify { car.drive(Direction.NORTH) }
コルーチンの処理をモックしたい場合は、every、verify などのメソッドの頭にco
をつけた coEvery や coVerify を使用することで、同じようにテストすることができます。
runBlocking {
val car = mockk<Car>()
coEvery { car.drive(Direction.NORTH) } returns Outcome.OK
car.drive(Direction.NORTH) // returns OK
coVerify { car.drive(Direction.NORTH) }
}
テストコードでも、同期的な処理と同じ構造で書けるようになるというコルーチンの恩恵が得られます。例えばテストケース上で何らかの exception が吐かれることを期待するテストを書きたい場合、RxJava+Mockito なら observer.assertError(Hoge::class.java)
など特別なメソッドを使う必要がありましたが、コルーチンではJUnitの機能を使って @Test(expected = Hoge::class)
のように素直に指定できます。
現在は Kotlin 1.2 以前に互換があるものと、Kotlin 1.3 版の両方が提供されているので、 Kotlin 1.3 で使用場合は、 Kotlin 1.3版 (1.9 や 1.8.13.kotlin13) を依存に指定しているか注意したほうが良いかもしれません。
testImplementation "io.mockk:mockk:1.9"
まとめ
詳しい説明は省きつつ、 Android でコルーチンを使って API などを叩く方法をパターン別に紹介しました。
依然として RxJava のほうが綺麗に書けるところもあると思いますが、 Single や Completable を使用しているところなどに関してはコルーチンのほうがシンプルに書けることが多いのではと思います。
非同期処理は今や必ずと言って良いほどGUIアプリケーションに登場する重要な要素でありながら複雑になりやすく、これをどれだけ可読性を高めて書けるのかは大きな課題だと思います。Kotlin Coroutines がそれに対する強力なアプローチなのは間違いないと思うので、最大限活用して保守性や安定性の高いコードにしていければなと思っています。
参考リンク
コルーチンの理解の助けになる説明や、参考になる実例のリンクです (この記事でも参考にさせていただいています)。
- ガッツリきちんと理解したい(公式)
- コルーチンの説明や実例を見たい
- モダンな Android アプリで、実際どう使われているのか見たい
- Kotlin 1.2 のときの内容だがわかりやすいもの