IO処理を待ってから画面遷移する方法

# はじめに
画面遷移をする際に事前にサーバにリクエストしてから遷移したいときってありますよね。
単純にIO処理を待ってから画面遷移をするだけでできると思ったのですが、そう簡単にはいかなかったので解決策をまとめておきます。

# 環境
2023年7月時点で以下の環境で実施しています。
```gradle:build.gradle.kts(プロジェクトルート)
plugins {
    id("com.android.application").version("8.0.2").apply(false)
    id("com.android.library").version("8.0.2").apply(false)
    id("org.jetbrains.kotlin.android").version("1.7.20").apply(false)
    id("com.google.dagger.hilt.android").version("2.44").apply(false)
}
```
```gradle:build.gradle.kts(モジュール)
dependencies {
    implementation("androidx.core:core-ktx:1.8.0")
    implementation(platform("org.jetbrains.kotlin:kotlin-bom:1.8.0"))
    implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.3.1")
    implementation("androidx.activity:activity-compose:1.5.1")
    implementation(platform("androidx.compose:compose-bom:2022.10.00"))
    implementation("androidx.compose.ui:ui")
    implementation("androidx.compose.ui:ui-graphics")
    implementation("androidx.compose.ui:ui-tooling-preview")
    implementation("androidx.compose.material3:material3")

    val nav_version = "2.5.3"
    implementation("androidx.navigation:navigation-compose:$nav_version")
    implementation("androidx.hilt:hilt-navigation-compose:1.0.0")

    implementation("com.google.dagger:hilt-android:2.44")
    kapt("com.google.dagger:hilt-android-compiler:2.44")
}
```

# NG例
まずはNG例から紹介します。
下記ではIOディスパッチャのコルーチンの処理を待ってから、画面遷移をしようとしています。

```kotlin:NavHost
@Composable
fun MyNavigationCompose(modifier: Modifier = Modifier) {
    val navController = rememberNavController()
    NavHost(
        modifier = modifier,
        navController = navController, startDestination = "transitSource"
    ) {
        //遷移元
        composable("transitSource") {
            val vm: TransitSourceViewModel = hiltViewModel()
            TransitSourceScreen(
                //ボタンクリック時の動作を画面に渡す
                transit = {
                    //ViewModelのメソッドにnavController.navigate()を渡して、IO処理完了後に画面遷移させる
                    vm.fetchSomeData(navigate = {navController.navigate("transitTarget")})
                }
            )
        }
        //遷移先
        composable("transitTarget") {
            TransitDestScreen()
        }
    }
}
```
```kotlin:ViewModel
fun fetchSomeData(
    navigate: () -> Unit
) {
    viewModelScope.launch(Dispatchers.IO) {
         //ここでIO処理
        try {
            delay(5000)
        } catch (e: Exception) {
            // throwするなりResultをreturnするなり適切にエラーハンドリングして
            // 処理がここで終わるようにしてください
        }

        withContext(Dispatchers.Main) {
            //UIスレッドでnavController.navigate("transitTarget")を実行
            navigate()
        }
    }
}
```

上記を動かすと以下のようになります。
(分かりやすくするためにインジケーターを別の箇所で実装しています。)
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/287959/04bb6c0e-4171-1179-b5cb-edcf427d91c1.gif" width=40% />

### なにがNGなのか？
いっけんうまく動いているように見えますが、IO処理中にActivityが破棄されるとNavController.navigateでIllegalStateExceptionが発生しクラッシュします。
```stacktrace:スタックトレース
FATAL EXCEPTION: DefaultDispatcher-worker-1
Process: io.github.aniokrait.androidsandbox, PID: 26392
java.lang.IllegalStateException: no event down from INITIALIZED
	at androidx.lifecycle.LifecycleRegistry.moveToState(LifecycleRegistry.java:142)
	at androidx.lifecycle.LifecycleRegistry.setCurrentState(LifecycleRegistry.java:121)
	at androidx.navigation.NavBackStackEntry.updateState(NavBackStackEntry.kt:183)
	at androidx.navigation.NavBackStackEntry.setMaxLifecycle(NavBackStackEntry.kt:156)
	at androidx.navigation.NavController.updateBackStackLifecycle$navigation_runtime_release(NavController.kt:987)
	at androidx.navigation.NavController.dispatchOnDestinationChanged(NavController.kt:892)
	at androidx.navigation.NavController.navigate(NavController.kt:1730)
	at androidx.navigation.NavController.navigate(NavController.kt:1662)
	at androidx.navigation.NavController.navigate(NavController.kt:1984)
	at androidx.navigation.NavController.navigate$default(NavController.kt:1979)
	at io.github.aniokrait.androidsandbox.ui.MyNavigationComposeKt$MyNavigationCompose$1$1$1$1.invoke(MyNavigationCompose.kt:29)
	at io.github.aniokrait.androidsandbox.ui.MyNavigationComposeKt$MyNavigationCompose$1$1$1$1.invoke(MyNavigationCompose.kt:29)
	at io.github.aniokrait.androidsandbox.ui.viewmodel.TransitSourceViewModel$fetchSomeData$1$1.invokeSuspend(TransitSourceViewModel.kt:22)
	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
	at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106)
	at android.os.Handler.handleCallback(Handler.java:942)
	at android.os.Handler.dispatchMessage(Handler.java:99)
	at android.os.Looper.loopOnce(Looper.java:201)
	at android.os.Looper.loop(Looper.java:288)
	at android.app.ActivityThread.main(ActivityThread.java:7872)
	at java.lang.reflect.Method.invoke(Native Method)
	at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:548)
	at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:936)
	Suppressed: kotlinx.coroutines.DiagnosticCoroutineContextException: [StandaloneCoroutine{Cancelling}@ab94cfa, Dispatchers.IO]
```
画面回転でクラッシュする様子
<div style="text-align: center"><figure><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/287959/22ed5e84-cf19-078d-7217-7fd2b4ef0263.gif" width=40% /><figcaption></figcaption></figure></div>

# 解決策
ViewModelの中から直接navController.navigate()を呼ぶのではなく、Stateを変更してComposable関数がnavController.navigate()を呼ぶように修正します。
```diff_kotlin:NavHost
@Composable
fun MyNavigationCompose(modifier: Modifier = Modifier) {
    val navController = rememberNavController()
    NavHost(
        modifier = modifier,
        navController = navController, startDestination = "transitSource"
    ) {
        composable("transitSource") {
            val vm: TransitSourceViewModel = hiltViewModel()
            //Activityが破棄されても保持し続けるためにrememberではなくrememberSaveableで定義
+           val isLoadCompleted = rememberSaveable { mutableStateOf(false) }
            TransitSourceScreen(
                transit = {
                    //navController.navigateではなく、BooleanのMutableStateを渡す
-                    vm.fetchSomeData(navigate = {navController.navigate("transitTarget")})
+                    vm.fetchSomeData(isLoadCompleted)
                }
            )
            //isLoadCompletedがtrueになったら画面遷移を行う。
+           LaunchedEffect(isLoadCompleted.value) {
+               if (isLoadCompleted.value) {
+                   navController.navigate("transitTarget")
+               }
+           }
        }
        composable("transitTarget") {
            TransitDestScreen()
        }
    }
}
```
```diff_kotlin:ViewModel
fun fetchSomeData(
-   navigate: () -> Unit
+   isLoadCompleted: MutableState<Boolean>
) {
    viewModelScope.launch(Dispatchers.IO) {
        //ここでIO処理
        try {
            delay(5000)
        } catch (e: Exception) {
            // throwするなりResultをreturnするなり適切にエラーハンドリングして
            // 処理がここで終わるようにしてください
        }

        //IOが正常終了したら遷移
        withContext(Dispatchers.Main) {
            //navController.navigateを実行するのではなく、MutableStateを更新する
-            navigate()
+            isLoadCompleted.value = true
        }
    }
}
```
こうすることによりIO処理完了後に再コンポーズが走り、正しいライフサイクルになった状態で画面遷移させることができます。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/287959/85449acc-d20f-4b5e-56ae-9e32b1d0fdfe.gif" width=40% />

# さいごに
上記のサンプルはgithubにもおいておきます。
https://github.com/Aniokrait/AndroidToyBox/tree/main/ioBeforeTransition
ソースコードをフルで確認したい場合はご参照ください。