0
1

More than 3 years have passed since last update.

viewModelScope のキャンセルと onSaveInstanceState の順序

Last updated at Posted at 2021-07-18

概要

viewModelScope にて coroutine 実行中に process kill が発生した場合の、coroutine 実行のキャンセルonSaveInstanceState(Bundle) の順序に関するメモです。

Android の ViewModel 内部で coroutine 実行中に process が kill される場合の、viewModelScope のキャンセルと onSaveInstanceState の順序に関するメモです。

viewModelScope のキャンセルと onSaveInstanceState の順序

Activity#onSaveInstanceState() が先に呼ばれ、続いて viewModelScope 内で kotlinx.coroutines.JobCancellationException が呼び出されます。

そのため、$\color{red}{\rm Job キャンセル後の情報を instance state に保存することはできません}$。(と思ってるんだけど、対応法があったりするのだろうか?)

考察1:coroutine の実行中であることを示す progress bar を制御する場合

◆ viewModelScope のキャンセルと onSaveInstanceState の順序を考慮しないで作ってみる

$\color{red}{\rm ※以下試行錯誤中。いい方法があれば教えてくださいmm}$

以下のような処理を行いたいとします。

  1. progress bar を表示する。
  2. coroutine にて WorkerThread の処理を実行する。
  3. progress bar を隠す。

その場合、以下のようなコードうまく行きそうな気がしますが、doWork() 実行中に process kill が発生すると、instance state に inProgress.value = false が反映されません。そのため、instance state は inProgress.value = true として復活しし、プログレス表示がされたままになってしまいます。

class MyViewModel(
    application: Application,
    state: SavedStateHandle
) : AndroidViewModel(application), LayoutModel {
    // instance state は SavedStateHandle により保存している。(普通に Activity#onSaveInstanceState() でやっても同じ)
    val inProgress: MutableLiveData<Boolean> = state.getLiveData("isInProgress", false)

    fun load() {
        viewModelScope.launch {
            try {
                inProgress.value = true
                doWork() // ← (1) この処理中に process kill が発生した場合、Activity#onSaveInstanceState() が呼ばれて
                         //       インスタンスの状態が保存される。
                         //   (2) kotlinx.coroutines.JobCancellationException がスローされる。
            } finally {
                // (3) finally 内部で inProgress を false にする処理は実行される。しかし、既に instance state の保存が
                //     完了しているため、kill からの復活時の instance state には反映されない。
                inProgress.value = false
            }
        }
    }

    private suspend fun doWork() {
        withContext(Dispatchers.IO) {
            Thread.sleep(5000) // 実際はネットワーク呼び出しとか重い処理とか。
        }
    }
}

◆ inProgress が true の場合には再実行してみる

それならば、inProgress を instance state に含めなければいいのでは? と考えましたが、それでは doWork() の実行が完了していない状態として kill から復活してしまいます。この場合、リロードボタンなどの再度 load() を呼び出す流れが存在しない場合、二度と load() を呼び出せない状態で詰んでしまいます。

仕方ないので、init block にて、inProgress が true の場合には load() を呼び出すようにしたのが下記コードです。

class MyViewModel(
    application: Application,
    state: SavedStateHandle
) : AndroidViewModel(application), LayoutModel {
    val inProgress: MutableLiveData<Boolean> = state.getLiveData("isInProgress", false)

    // ------------------------ 追加ここから ------------------------
    init {
        if (inProgress.value == true) {
            load()
        }
    }
    // ------------------------ 追加ここまで ------------------------

    fun load() // 以下割愛...
}

inProgress の状態で kill から復活した場合は中断された処理を再実行するというのは、処理としては正しいような気がします。

しかし、手法として一般化できるかというとダメな気がしますね、、、。再実行したくないケースとか、coroutine による処理が複数の処理がある場合や、それがさらに入れ子になっているような場合や、並列で動作している場合など、kill からの復活後にそれらすべての状況を完全に引き継ぐコードは、作成することはできても、保守しきれない気がします。仮に保守可能な範囲に収まったとしても、保守開発過程で仕様が複雑化しない保証はなく、どこまで複雑化したらどのように設計を変更するといったルールを随時適用するというのも現実的ではない気がします。


以下ポエムw

◆ kill からの復活の手順を一本化するw

process kill 時に実行されていた処理を引き継ぐという事自体が現実的ではない1気がするので、kill からの復活は問答無用で単一の方法を適用するという仕組みを考えてみます。

単純に一本化しようとすると、instance state を一切引き継がない初期化が一番シンプルなのですが、それって普通に instance state 使ってないだけじゃんwww

◆ 不整合を発生させないようにする

coroutine の処理中に kill される場合の懸念点は、kill されること自体はバグではなく普通に起こりうること、状態の不整合が発生しうること、必ずしも不整合が発生するわけではないこと、などですよね。

っていうか、coroutine とか関係なく、バグやシステムレベルのエラー(デバイスの故障など)以外で不整合が起こり得ることが問題じゃね?coroutine の場合は普通に cancel が発生しうるので、不整合が発生しやすいだけであり、coroutine であること自体はまったく問題ないような気がします。

それだったら、バグやシステムレベルのエラー(デバイスの故障など)以外では不整合が絶対に起きないようにすればよさそう。

Unit of Work 的な思想が普通に適用できそうな気がします。

commit 前のデータを local variable として扱い、instance state への保存対象の MutableLiveData への登録を commit に見立て、その commit 的な処理を main thread で連続して(thread の switching が行われない状態で)行うようにすれば、不整合は起こらなさそう。

とは言っても結構めんどくさそうですね、、、。

関数がネストするのを忌避するとかは現実的ではないので、関数階層で context 的に保存データの全コピをたらい回しにするとか、、、。しかし、すべてが main thread で実行されるわけではないので、呼び出しの起点をすべて main thread であることを保証したとしても、途中で suspend fun が含まれたら並行動作がないことを保証はできないですよね、、、。

起点が main thread であることを保証して、処理開始から終了までの間、ほかの処理が入れないようにフラグでブロックしてしまうというのならできそうな気がする。他の処理が実行中の場合は、何もしないとか、何もしないで戻り値で実行しなかったことを示すとか。しかし、画面上で複数の非同期処理ができないとか、現実的ではないですよね、、、。画面上に複数の情報を同時にロードするとか普通にやりますよね、、、(´・ω・`)

◆ 暫定結論 (´・ω・`)

一般化できる対処法は今のところ見当がつかないっす (´・ω・`)

整合性が必要とされるデータを非同期で扱う場合に、おいらが現状で採れる選択肢は以下の2通りしかなさそうな気がします。

  1. 全体を把握し、確実に整合性をとる。ただし、新規の保守開発者がコードを一読しただけで理解できないような複雑さは許容できない。
  2. kill から復元の場合、ViewModel を新規作成する際と同様の初期化を行う。(instance state への保存は一切行わず、ViewModel の init block にて viewModelScope.launch で初期化処理を指定するだけでOKなはず)

※ っていうか、整合性を必要とする複数のデータを複数のスレで処理するというのは、kill からの復活とか関係なく、オンメモリの ViewModel 内部であっても現実的ではない気がする。画面上で複数のデータを不確定な様々タイミングでロードするケースは普通にあるけれど、それらのデータが相互に細かな整合性を必要とするケースって、仕様としてありえない2っすよねw 実際、現実的な問題として困っているわけではなく単なる思考実験なので、ここでひとまずおしまいとして、実際に困るケースが見つかったらまた考えることにします、、、。


  1. 最初は現実的だったとしても、仕様変更等により現実的ではない状況が訪れない保証がない。現実的でなくなった時点で設計変更のリファクタをしたうえで対応したり、仕様がシンプルに変更された際にも設計変更のリファクタをしたうえで対応したりする必要があるが、その設計変更のリファクタは、kill からの復活の手順を一本化する方向ならある程度機械的な手順を踏めるが、逆方向の場合は kill 後の復活方法がコード中から失われてしまっているため機械的な手順によるリファクタは不可能。コメントとして取っておいたとしても、動作しているコードからのリファクタとは異なる手動処理なので、エンバグのリスクが高すぎる。 

  2. 仕様作成者が自身も、仕様のレビュー者も、仕様の正当性を判断できないと思う。 

0
1
0

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
0
1