Now in REALITY Tech #73 Android非同期処理ばなし - ライフサイクル考慮 +ワンショット非同期処理をやる難しさ

まえがき

こんにちは、REALITYでAndroidエンジニアをしているmits_sidです。

皆さんはActivityやFragmentのライフサイクルを意識する必要があるコルーチンを実行するときどうしているでしょうか?
具体的にはonStart ~ onStopの間で1回だけ動いていて欲しい非同期処理を作りたい場合などです。

一昔前はlaunchWhenStartedを利用することで実現できましたが、現在は非推奨となっています。
この差し替えとしてよく例示されているのが repeatOnLifecycleですが、これは使い方を誤ると異なった振る舞いとなってしまいます。

今回はこの `launchWhenXXX` 系と `repeatOnLifecycle` 系の振る舞いの違いについて触れつつ、ライフサイクルを意識しつつワンショットの非同期処理を実行することの難しさについてお話しようと思います。

そもそもlaunchWhenXXX が非推奨の理由

公式では非推奨となった理由について、以下のように記載されています

launchWhenStarted is deprecated as it can lead to wasted resources in some cases. Replace with suspending repeatOnLifecycle to run the block whenever the Lifecycle state is at least Lifecycle.State.STARTED.

https://developer.android.com/reference/kotlin/androidx/lifecycle/LifecycleCoroutineScope#launchWhenStarted(kotlin.coroutines.SuspendFunction1)

これがどういうことかというと、launchWhenStartedは実行されたsuspend functionをアプリバックグラウンド中であっても一時停止して保持し続けている、というものです。

イメージとしては仮に4つのステートメントを持つsuspend functionが途中で中断されたとき、suspend functionは進行状況を保持して一時停止状態になり、再開時には続きのステートメントから処理が始まるような挙動です。

launchWhenStartedとアプリサスレジ時のシーケンスイメージ

この挙動が好ましいケースもありますが、コルーチンの処理にFlowの収集が絡んできた際には問題が発生する可能性があります。

警告: launchWhenX API 内で Flow を収集するのではなく、repeatOnLifecycle API を使用して Flow を収集することをおすすめします。Lifecycle が STOPPED の場合、前者の API はコルーチンをキャンセルするのではなく停止するため、アップストリーム フローはバックグラウンドでアクティブのままにされ、新しいアイテムが出力される可能性やリソースが浪費される可能性があります。

https://developer.android.com/topic/libraries/architecture/coroutines?hl=ja

アップストリームのFlow、つまりFlowでのイベントの送信元がアクティブのままになる、ということはどういうことなのでしょうか。

これは「Flowの収集が止まっている」ものの「Flow自体は生き続けている」と認識されます。つまり、Flowの購読者がまだ存在し続ける状態の挙動を取ります。
そのため、もしアプリバックグラウンド中でもFlowのイベント送信元のコルーチンが実行され続けるスコープであった場合(例. ViewModelScope等)、対象のアップストリームFlowでemitが実行された時はFlowの指定されたbufferに対してイベントが送信されたり、bufferが存在しない場合は送信元が一時停止したまま待機状態になる可能性があります。

launchWhenStartedでのFlow収集とアプリサスレジ時のシーケンスイメージ

このとき、紐付くコルーチンがアプリバックグラウンド中でも待機中処理としてぶら下がり続けることになるため、リソースを浪費していると言えるでしょう。
また、アプリ復帰時にコルーチンは停止状態から再開するため、アプリバックグラウンド時に送られていたイベントが収集され、場合によっては予期せぬ動作になる恐れがあります。

推奨されるやり方 repeatOnLifecycle について

repeatOnLifecycleは「関連する Lifecycle が少なくとも STARTED 状態にあるたびに実行し、Lifecycle が STOPPED になるとキャンセルする」という振る舞いをします。
つまり、アプリバックグランド時にはsuspend functionは一時停止せずキャンセルされ、アプリフォアグラウンド時に再度suspend functionが最初から実行されます

repeatOnLifecycleでのFlow収集とアプリサスレジ時のシーケンスイメージ

この挙動により、repeatOnLifecycle内でFlowの収集をしているのであれば、アプリバックグランド時にはFlowの収集は一時停止ではなくキャンセルされる挙動となり、イベント送信元の処理もキャンセルされるため、新しいイベントの送信によるリソース浪費の懸念がなくなります。
そのため、公式ではFragmentやActivityなどでFlowを収集する際には repeatOnLifecycleを利用することが推奨されています。

全てのケースで差し替えられるわけではない

さて、これまでの説明で分かった通り、 launchWhenStartedとrepeatOnLifecycleは挙動が完全に別物です。
ライフサイクルを考慮したFlowの収集、という点では単純に差し替えてOKかもしれませんが、全てのケースで単純に差し替えてOKではありません。

例えば、以下のようなクリックイベントを受けて非同期処理を実行するようなケースの場合、単純差し替えは出来ません。

// onStart以降に処理させたい
onClick = {
    // 非同期処理の実行を開始
    launchWhenStarted {
        fn()
        fn2()
    }
}

// こういう形には差し替えられない
onClick = {
    // 非同期処理の実行を開始
    repeatOnLifecycle(State.STARTED) {
        fn()
        fn2()
    }
}

これは、fn(), fn2()が実行されるのはアプリがフォアグラウンド中であることが保障されるという点においてはlaunchWhenStartedのときと同様ですが、それ以外に以下のような挙動差があります。

  • onClickの呼び出しが1回でも、非同期処理の実行中にアプリをバックグラウンドにしたりフォアグラウンドにしたりするとその度にfn(), fn2()が実行されることになります。

  • fn()が実行され、fn2()が実行されるより前にアプリがバックグラウンドになると、fn()だけが実行され、fn2()が実行されないという状況が発生し得ます。

  • fn(), fn2()の実行が完了した後でも、アプリがバックグラウンドになる〜フォアグラウンドに戻るの状態遷移を繰り返す度に非同期処理が再実行されるようになります。

そして、onClickの呼び出しが複数回あった場合、repeatOnLifecycleの呼び出しが複数回行われると、アプリがバックグラウンドになる〜フォアグラウンドに戻るの状態遷移を繰り返す度に fn(), fn2()の呼び出しが複数回ずつ行われるようになります。 これが意図した動作であるというケースはまずありません。

したがって、 ボタン操作などユーザーの操作に応じて複数回呼ばれるメソッドの中でrepeatOnLifecycleを実行するのは間違いである と思っておくのが良いでしょう。

launchWhenXXX の真の代替APIはないのか?

これまでの話からrepeatOnLifecycleがlaunchWhenXXXを代替するものではない、ということがお分かり頂けたかと思います。
では、本当の意味でlaunchWhenXXXを代替するAPIは存在しないのでしょうか?

こちらについては公式でIssueが立てられていたのですが、「本質的に安全な要求ではない」としてWon't Fixとなっています。気になる方は是非本家Issueを見てみて下さい。
https://issuetracker.google.com/issues/270049505#comment2

どうしても launchWhenXXX 的な挙動をやりたいときの書き方

この記述をしたい場合、再開時のケースとして少なくとも3パターンが挙げられると上記Issueで例示されています。
その3パターンのうちのどの処理に相当するかを考えれば、擬似的に従来のlaunchWhenXXXのような挙動を再現することが可能です。

実際にIssueで例示されていた3パターンについて以下で引用しつつ紹介します。

パターン1. suspend functionは最後まで完了してよい

実行時にonStart以降であることだけ保証されていればよく、suspend function実行中にアプリバックグラウンドに入ったとしても実行され続けて問題ない場合の書き方です。

lifecycleScope.launch {
  // STARTEDになるまで待機される
  withStarted { }
  // STARTED以降にこのコードが実行される
  doYourOneTimeWork()
  // Note: 実行されたコードは、たとえLifecycleがSTARTEDを下回ったとしても実行され続ける
}

この書き方の場合、doYourOneTimeWorkがLifecycleの状態維持に依存しない(STOPPEDになっても実行されて問題ない)処理である必要があります。

パターン2. suspend functionをキャンセルして、アプリ復帰時に完了していなかったら最初から再実行する

repeatOnLifecycle + 完了フラグを持つやり方です。アプリが復帰する度にブロック内が再実行されますが、もし既に処理が完了済みであるなら何も動作しません。

lifecycleScope.launch {
  var isComplete = false
  repeatOnLifecycle(Lifecycle.State.STARTED) {
    // 完了していない場合のみ処理する
    if (!isComplete) {
      // ここで処理する。もしアプリバックグラウンドに行ったらキャンセル
      doYourOneTimeWork()
      // 完了フラグを立てる
      isComplete = true
    }
  }
}

パターン3. suspend functionをキャンセルし、再実行させない

これは前のものと似ていますが、作業がキャンセルされたかどうかに関係なく、finallyブロックを使ってisCompleteフラグを設定します。

lifecycleScope.launch {
  var isComplete = false
  repeatOnLifecycle(Lifecycle.State.STARTED) {
    if (!isComplete) {
      try {
        // ここで処理する。もしアプリバックグラウンドに行ったらキャンセル
        doYourOneTimeWork()
      } finally {
        // doYourOneTimeWorkの処理途中でキャンセルされたとしてもfinallyブロックで完了とする
        // これにより再実行されなくなる
        isComplete = true
      }
    }
  }
}

補足: パターン2, パターン3について

これらは完了フラグが立っている場合、doYourOneTimeWork()については実行されませんが、アプリのサスレジ操作を繰り返す度にrepeatOnLifecycleブロック内の処理が再実行される挙動となります。
これは不要な処理が都度再実行され続ける状態を作ることになるため、パフォーマンスを考えると好ましくない状態です。

厳密にこれらの問題を防ぐ場合、完了フラグの操作に加えてrepeatOnLifecycleを実行するコルーチンのキャンセルについても考慮を行う必要があり、より複雑な制御が求められることになります。

まとめ

ここまでの話を見て launchWhenXXXを完全に代替することの大変さが何となく分かったのではないでしょうか。

こうした大変さを防ぐため、ライフサイクルの状態が少なくともXであるときに、1回限りのsuspend functionを実行しようとしないのが望ましいです。気をつけていきましょう。
また、 repeatOnLifecycleへの置き換えはFlowの収集等、suspend functionが再実行されても問題ないケースにおいて利用するようにしましょう。