Navigation Composeの型安全なstartDestination

# はじめに
2024年5月１日にリリースされたNavigationのバージョン2.8.0-alpha08から、型安全なobjectでNavHostのdestinationを指定することができるようになりました。
これに伴い、NavHostに渡すstartDestinationもobjectで指定するようになりました。

[公式のサンプルコード](https://developer.android.com/jetpack/androidx/releases/navigation#2.8.0-alpha08)を見た際に、startDestinationの指定の仕方が気になったので調べてみました。
 
 <details><summary>サンプルコード引用</summary>

```kotlin
  // Define a home destination that doesn't take any arguments
  @Serializable
  object Home

  // Define a profile destination that takes an ID
  @Serializable
  data class Profile(val id: String)

  // Now define your NavHost using type safe objects
  NavHost(navController, startDestination = Home) {  // HomeもProfileも受け取れるなら、あらゆる型を受け取れてしまうのでは？
      composable<Home> {
          HomeScreen(onNavigateToProfile = { id ->
              navController.navigate(Profile(id))
          })
      }
      composable<Profile> { backStackEntry ->
          val profile: Profile = backStackEntry.toRoute()
          ProfileScreen(profile)
      }
  }
```
</details>


# 新しく追加されたNavHostのシグネチャ
実際にどう変わったのか、まずはシグネチャで比較してみます。
```kotlin:旧来のNavHostのシグネチャ
@Composable
public fun NavHost(
    navController: NavHostController,
    startDestination: String,
    modifier: Modifier = Modifier,
    contentAlignment: Alignment = Alignment.TopStart,
    route: String? = null,
    // トランジション関連は省略
    builder: NavGraphBuilder.() -> Unit
)
```
```kotlin:2.8.0-alpha08以降で追加されたシグネチャ
@Composable
public fun NavHost(
    navController: NavHostController,
    startDestination: Any,                         
    modifier: Modifier = Modifier,
    contentAlignment: Alignment = Alignment.TopStart,
    route: KClass<*>? = null,
    typeMap: Map<KType, @JvmSuppressWildcards NavType<*>> = emptyMap(),
    // トランジション関連は省略
    builder: NavGraphBuilder.() -> Unit
)
```
比較してみると、startDestinationがStringからAnyに変わっているのが分かります。

やはり想像した通り、startDestinationにはあらゆる型を指定できてしまうようです。

# startDestinationがAnyであることへの不安
startDestinationの型がAnyだからといって、誤った値が設定される可能性は低いですし、万が一誤った値が入ったとしても、アプリを起動すればすぐに実行時エラーで検出できます。そのため、過度に心配する必要はないかもしれません。

それでも、型安全を確保するためには、できるだけコンパイル時に誤った値を排除したいものです。

# 解決策
startDestinationの型がないならstartDestination用のインターフェースを用意すればいいじゃない。
ということで専用のインターフェースを用意し、startDestinationには特定の型の値しか入らないことを保証できるようにしました。

```kotlin
interface StartDestination

// startDestinationの判定処理が完了するまでの間に表示する画面
@Serializable
private object Loading : StartDestination

// アプリの使い方を説明する画面
@Serializable
private object Onboarding : StartDestination

// メイン画面
@Serializable
private object Main : StartDestination

@Composable
fun MTransNavHost(
    modifier: Modifier = Modifier,
    navController: NavHostController,
) {
    // 開始画面を表す状態
    val startDestinationState: MutableState<StartDestination> = remember {
        mutableStateOf(Loading)
    }

    // startDestination判定
    LaunchedEffect(Unit) {
        startDestination(
            startDestinationState = startDestinationState,
        )
    }
    
    NavHost(
        navController = navController, 
        startDestination = startDestinationState.value
    ) {
        composable<Loading> {
            // 初回起動判定がすぐに完了する場合は、あえて何も表示しない方がチラつきがなくて良い。
        }
        composable<Onboarding> {
            OnboardingScreen()
        }
        composable<Main> {
            MainScreen()
        }
    }
}

// 非同期で初回起動を判定するロジック
private suspend fun startDestination(
    startDestinationState: MutableState<StartDestination>,
) {
    val isFirstLaunch = // DataStoreからフラグを取得する等、初回起動を判定するロジック

    startDestinationState.value = if (isFirstLaunch) {
        Onboarding
    } else {
        Main
    }
}
```

:::note info
説明の都合上、destinationを表すobjectをすべて同じ場所に記述していますが、実務ではそれぞれの画面Composableと同じファイル内で管理したほうがよいでしょう。
:::

# まとめ
上記の実装を行うことでstartDestinationの型を固定することができました。
また、副次的なメリットですが、開始画面となるobjectは必ずStartDestination型を実装しているので、どの画面がstartDestinationになりうるか？というのを明示的にコードで表せるようになっています。

# 参考
https://developer.android.com/jetpack/androidx/releases/navigation

https://medium.com/androiddevelopers/navigation-compose-meet-type-safety-e081fb3cf2f8