[Kotlin]coroutineのキャンセル時に実行したい処理の扱い方

仕事でAndroidのコードを書いていて、たまにsuspend functionの中でもキャンセルされても確実に動かさなければ問題が起きることがある。このような場合、怪しげな実装になってしまったり、どうにも対処方がわからないというケースに遭遇したことがある。

例えば、ユーザ操作ログの送信処理。仕事でアプリを作っているとこの手の処理はよくあること。その他、アプリ起動中に動的に変化するようなデータをアプリを終了する前に確実に保存しなければならない場合もたまにある。

coroutineの機能をよくよく調べてみると、そのようなケースに対応できる手段がいくつかあることがわかったのでメモしておく。

参考:Kotlin Coroutines DEEP DIVE

NonCancellable

例えば、キャンセルされてDBの変更をロールバックしたい場合。このような場合は

withContext(NonCancellable)

を使うのが良い。これにより、キャンセル後でもアクティブなJobが利用でき、suspend関数を呼び出すことができる。

sudpend fun main(): Unit = coroutineScope {
  val job = Job()
  launch(job) {
    try {
      delay(200)
      println("Coroutine finished")
    } finally {
      println("finally")
      withContext(NonCancellable) {
        delay(1000L)
        println("NonCancellable完了")
      }
    }
  }
  delay(100)
  job.cancelAndJoin()
  println("完了")
}

実行時の出力は以下のようになる。

finally
NonCancellable完了
完了

invokeOnCompletion

もう一つの方法がこれ。JobのinvokeOnCompletion関数。Jobが終端状態(Completed, Cancelled)になったときに何らかの処理をしたいときに使われる。

sudpend fun main(): Unit = coroutineScope {
  val job = launch {
    delay(200)
  }
  job.invokeOnCompletion { exception: Throwable? ->
    println("完了")
  }  
  delay(400)
  job.cancelAndJoin()
}
完了

なお、このメソッドの引数には例外が入る。

  • 例外が発生せず完了した場合はnull
  • キャンセルされた場合はCancellationException
  • 終了の原因となった例外

jobがinvokeOnCompletionを呼び出す前に完了した場合は、これを呼んだ後直ちに渡した処理が呼び出される。また、引数のonCancelling , invokeImmediately によりカスタム可能。

別のScopeを渡す

これは冒頭で触れたように、AndroidのUseCaseなどの処理でユーザー操作を処理しつつ、同時にユーザー操作ログを送信したいような場合、よくあるのが以下のような処理だが、いくつか問題点がある。

class ShowUserDataUseCase(
    private val repository: UserDataRepository,
    private val view: UserDataView
) {

    suspend fun showUserData() = coroutineScope {
        val name = async { repository.getName() }
        val friends = async { repository.getFriends() }
        val profile = async { repository.getProfile() }
        val user = User {
            name = name.await()
            friends = friends.await()
            profile = profile.await()
        }
        view.show(user)
        
        // このlaunchはこの関数自体が終了を待つため実質意味がない
        // さらに、途中の処理が例外でキャンセルされると実行されない
        launch { repository.notifyUserViewed() } // ログの送信
    }
}

---

fun onCreate() {
  viewModelScope.launch {
    _progressBar.value = true
    showUserData()  // ログの送信まで終わらないとUI処理も進まない
    _progressBar.value = false
  }
}

コメントにもあるように、1つは、最後のlaunchは呼び出した関数自体が完了を待つため、実質意味がなくなっている。そのため、それに上記の例のようにプログレスバーを表示するようなUIの処理も引きづられて必要以上にユーザーを待たせてしまう。

また、もうひとつはキャンセル時の問題で、例外が発生したときは他のコルーチンの処理もキャンセルされる。したがって、getProfile()が例外を投げた場合、getName(), getFriends()の処理もキャンセルするのは自然だが、ログの送信までキャンセルされてしまうのは困ることになる。

このような場合にはこのようなメインの処理に影響を受けない別のスコープを用意するのが良い。

val analyticsScope = CoroutineScope(SupervisorJob())

(単体テストやScopeを管理するためにコンストラクタで注入するのが良い)

class ShowUserDataUseCase(
    private val repository: UserDataRepository,
    private val view: UserDataView,
    private val analyticsScope: CoroutineScope
) {

    suspend fun showUserData() = coroutineScope {
        val name = async { repository.getName() }
        val friends = async { repository.getFriends() }
        val profile = async { repository.getProfile() }
        val user = User {
            name = name.await()
            friends = friends.await()
            profile = profile.await()
        }
        view.show(user)
        
        // 別のScopeで実行する
        analyticsScope.launch { repository.notifyUserViewed() }
    }
}

注入されたscopeで処理を実行することは一般的である。scopeを渡すことは、そのクラスが独立した呼び出しをすることをわかりやすく知らせることにもなり、これはこのクラスのsuspend関数がすべての処理を待つわけではないということを意味するようになる。逆に、このようなscopeをインジェクトしないクラスのsuspend関数は、すべての処理を待つということの表明にもなる。

コメント

タイトルとURLをコピーしました