Kotlin DSL と Navigation Compose における型安全性

このページでは、Navigation Kotlin DSLNavigation Compose において、ランタイムでの型安全性を実現するためのベスト プラクティスについて説明します。ポイントは、アプリまたはナビゲーション グラフの各画面を、モジュールごとに Navigation ファイルにマッピングする必要があるということです。マッピングされたファイルには、それぞれ特定のデスティネーションに関する Navigation 関連の情報がすべて含まれていなければなりません。Kotlin の可視性修飾子によって、ナビゲーション ファイルでもランタイムでの型安全性が実現されます。

  • 型安全な関数は、他のコードベースに対して公開されます。
  • 特定の画面やナビゲーション グラフに対するナビゲーション固有のコンセプトは、同じファイル内に配置して非公開にし、他のコードベースからはアクセスできなくします。

ナビゲーション グラフを分割する

ナビゲーション グラフは画面ごとに分割する必要があります。これは、画面を複数のコンポーズ可能な関数に分割する場合と基本的に同じです。各画面には NavGraphBuilder 拡張関数が必要です。

この拡張関数は、画面レベルでコンポーズ可能なステートレス関数と Navigation 固有のロジックを結びつけるものです。このレイヤでは、状態の発生元とイベントの処理方法も定義できます。

以下は、典型的な ConversationScreen です。他のモジュールからアクセスされないように、自身のモジュールに internal を指定できます。

// ConversationScreen.kt

@Composable
internal fun ConversationScreen(
  uiState: ConversationUiState,
  onPinConversation: () -> Unit,
  onNavigateToParticipantList: (conversationId: String) -> Unit
) { ... }

次の NavGraphBuilder 拡張関数は、その NavGraph のデスティネーションとして ConversationScreen コンポーザブルを追加するものです。また、画面 UI の状態を提供し、画面関連のビジネス ロジックを処理する ViewModel と画面を統合します。ViewModel で処理できないナビゲーション イベントは、呼び出し元に公開されます。

// ConversationNavigation.kt

private const val conversationIdArg = "conversationId"

// Adds conversation screen to `this` NavGraphBuilder
fun NavGraphBuilder.conversationScreen(
  // Navigation events are exposed to the caller to be handled at a higher level
  onNavigateToParticipantList: (conversationId: String) -> Unit
) {
  composable("conversation/{$conversationIdArg}") {
    // The ViewModel as a screen level state holder produces the screen
    // UI state and handles business logic for the ConversationScreen
    val viewModel: ConversationViewModel = hiltViewModel()
    val uiState = viewModel.uiState.collectAsStateWithLifecycle()
    ConversationScreen(
      uiState,
      ::viewModel.pinConversation,
      onNavigateToParticipantList
    )
  }
}

ConversationNavigation.kt ファイルは、Navigation ライブラリのコードをデスティネーション自体から分離します。また、ルートや引数 ID など、Navigation のコンセプトに関するカプセル化も行います。ルートや引数 ID は非公開にして、このファイルの外部に漏洩しないようにする必要があります。このレイヤで処理できないナビゲーション イベントは、呼び出し元に公開して適切なレベルで処理されるようにする必要があります。上記のコード スニペットでは、onNavigateToParticipantList を使用した該当のイベントの例を示しています。

型安全なナビゲーション

Navigation Compose がビルドされる Navigation Kotlin DSL は現在、Safe Args が提供する種類のコンパイル時の型安全性を、ナビゲーション XML リソース ファイルに組み込まれたナビゲーション グラフに対して実現していません。Safe Args は、Navigation のデスティネーションとアクションに対して型安全なクラスとメソッドを含むコードを生成します。ただし、ランタイムに型安全になるように Navigation コードを構成することは可能です。そのため次のことが確保され、クラッシュが回避されます。

  • デスティネーションまたはナビゲーション グラフに移動するときに指定する引数は適切な型であり、必要な引数がすべて存在している。
  • SavedStateHandle から取得する引数は正しい型である。

他のデスティネーションから安全に移動できるように、各デスティネーションでは NavController 拡張関数も公開する必要があります。

// ConversationNavigation.kt

fun NavController.navigateToConversation(conversationId: String) {
    this.navigate("conversation/$conversationId")
}

別の NavOptionspopUpTo, savedState, restoreStatesingleTop など)を使用してアプリの画面に移動する場合は、移動時にオプションのパラメータを NavController 拡張関数に渡します。

// HomeNavigation.kt

const val HomeRoute = "home"

fun NavController.navigateToHome(navOptions: NavOptions? = null) {
    this.navigate(HomeRoute, navOptions)
}

型安全な引数ラッパー

必要に応じて型安全なラッパーを作成し、ViewModel の SavedStateHandle とデスティネーションのコンテンツ内の NavBackStackEntry から引数を抽出します。それによって、このセクションの冒頭で説明したメリットを得られます。

// ConversationNavigation.kt

private const val conversationIdArg = "conversationId"

internal class ConversationArgs(val conversationId: String) {
  constructor(savedStateHandle: SavedStateHandle) :
    this(checkNotNull(savedStateHandle[conversationIdArg]) as String)
}

// ConversationViewModel.kt

internal class ConversationViewModel(...,
  savedStateHandle: SavedStateHandle
) : ViewModel() {
  private val conversationArgs = ConversationArgs(savedStateHandle)
}

ナビゲーション グラフを作成する

ナビゲーション グラフは、上記の型安全な拡張関数を使用してデスティネーションを追加し、移動します。

次の例では、conversation デスティネーションと他の 2 つのデスティネーション(homeparticipant list)がアプリレベルの NavHost に含まれています。以下を参照してください。

// MyApp.kt

@Composable
fun MyApp(modifier: Modifier = Modifier) {
  val navController = rememberNavController()
  NavHost(
    navController = navController,
    startDestination = HomeRoute,
    modifier = modifier
  ) {

    homeScreen(
      onNavigateToConversation = { conversationId ->
        navController.navigateToConversation(conversationId)
      }
    )

    conversationScreen(
      onNavigateToParticipantList = { conversationId ->
        navController.navigateToParticipantList(conversationId)
      }
    }

    participantListScreen()
}

ネストされたナビゲーション グラフでの型安全性

複数の画面を提供するモジュールには、適切な公開設定を選択する必要があります。これは、上記セクションの各メソッドと同じコンセプトです。ただし、各画面を他のモジュールに個別に公開することはまったく意味がありません。個別に公開するのではなく、より大きな自己完結型フローの一部として処理します。

この自己完結型の画面セットを「ネストされたナビゲーション グラフ」と言います。これにより、複数の画面を 1 つの NavGraphBuilder 拡張メソッドに含められます。このメソッドは、NavController 拡張メソッドを順番に使用し、同じモジュール内の画面を 1 つに結びつけます。

次の例では、前のセクションで説明した conversation デスティネーションが、他の 2 つのデスティネーション(conversation listparticipant list)とともにネストされたナビゲーション グラフに表示されます。

// ConversationGraphNavigation.kt

private val ConversationGraphRoutePattern = "conversation"

fun NavController.navigateToConversationGraph(navOptions: NavOptions? = null) {
  this.navigate(ConversationGraphRoutePattern, navOptions)
}

fun NavGraphBuilder.conversationGraph(navController: NavController) {
  navigation(
    startDestination = ConversationListRoutePattern,
    route = ConversationGraphRoutePattern
  ) {
    conversationListScreen(
      onNavigateToConversation = { conversationId ->
        navController.navigateToConversation(conversationId)
      }
    )
    conversationScreen(
      onNavigateToParticipantList = { conversationId ->
        navController.navigateToParticipantList(conversationId)
      }
    )
    partipantList()
}

アプリレベルの NavHost では、次のようにネストされた複数のナビゲーション グラフを使用できます。

// MyApp.kt

@Composable
fun MyApp(modifier: Modifier = Modifier) {
  val navController = rememberNavController()
  NavHost(
    navController = navController,
    startDestination = HomeGraphRoutePattern
    modifier = modifier
  ) {
    homeGraph(
      navController,
      onNavigateToConversation = {
        navController.navigateToConversationGraph()
      }
    }
    conversationGraph(navController)
}