Kotlin DSL 和 Navigation Compose 中的类型安全

本页介绍了为 Navigation Kotlin DSLNavigation Compose 提供运行时类型安全的最佳实践。总的来说,您应将应用的每个屏幕或导航图按模块映射到导航文件。每个生成的文件都应包含指定目的地的所有导航相关信息。这些导航文件也是 Kotlin 可见性修饰符提供运行时类型安全性的位置:

  • 类型安全函数会公开给代码库的其余部分。
  • 特定屏幕或导航图的导航相关概念位于同一个文件中并保持为私密状态,这样代码库的其余部分就无法访问它们。

拆分导航图

您应按屏幕拆分导航图。这与将屏幕拆分为不同的可组合函数的方法基本相同。每个屏幕都应有一个 NavGraphBuilder 扩展函数。

此扩展函数是无状态屏幕级可组合函数与导航相关逻辑之间的桥梁。该层还可以定义状态的来源以及处理事件的方式。

以下是一个典型的 ConversationScreen,可以将其设为 internal,使其只对其自身所在的模块可见,这样其他模块就无法访问它:

// ConversationScreen.kt

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

以下 NavGraphBuilder 扩展函数将 ConversationScreen 可组合项添加为该 NavGraph 的目的地。此外,它还会将屏幕与提供屏幕界面状态并处理与屏幕相关的业务逻辑的 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 文件会将导航库的代码与目的地本身分隔开。此外,它还会提供导航概念(例如路线或参数 ID)的封装,这些路线或参数 ID 将处于私密状态,因为它们绝不应泄露到此文件外部。无法在此层处理的导航事件需要公开给调用方,以便在适当的级别得到处理。您可以在上面的代码段中找到具有 onNavigateToParticipantList 的此类事件的示例。

类型安全导航

Safe Args 为导航 XML 资源文件中构建的导航图提供了编译时类型安全。但是,用于构建 Navigation Compose 的 Navigation Kotlin DSL 目前并未提供这种编译时类型安全。Safe Args 会生成代码,其中包含用于导航目的地和操作的类型安全类及方法。不过,您可以设计 Navigation 代码的结构,使其在运行时符合类型安全要求。这样做可以避免崩溃,并确保:

  • 导航到目的地或导航图时您提供的参数的类型正确,并且所需的所有参数都存在。
  • 您从 SavedStateHandle 中检索到的参数的类型正确。

每个目的地还应公开一个 NavController 扩展函数,以允许其他目的地安全地导航到该目的地。

// ConversationNavigation.kt

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

如果您要使用不同的 NavOptions(例如 popUpTo, 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)
}

组建导航图

导航图使用上述类型安全扩展函数来添加目的地以及导航到目的地。

在以下示例中,“对话”目的地与另外两个目的地(“主屏幕”和“参与者列表”)包含在如下应用级 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()
}

嵌套导航图中的类型安全

您应为提供多个屏幕的模块选择适当的可见性。这采用与上述部分中每种方法相同的概念。不过,将各个屏幕公开给其他模块可能毫无意义。在这种情况下,您应将其视为更大的独立流程的一部分。

这种独立的屏幕集称为嵌套导航图。这样一来,您就可以在单个 NavGraphBuilder 扩展方法中包含多个屏幕。此方法会使用这些 NavController 扩展方法将同一模块中的屏幕链接到一起。

在以下示例中,前面几个部分描述的“对话”目的地与另外两个目的地(“对话列表”和“参与者列表”)一起显示在嵌套导航图中:

// 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)
}