はじめに
Retrofit と Kotlin Coroutine Adapter を使ってHTTP通信を行うAndroidアプリを開発しているのですが、Coroutineのキャンセル後にHTTP通信の例外が発生すると、思いがけず例外が未キャッチになる → アプリがクラッシュする、という挙動があり、ハマったので、問題の内容と解決方法をシェアします。
問題の説明
コード例
例えば、Retrofitで次のようにHTTP API呼び出しを定義し
interface GitHubService {
@GET("users/{user}/repos")
fun listRepos(@Path("user") user: String): Deferred<List<Repo>>
}
これをViewModelから利用することを考えます1。
class MyViewModel : ViewModel() {
private val viewModelJob = Job()
private val uiScope = CoroutineScope(Dispatchers.Main + viewModelJob)
private val service: GitHubService by lazy {
// 略: Retrofitを使って GitHubService の実体を作成する
}
override fun onCleared() {
super.onCleared()
viewModelJob.cancel()
}
fun showMyGitHubRepos() {
uiScope.launch {
try {
val deferred = service.listRepos("kafumi")
val repos = deferred.await()
showRepos(repos)
} catch (e: Throwable) {
showError(e)
}
}
}
// ...
}
API呼び出しに成功したら showRepos()
を呼び出し、何らかの例外が発生したら、例外の種類によらず全てキャッチして showError()
を呼び出す、という実装です。HTTP通信まわりでどのような例外が発生しても、未キャッチ例外となることはないように見えます。
問題となるケース
さて、uiScope.launch
で作成されたCoroutineが await()
で中断している間 (= HTTP通信のレスポンスを待っている間) に、ユーザー操作によって画面が終了して、MyViewModel.onCleared()
が呼び出されたらどうなるでしょうか? つまり、HTTP通信中に画面が終了されて、その後にHTTP通信がエラーになる、というケースです。
今回のCoroutineは uiScope
をスコープとして作成されており、viewModelJob
が親Jobとして設定されているので、viewModelJob.cancel()
が呼ばれると即座にキャンセルされ、await()
から CancellationException が投げられます (参考: await のリファレンス)。
キャンセルされない Deferred
ここで、ViewModelのクリアによってCoroutineがキャンセルされ、await()
での中断もキャンセルされるのですが、service.listRepos("kafumi")
から返される Deferred
は実はキャンセルされていません。理由は、Kotlin Coroutine Adapterの実装にあります。
private class BodyCallAdapter<T>(
private val responseType: Type
) : CallAdapter<T, Deferred<T>> {
override fun responseType() = responseType
override fun adapt(call: Call<T>): Deferred<T> {
val deferred = CompletableDeferred<T>()
deferred.invokeOnCompletion {
if (deferred.isCancelled) {
call.cancel()
}
}
call.enqueue(object : Callback<T> {
override fun onFailure(call: Call<T>, t: Throwable) {
deferred.completeExceptionally(t)
}
override fun onResponse(call: Call<T>, response: Response<T>) {
deferred.complete(response)
}
})
return deferred
}
}
Kotlin Coroutine Adapterは、上記のように CompletableDeferred を作り、それを Deferred
として返すのですが、CompletableDeferred
を作るときに親Jobを設定していません (呼び出し側の CoroutineContext
が取得できないので仕方ありません)。アプリ側のCoroutineがキャンセルされたとしても、親子関係がないのでキャンセルが伝播せず、この CompletableDeferred
はキャンセルされない、というわけです。
なお、Coroutineのキャンセル時に await()
から CancellationException
が投げられますが、これは、await()
での中断がキャンセルされるために発生しており、CompletableDeferred
がキャンセルされているわけではありません。
キャッチされない例外
Coroutineがキャンセルされても、上記のように CompletableDeferred
はキャンセルされていないので、RetrofitによるHTTP通信もキャンセルされておらず、レスポンス待ちの状態が続いています。この状態で、HTTP通信が失敗して、例外が発生したらどうなるでしょうか。HTTP通信やJSONの変換で例外が発生すると、Retrofitから Callback.onFailure()
がコールバックされますが、このとき、Kotlin Coroutine Adapterは CompletableDeferred
の completeExceptionally() を呼び出します。
call.enqueue(object : Callback<T> {
override fun onFailure(call: Call<T>, t: Throwable) {
deferred.completeExceptionally(t)
}
この時点で deferred
はまだ未完了の CompletableDeferred
なので、completeExceptionally()
が呼ばれると、失敗として完了するための処理が実行されます。await()
における中断はCoroutineのキャンセルによりすでにキャンセルされているので、発生した例外は await の継続処理 (try-catch がある上記のMyViewHolder
のコード) には送られず、CoroutineExceptionHandler に送られます。MyViewHolder
では CoroutineContext
に CoroutineExceptionHandler
を設定していないので、これは、CoroutineExceptionHandlerのリファレンスの説明の通りに処理されます。
By default, when no handler is installed, uncaught exception are handled in the following way:
- If exception is CancellationException then it is ignored (because that is the supposed mechanism to cancel the running coroutine)
- Otherwise:
- if there is a Job in the context, then Job.cancel is invoked;
- Otherwise, all instances of CoroutineExceptionHandler found via ServiceLoader
- and current thread’s Thread.uncaughtExceptionHandler are invoked.
つまり、最終的に Thread.uncaughtExceptionHandler に未キャッチ例外として処理され、Androidの場合はアプリの強制終了を引き起こします。
問題の解決方法
案1: CoroutineExceptionHandlerを明示的に設定する
CoroutineContext
に CoroutineExceptionHandler
を設定することで、未キャッチ例外をアプリで処理して、Thread.uncaughtExceptionHandler
への送出を防ぐことができます。
val defaultExceptionHandler = CoroutineExceptionHandler { _, e ->
Log.w("Coroutine", "uncaught coroutine exception", e)
}
class MyViewModel : ViewModel() {
private val viewModelJob = Job()
private val uiScope = CoroutineScope(Dispatchers.Main + viewModelJob + defaultExceptionHandler)
手軽ではありますが、別のケースの例外も握りつぶしてしまっている、例外に気がつきにくくなる、という欠点もあります。
案2: Kotlin Coroutine Adapterを使うのをやめる
Kotlin Coroutine Adapterの利用をやめて、自前の処理で Call
をsuspend関数に変換することで、Coroutineキャンセル後の例外を無視する、という方法もあります。例えば、suspendCancellableCoroutine を使うことで、次のように Call
の拡張関数として実装できます2。
suspend fun <T> Call<T>.await(): Response<T> = suspendCancellableCoroutine { cont ->
enqueue(object : Callback<T> {
override fun onFailure(call: Call<T>, t: Throwable) {
if (!cont.isCancelled) {
cont.resumeWithException(t)
}
}
override fun onResponse(call: Call<T>, response: Response<T>) {
cont.resume(response)
}
})
cont.invokeOnCancellation {
cancel()
}
}
この場合、Retrofit のインターフェースは Call
を返すように修正する必要があります。
interface GitHubService {
@GET("users/{user}/repos")
fun listRepos(@Path("user") user: String): Call<List<Repo>>
}
呼び出し側の使い勝手は、Kotlin Coroutine Adapterを利用しているときと同等です。
uiScope.launch {
try {
val call = service.listRepos("kafumi")
val repos = call.await()
showRepos(repos)
} catch (e: Throwable) {
showError(e)
}
}
RetrofitのAdapterではsuspend関数を実装できませんが、Call
の拡張関数であればsuspend関数として実装でき、Deferred
を使わなくてもCoroutinesに適した処理に変換できます。
おまけ: Responseが不要な場合
Response
が不要な場合、await()
は次のように実装できます 2。
suspend fun <T> Call<T>.awaitBody(): T = suspendCancellableCoroutine { cont ->
enqueue(object : Callback<T> {
override fun onFailure(call: Call<T>, t: Throwable) {
if (!cont.isCancelled) {
cont.resumeWithException(t)
}
}
override fun onResponse(call: Call<T>, response: Response<T>) {
if (response.isSuccessful) {
cont.resume(response.body()!!)
} else if (!cont.isCancelled) {
cont.resumeWithException(HttpException(response))
}
}
})
cont.invokeOnCancellation {
cancel()
}
}
-
JetPackを使う際の推奨設計に従うなら、Repositoryモデルを採用し、ViewModelからRetrofitのAPIを直接呼び出すべきではないですが、今回はコード量を抑えるために簡略化しています ↩
-
kotlinx.coroutines の 1.1.0-alpha から
CancellableContinuation
のキャンセル後の例外は無視されるようになったので、1.1.0-alpha 以降のバージョンを使っている場合は、cont.isCancelled
の検査は不要で、失敗時に常にcont.resumeWithException()
を呼び出して問題ありません ↩ ↩2