高效稳定 | Compose 的类型安全导航

7ae50db0cbb33efac2aee472ce455d1e.png

作者 / Don Turner

*截至本文发布时,最新的 Jetpack Navigation 稳定版本为 2.8.3,您可以在 Android 开发者网站参阅最新的版本更新内容。

借助 Jetpack Navigation 2.8.0,用于在 Kotlin 中构建导航图的类型安全导航 API 已经稳定了。这意味着您可以使用可序列化的类型定义目的地,并受益于编译时安全。

🔗 Jetpack Navigation 2.8.0

https://developer.android.google.cn/jetpack/androidx/releases/navigation

如果您使用 Jetpack Compose 设计界面,这将是一个好消息,因为定义导航目的地和参数会更加简单和安全。

🔗 Jetpack Compose

https://developer.android.google.cn/compose

此前的博文不仅介绍了这些新 API 背后的设计理念,还介绍了首次发布这些 API 的版本——2.8.0-alpha08。

🔗 此前的博文

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

从那时起,我们收到并整合了大量反馈,修复了一些错误,并对 API 进行了若干改进。本文介绍了稳定版本的 API 以及从首个 alpha 版本以来发生的变化。我们还将介绍如何迁移现有代码,并为您提供一些测试导航用例的技巧。

1c1f552dda0183ad36d71121106669f9.png

基础知识

适用于 Kotlin 的新类型安全导航 API 允许您使用 Any 可序列化类型来定义导航目的地。要使用这些 API,您需要在项目中添加 Jetpack 导航库版本 2.8.0 和 Kotlin 序列化插件。

🔗 添加 Jetpack 导航库版本 2.8.0

https://developer.android.google.cn/guide/navigation#set-up

🔗 Kotlin 序列化插件

https://kotlinlang.org/docs/serialization.html#example-json-serialization

完成后,您可以使用 @Serializable 注释自动创建可序列化类型。然后,这些类型可用于创建导航图。

在本文的其余部分中,我们会假设您使用 Compose 作为界面框架 (即通过在依赖项中包含 navigation-compose),尽管这些示例应该同样适用于 Fragment,但略有不同。如果您同时使用两者,也有一些新的互操作 API 可供选择。

🔗 navigation-compose

https://developer.android.google.cn/develop/ui/compose/navigation

🔗 略有不同

https://developer.android.google.cn/guide/navigation/design/kotlin-dsl#navgraphbuilder

🔗 新的互操作 API 可供选择

https://youtu.be/DZJV-ZKQ634?si=8WmEQY-5VXMO4psl

composable 就是一个新 API 很好的示例,现在接受可用于定义目的地的泛型类型。

@Serializable data object Home


NavHost(navController, startDestination = Home) {
 composable<Home> {
   HomeScreen()
 }
}

🔗 composable

https://developer.android.google.cn/reference/kotlin/androidx/navigation/NavGraphBuilder#(androidx.navigation.NavGraphBuilder).composable(kotlin.collections.Map,kotlin.collections.List,kotlin.Function1,kotlin.Function1,kotlin.Function1,kotlin.Function1,kotlin.Function1,kotlin.Function2)

命名法在这方面很重要。在导航领域,Home 一词指用于创建目的地路由。目的地具有路由类型,并定义将在该目的地屏幕上显示的内容,在本例中为 HomeScreen。

这些新 API 的特征可以总结为:任何接受路由的方法现在都接受该路由的泛型类型。以下示例便使用了这些新方法。

d7fc350c1be22592ff9d14bf78372036.png

在目的地之间传递数据

这些新 API 的主要优势之一是使用类型作为导航参数,以提供编译时安全性。对于基本类型,将这些 API 传递到目的地非常简单。

🔗 基本类型

https://kotlinlang.org/docs/basic-types.html

假设我们有一款在主屏幕上显示产品的应用。点击任何产品都会在产品屏幕上显示产品的详细信息。

c93c92b8e19cbbb56f0f722c90211603.png

△ 有两个目的地的导航图:主屏幕与产品

我们可以使用数据类 (其中的字符串 ID 字段将包含产品 ID) 来定义产品路由。

@Serializable data class Product(val id: String)

通过这样做,我们建立了一些导航规则:

  • Product 路由必须始终有 id

  • id 的类型始终是 String

您可以使用任何基本类型作为导航参数,包括列表和数组。有关更复杂的类型,请参阅这篇博文的 "自定义类型 (Custom types)" 部分。

🔗 这篇博文

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

自 alpha 版以来的新变化

  • 支持使用可为 null 的类型。

  • 支持枚举 (不过您需要在枚举声明上使用 @Keep,以确保枚举类在缩减构建、跟踪错误期间不被移除)

🔗 跟踪错误

https://issuetracker.google.com/issues/358687142

a771cba0879f83184b663e30d9dfb969.png

获取目的地路由

当我们使用此路由在导航图中定义目的地时,我们可以使用 toRoute 从返回堆栈条目获取路由。然后,路由可以被传递给在屏幕上呈现该目的地所需的任何内容,在本例中为 ProductScreen。下面是我们如何完成目的地的设置:

composable<Product> { backStackEntry ->
 val product : Product = backStackEntry.toRoute()
 ProductScreen(product)
}

自 alpha 版以来的新变化:如果使用 ViewModel 向屏幕提供状态,您也可以使用 toRoute 扩展函数从 savedStateHandle 中获取路由。

ProductViewModel(private val savedStateHandle: SavedStateHandle, …) : ViewModel {
 private val product : Product = savedStateHandle.toRoute()
 // Set up UI state using product
}

有关测试的注意事项:从版本 2.8.0 开始,SavedStateHandle.toRoute 将依赖于 Android Bundle。这意味着,您需要对 ViewModel 进行插桩测试 (例如,通过使用 Robolectric 或在模拟器上运行测试)。我们正在研究如何在未来的版本中移除此依赖项 (持续跟踪)。

🔗 持续跟踪

https://issuetracker.google.com/349807172

e775f3cbcefe0f218955c614a6e4cf54.png

导航时传递数据

使用路由传递导航参数很简单,只需结合使用 navigate 与路由类的实例即可。

navController.navigate(route = Product(id = "ABC"))

下面是一个完整示例:

NavHost(
   navController = navController,
   startDestination = Home
) {
   composable<Home> {
       HomeScreen(
           onProductClick = { id -> 
               navController.navigate(route = Product(id))
           }
       )
   }
   composable<Product> { backStackEntry ->
       val product : Product = backStackEntry.toRoute()
       ProductScreen(product)
   }
}

现在您已经了解了如何在应用内的屏幕之间传递数据,让我们来看看如何从外部导航并将数据传递到应用中。

acab2f9e49080c226657b46c3debc028.png

通过深层链接直达目的地

有时,您希望将用户直接带到应用内的特定屏幕,而不是从主屏幕开始。例如,如果您刚刚向用户发送了 "查看此新品" 的通知,那么他们在点击通知后直接进入产品屏幕便是完全合理的。深层链接可以帮助您实现这一目标。

🔗 深层链接

https://developer.android.google.cn/guide/navigation/design/deep-link

您可以通过以下方式将深层链接添加到上述的 Product 目的地:

composable<Product>(
 deepLinks = listOf(
   navDeepLink<Product>(
     basePath = "www.hellonavigation.example.com/product"
   )
 )
) {
…
}

navDeepLink 用于同时根据本例中的 Product 类和提供的 basePath 来构造深层链接 URL。所提供类中的任何字段都会自动包含在 URL 中作为参数。生成的深层链接 URL 是:

www.hellonavigation.example.com/product/{id}

要进行测试,您可以使用以下 adb 命令:

adb shell am start -a android.intent.action.VIEW -d "https://www.hellonavigation.example.com/product/ABC" com.example.hellonavigation

这将直接在 Product 目的地上启动应用,并将 Product.id 设置为 "ABC"。

079fc2e181d38b4d5927ec941f14bd81.png

URL 参数类型

我们刚刚看到了导航库自动生成包含路径参数的深层链接 URL 的示例。为所需的路由参数生成路径参数。再次查看我们的 Product:

@Serializable data class Product(val id: String)

id 字段为必填字段,因此 /{id} 的深层链接 URL 格式将附加到基本路径。路径参数始终为路由参数生成,以下情况除外:

1. 类字段有默认值 (字段为可选),或

2. 类字段表示一系列原始类型,如 List<String> 或 Array<Int> (受支持类型的完整列表,可通过扩展 CollectionNavType 添加专属类型)

🔗 受支持类型的完整列表

https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:navigation/navigation-common/src/main/java/androidx/navigation/NavType.kt;l=179

🔗 CollectionNavType

https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:navigation/navigation-common/src/main/java/androidx/navigation/CollectionNavType.kt

在每种情况下,系统都会生成一个查询参数。查询参数的深层链接 URL 格式为 ?name=value。

以下是不同类型 URL 参数的摘要:


路径参数查询参数
用于必需参数

可选参数

集合 (数组和列表)

示例字段

val id: String

val code: Int?

val color: String? = null

val variants: List<String> = emptyList()

生成的 URL 格式/{id}/{code} ?color={color}&variants={variant1}&variants={variant2}
URL 示例product/ABC/123product?color=red&variants=small&variants=medium

△ 路径和查询参数

自 alpha 版以来的新变化:现在,路径参数的字符串可为空。在上面的示例中,如果您使用 www.hellonavigation.example.com/product// 的深层链接 URL,id 字段会被设置为空字符串。

5b48bdb81d0727a29700750e4b040181.png

测试深层链接

一旦您将应用的清单设置为接受传入链接,测试深层链接的一个简单方法就是使用 adb。示例如下 (注意,& 经过转义):

adb shell am start -a android.intent.action.VIEW -d “https://hellonavigation.example.com/product/ABC?color=red\&variants=var1\&variants=var2" com.example.hellonavigation

🔗 将应用的清单设置为接受传入链接

https://developer.android.google.cn/training/app-links/deep-linking#adding-filters

🐞 调试技巧:如果您想检查生成的深层链接 URL 格式,只需从目的地打印 NavBackStackEntry.destination.route 即可;它将在您导航到该目的地时显示在 logcat 中:

composable<Product>( … ) { backStackEntry ->
  println(backStackEntry.destination.route)
}

f2b5d9fb6188ed08b173c8d7890df42b.png

测试导航

我们已经介绍了如何使用 adb 测试深层链接。接下来,我们一起更深入地了解如何测试导航代码。导航测试通常是插桩测试,即模拟用户如何浏览应用。

以下是一个简单的测试,用于验证当您点击产品按钮时,产品屏幕是否会显示正确的内容。

@RunWith(AndroidJUnit4::class)
class NavigationTest {
 @get:Rule
 val composeTestRule = createAndroidComposeRule<MainActivity>()


 @Test
 fun onHomeScreen_whenProductIsTapped_thenProductScreenIsDisplayed() {
   composeTestRule.apply {
     onNodeWithText("View details about ABC").performClick()
     onNodeWithText("Product details for ABC").assertExists()
   }
 }
}

本质上讲,您并非直接与导航图进行交互,而是在模拟用户输入,以确保导航路由指向正确的内容。

🐞 调试技巧:如果您想暂停插桩测试,但仍然与应用交互,则可以使用 composeTestRule.waitUntil(timeoutMillis = 3_600_000, condition = { false })。您需要将该参数粘贴到测试中,并放置于故障点之前,然后对应用进行全面测试,以了解测试失败的原因 (您有一个小时的时间,希望这一小时足够您解决相关问题!)。布局检查器甚至可以同时工作。如果您只想使用测试设置代码调查应用的状态,也可以在单个测试中进行。当插桩测试的应用使用虚假数据时尤其有用,因为虚假数据可能会导致测试结果与生产版本的行为不同。

1b34ff67ab2047ba40624ed931c2ff0e.png

迁移现有代码

如果您已经在使用 Jetpack Navigation 并使用 Kotlin DSL 定义导航图,您可能希望更新现有代码。让我们来看看两个常见的迁移用例:基于字符串的路由和顶级导航界面。

🔗 Kotlin DSL

https://developer.android.google.cn/guide/navigation/design/kotlin-dsl

ef1be65b1867c6f78ab8791531a5966d.png

从字符串到任何…事物

在以前版本的 Navigation Compose 中,您需要将路由和导航参数键定义为字符串。以下是以这种方式定义的产品路由示例。

const val PRODUCT_ID_KEY = "id"
const val PRODUCT_BASE_ROUTE = "product/"
const val PRODUCT_ROUTE = "$PRODUCT_BASE_ROUTE{$PRODUCT_ID_KEY}"


// Inside NavHost
composable(
  route = PRODUCT_ROUTE,
  arguments = listOf(
    navArgument(PRODUCT_ID_KEY) {
      type = NavType.StringType
      nullable = false
    }
  )
) { entry ->
  val id = entry.arguments?.getString(PRODUCT_ID_KEY)
  ProductScreen(id = id ?: "Not found")
}


// When navigating to Product destination
navController.navigate(route = "$PRODUCT_BASE_ROUTE$productId")

请注意 id 参数的类型在多个位置 (NavType.StringType 和 getString) 被定义。新的 API 允许我们移除此重复项。

要迁移此代码,请为路由创建可序列化的类 (如果没有参数,则创建一个对象)。

@Serializable data class Product(val id: String)

将用于创建目的地的基于字符串的路由实例替换为新类型,并移除所有参数:‍

composable<Product> { … }

获取参数时,使用 toRoute 获取路由对象或类。

composable<Product> { backStackEntry ->
 val product : Product = backStackEntry.toRoute()
 ProductScreen(product.id)
}

在调用 navigate 时,还要替换任何基于字符串的路由实例:

navController.navigate(route = Product(id))

大功告成!我们已经能够移除字符串常量和样板代码,并且还为导航参数引入了类型安全。

增量迁移

您不必一次性迁移所有基于字符串的路由。只要您的字符串格式与导航库从路由类型生成的格式相匹配,您就可以将接受泛型类型路由的方法与接受基于字符串路由的方法交替使用。

换句话说,在完成上述迁移之后,以下代码仍将按预期工作:

navController.navigate(route = “product/ABC”)

这使您可以逐步迁移导航代码,而不是执行 "全有或全无" 的程序。

b298122b0cf3c82f3ad358aa3d397866.png

顶级导航界面

大多数应用都采用某种形式的导航界面,并且会始终显示,以便用户可以导航到不同的顶级目的地。

f6726007c44e305bbc7a2d1b65eba6cf.png

△ Material 3 导航栏

🔗 Material 3 导航栏

https://m3.material.io/components/navigation-rail/overview

此导航界面的关键作用是显示用户当前所在的顶级目的地。这通常通过迭代顶级目的地并检查其路由是否是当前返回堆栈中的路由来完成。

对于以下示例,我们将使用 NavigationSuiteScaffold,根据可用窗口大小显示正确导航界面。

const val HOME_ROUTE = "home"
const val SHOPPING_CART_ROUTE = "shopping_cart"
const val ACCOUNT_ROUTE = "account"


data class TopLevelRoute(val route: String, val icon: ImageVector)


val TOP_LEVEL_ROUTES = listOf(
  TopLevelRoute(route = HOME_ROUTE, icon = Icons.Default.Home),
  TopLevelRoute(route = SHOPPING_CART_ROUTE, icon = Icons.Default.ShoppingCart),
  TopLevelRoute(route = ACCOUNT_ROUTE, icon = Icons.Default.AccountBox),
)


// Inside your main app layout
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination


NavigationSuiteScaffold(
  navigationSuiteItems = {
    TOP_LEVEL_ROUTES.forEach { topLevelRoute ->
      item(
        selected = currentDestination?.hierarchy?.any {
          it.route == topLevelRoute.route
        } == true,
        icon = {
          Icon(
            imageVector = topLevelRoute.icon,
            contentDescription = topLevelRoute.route
          )
        },
        onClick = { navController.navigate(route = topLevelRoute.route) }
      )
    }
  }
) {
NavHost(…)
}

🔗 NavigationSuiteScaffold

https://developer.android.google.cn/develop/ui/compose/layouts/adaptive/build-adaptive-navigation

在新的类型安全 API 中,您不再将顶级路由定义为字符串,所以不能使用字符串比较。相反,请使用 NavDestination 上新的 hasRoute 扩展函数来检查目的地是否有特定的路由类。

@Serializable data object Home
@Serializable data object ShoppingCart
@Serializable data object Account


data class TopLevelRoute<T : Any>(val route: T, val icon: ImageVector)


val TOP_LEVEL_ROUTES = listOf(
  TopLevelRoute(route = Home, icon = Icons.Default.Home),
  TopLevelRoute(route = ShoppingCart, icon = Icons.Default.ShoppingCart),
  TopLevelRoute(route = Account, icon = Icons.Default.AccountCircle)
)


// Inside your main app layout
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination


NavigationSuiteScaffold(
  navigationSuiteItems = {
    TOP_LEVEL_ROUTES.forEach { topLevelRoute ->
      item(
        selected = currentDestination?.hierarchy?.any {
          it.hasRoute(route = topLevelRoute.route::class)
        } == true,
        icon = {
          Icon(
            imageVector = topLevelRoute.icon,
            contentDescription = topLevelRoute.route::class.simpleName
          )
        },
        onClick = { navController.navigate(route = topLevelRoute.route)}
      )
    }
  }
) {
NavHost(…)
}

dfa17cd5818d3c18c8c435cd77f88792.png

注意事项

很容易混淆类和对象目的地

您能从以下代码中发现问题吗?

@Serializable
data class Product(val id: String)


NavHost(
 navController = navController,
 startDestination = Product
) { … }

问题不是很明显,但如果运行代码,则会看到以下错误:

kotlinx.serialization.SerializationException: Serializer for class ‘Companion’ is not found.

这是因为 Product 不是有效的目的地,而仅仅 Product 的实例 (如 Product("ABC"))。上述错误消息会令人困惑,直到您意识到序列化库需要的是未定义为可序列化的 Product 类的静态初始化 Companion 对象 (实际上,我们根本没有定义它,而是 Kotlin 编译器为我们添加了它),因此没有相应的序列化器。

自 alpha 版以来的新变化:已添加 lint 检查,以找出路由中使用了不正确类型的地方。当您尝试使用类名称而不是类实例时,您将收到一条有用的错误消息:"路由应为目的地类实例或目的地对象。" 在 2.8.1 版本中,使用 popBackStack 时,将会添加类似的 lint 检查。

🔗 lint 检查

https://source.corp.google.com/h/android/platform/frameworks/support/+/81043eb1b25606c0242fabd80c0714b3f91b4d82

🔗 类似的 lint 检查

https://b.corp.google.com/issues/358095343

01e5b455562746680b6249c19b7d7fc3.png

请勿创建重复的目的地

使用重复的目的地在之前常会导致未定义的行为。此问题现已修复,现在如果导航的目的地与之前重复,则会选择导航图中与当前目的地匹配、相对而言最近的目的地进行导航 (自 alpha 版以来的新变化)

🔗 现已修复

https://issuetracker.google.com/issues/352006850

尽管如此,仍不建议在导航图中创建重复的目的地,因为会不知道该导航到哪一个目的地。如果相同的内容出现在两个目的地中,请为每个目的地创建单独的目的地类,并只使用相同的内容可组合项。

1970a667b7af5053aea9758d5aabff4e.png

小心 "null" 字符串 (暂时)

目前,如果您的路由带有 String 参数,并且其值被设置为文本字符串 "null",导航到该目的地时,应用将会崩溃。此问题已在 2.8.1 中修复。

🔗 导航到该目的地时,应用将会崩溃

https://issuetracker.google.com/360940641

🔗 2.8.1

https://developer.android.google.cn/jetpack/androidx/releases/navigation?hl=en#2.8.1

与此同时,如果 String 路由参数中包含未被清理的输入,请先检查是否存在 "null",以免发生崩溃。

9c980510a48c176b5bcec072b956b5f6.png

TransactionTooLarge

Exception

请勿使用大型对象作为路由,因为可能会遇到 TransactionTooLargeException。导航时,路由会被保存以在系统发起的进程终止时持久化,并且保存机制为 Binder 事务。Binder 事务有 1 MB 的缓冲区,因此大型对象可以很容易填满此缓冲区。

🔗 TransactionTooLargeException

https://developer.android.google.cn/reference/android/os/TransactionTooLargeException

通过使用专为大型数据设计的存储机制 (如 Room 或 DataStore) 存储数据,可以避免将大型对象用于路由。在插入数据时,获取一个唯一的引用,例如 ID 字段。然后,您可以在路由中使用这个更小的唯一引用,在目的地使用该引用来获取数据。

🔗 Room

https://developer.android.google.cn/jetpack/androidx/releases/room

🔗 DataStore

https://developer.android.google.cn/jetpack/androidx/releases/datastore

b2917db270fc950d272866b76319bd45.png

总结

以上便是关于新的类型安全导航 API 的全部内容。下面是我们对最重要的功能进行的简单总结。

  • 使用 composable<T> (或用于嵌套图形的 navigation<T>) 定义目的地

  • 使用适用于对象路由的 navigate(route = T) 或适用于类实例路由的 navigate(route = T(…)) 导航到目的地

  • 使用 toRoute<T>,从 NavBackStackEntry 或 SavedStateHandle 获取路由

  • 使用 hasRoute(route = T::class) 检查目的地是否使用给定路由创建而成

您可以在 Now in Android 应用中查看这些 API 的工作实现。从基于字符串的路由迁移发生在此 Pull Request 中。

🔗 这些 API 的工作实现

https://github.com/android/nowinandroid/blob/main/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/navigation/TopicNavigation.kt#L26

🔗 在此 Pull Request 中

https://github.com/android/nowinandroid/pull/1413

我们非常希望了解您对这些 API 的看法。您可以随时发表评论,如果您有任何问题,请提交错误报告。如需了解有关如何使用 Jetpack Navigation 的更多信息,请参阅官方文档。

🔗 提交错误报告

https://issuetracker.google.com/issues/new?component=409828&template=1093757

🔗 官方文档

https://developer.android.google.cn/guide/navigation/design/type-safety

本文中的代码段包含以下许可证:

// Copyright 2024 Google LLC. SPDX-License-Identifier: Apache-2.0

欢迎您持续关注 "Android 开发者" 微信公众号,及时了解更多开发技术和产品更新等资讯动态!

点击图片关注精彩活动

73281f7ca980ea6c373a4ba06b4033c7.jpeg

330134071a9449fa8e28f3453a591969.jpeg

8355d48840d4a57846819b2310886719.gif 点击屏末 阅读原文 | 即刻使用 Jetpack Compose 设计界面


9d9307a6daf406a6f27579892edea9a9.png

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值