23
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

RetrofitのKotlin Coroutine Adapterにひそむ罠

Last updated at Posted at 2019-01-05

はじめに

RetrofitKotlin 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は CompletableDeferredcompleteExceptionally() を呼び出します。

      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 では CoroutineContextCoroutineExceptionHandler を設定していないので、これは、CoroutineExceptionHandlerのリファレンスの説明の通りに処理されます。

By default, when no handler is installed, uncaught exception are handled in the following way:

つまり、最終的に Thread.uncaughtExceptionHandler に未キャッチ例外として処理され、Androidの場合はアプリの強制終了を引き起こします。

問題の解決方法

案1: CoroutineExceptionHandlerを明示的に設定する

CoroutineContextCoroutineExceptionHandler を設定することで、未キャッチ例外をアプリで処理して、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()
    }
}
  1. JetPackを使う際の推奨設計に従うなら、Repositoryモデルを採用し、ViewModelからRetrofitのAPIを直接呼び出すべきではないですが、今回はコード量を抑えるために簡略化しています

  2. kotlinx.coroutines1.1.0-alpha から CancellableContinuation のキャンセル後の例外は無視されるようになったので、1.1.0-alpha 以降のバージョンを使っている場合は、cont.isCancelled の検査は不要で、失敗時に常に cont.resumeWithException() を呼び出して問題ありません 2

23
17
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
23
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?