Jetpack Compose Navigation 遇上类型安全
引言
随着 Navigation 2.8.0-alpha08 版本的发布,Navigation 组件引入了基于 Kotlin Serialization 的完整类型安全系统,用于在使用 Kotlin DSL 时定义导航图。这一新特性旨在与 Navigation Compose 等集成最佳的方案。
什么是 Kotlin DSL?
Navigation 组件包含三个主要组件:
- Host —— 在您的布局中显示当前“目标”(destination)的 UI 元素。
- Graph —— 定义应用程序中所有可能的目标的数据结构。
- Controller —— 管理目标之间导航并保存目标的后向堆栈的中央协调器。
Kotlin DSL 只是构建导航图的一种方式。自 Navigation 于 2018 年首次发布以来,一直提供了三种构建图的方式:
- 手动构造 NavGraph 实例并添加目标(例如 Fragment 目标)以构建图。
- 从导航 XML 文件中膨胀您的图,并手动编辑它或通过导航编辑器进行编辑。
- 使用 Kotlin DSL 直接在 Kotlin 代码中构建导航图。
Navigation Compose 是第一个真正拥抱 Kotlin DSL 作为构建图的方式的集成,有意地转向了更灵活的系统,并远离了静态 XML 文件。
Kotlin 代码,但代价是什么?
然而,从构建时的静态 XML 文件到在运行时生成图的转变意味着开发者可用的工具也发生了显著变化。Navigation 组件的 Safe Args Gradle 插件生成了类型安全的“Directions”类,您可以在代码中使用它们在目标之间进行导航,但它依赖于从那些导航 XML 文件中读取目标及其参数。这意味着如果没有导航 XML 文件,就没有生成的 Safe Args 代码。
虽然 Navigation 在运行时需要正确的类型和参数(如果您尝试向期望 Int 的地方传递 String 或忘记了必需的参数,它会立即崩溃),但编译时安全性留给了开发者自行解决。
编译时类型安全性
如果没有 Safe Args Gradle 插件,您会剩下什么呢?使用 Navigation Compose 的 Kotlin DSL 是基于每个目标都有一个唯一的“路由”这个想法的 —— 一个唯一标识该目标的 RESTful 路径。
例如,就像一个网站一样,您可能会有一个“home”目标,一个“products”目标,以及需要参数的页面 —— 特定产品的路由可能是“products/{productId}” —— 它将包含该产品的唯一 ID 的占位符。
这意味着:
- 要跟踪这些字符串路由。
- 管理它们的参数及其类型。
- 最糟糕的是,执行字符串插值。
我们采取了一些措施来最小化这种繁琐工作 —— 将字符串与我们基础的 Kotlin DSL 上的类型安全扩展分离开来。虽然这提供了基础 Kotlin DSL 与您的代码库和不同模块中公开的 API 之间的强大隔离层,但显然这还不够。
Android 社区为我们提供了一些出色的解决方案,这些解决方案建立在 Navigation Compose 之上,旨在最小化或完全消除这些手动编写的代码,其中包括:
- Rafael Costa 的 Compose Destinations 使用 KSP 处理附加到组合函数的注解,以生成整个导航图。
- Kiwi.com 的 navigation-compose-typed 使用 Kotlin Serialization 直接从 @Serializable 对象或数据类生成路由。
如此多的代码生成方式
在研究在我们的 Kotlin DSL 中实现 Safe Args 时,我们探索了多种方法,基本上是研究了可以用来生成类型安全代码的许多技术。
我们探索的一种方法是将我们与 Safe Args Gradle 插件所做的事情进行转译 —— 而不是一个读取源的真理(您的导航 XML 文件)的 Gradle 插件,我们将使用您现有的 Kotlin 代码作为真理的来源。
这意味着如果您编写了一个类似于以下的 Kotlin DSL 片段的代码:
composable(
route = "profile/{userId}/{name}",
arguments = listOf(
navArgument("userId") {
type = NavType.Int,
nullable = false
},
navArgument("name") {
type = NavType.String,
nullable = true
}
)
) {
我们将生成您需要导航到“profile”目标并提取这些参数的 ProfileDestination 和 ProfileArgs 类。但是,经过调查后发现,这并不像想象中那么容易。像 “profile/{userId}/{name}” 这样的字符串信息在技术上是可以提取出来的,但只能作为 Kotlin 编译器插件。即使这样,虽然我们可以找到传递给 Kotlin DSL 的路由字符串,但是如果它不是一个常量字符串,则很难解析该字符串。目前这种解决方案已经不再维护。
那么,如果 Kotlin DSL 代码不是一个可行的真实来源,什么是一个可行的真实来源呢?有什么工具可以读取这些信息?
事实证明,另外两个重要选项(KSP 和 Kotlin Serialization)也都是 Kotlin 编译器插件。但值得注意的是:它们是随着每个 Kotlin 版本一起发布的插件,这对于让开发者能够及时使用新版本的 Kotlin 是至关重要的。
为什么选择 Kotlin Serialization
我们在开发 Navigation 组件时遵循的一个指导原则是尽量减少 Navigation 代码的“传染性”:例如,轻松地将我们的库替换为其他库(没有判断!)。如果您的每个文件中都散布着 Navigation 代码和类,您永远无法摆脱它。
这就是为什么我们的测试指南明确建议在屏幕级别的组合方法中不要有任何对 NavController 的引用,也是为什么没有 NavController 组合本地:您层次结构深处的按钮,尽管它可能很方便,但不应与您特定的导航库绑定。
因此,当寻找一个完全独立于 Navigation 类的方式来定义图中的每个目标时,这种方法正是我们正在寻找的。
这意味着,如果您想要在导航图中定义一个新目标,您可以编写最简单的代码:
// 定义一个不带任何参数的 home 目标
@Serializable
object Home
// 定义一个带有 ID 的 profile 目标
@Serializable
data class Profile(val id: String)
您会注意到这些故意不需要实现任何 Navigation 提供的接口,甚至不需要在具有 Navigation 依赖的模块中定义它们。但是,它们足以封装目标的有意义名称(如果您将其命名为 object Object1,您可能会受到一些怀疑)和对该目标的标识至关重要的任何参数。这看起来像是一个可行的真实来源。
借助 Kotlin Serialization 作为可行的编译时安全性来源,我们继续对接收字符串路由的每个 API 添加了基于 Kotlin Serialization 的重载。
示例代码
因此,一旦定义了您的 Home 和 Profile Serializable 类,您的图现在看起来像是这样的:
NavHost(navController, startDestination = Home) {
composable<Home> {
HomeScreen(onNavigateToProfile = { id ->
navController.navigate(Profile(id))
})
}
composable<Profile> { backStackEntry ->
val profile: Profile = backStackEntry.toRoute()
ProfileScreen(profile)
}
}
您应该立即注意到一件事:没有字符串!具体来说:
- 在定义组合目标时没有路由字符串 —— 指定类型就足以为您生成路由以及参数(不再需要
navArgument
)。 - 导航到新目标时没有路由字符串。您向
NavController
传递与要导航到的目标关联的Serializable
对象。 - 在定义导航图的起始目标时没有路由字符串。
对于 Profile 屏幕,我们使用了 toRoute() 扩展方法从 NavBackStackEntry
和其参数中重新创建 Profile 对象。在 SavedStateHandle
上也有类似的扩展方法,因此在不需要引用特定参数键的情况下,从 ViewModel 中轻松获取类型安全的参数也是同样容易的。
这种新方法适用于单个目标,因此您可以逐步从当前方法迁移到这种新方法,逐个目标或逐个模块。
路由 vs 路由模式
我个人最喜欢这种类型安全方法的一个特性是它清楚地表明哪些 API 支持路由模式(例如,“profile/{id}”)以及哪些支持填充的路由(例如,“profile/42”)。例如,popBackStack()
API 实际上支持两种,但当它的参数只是一个字符串时这一点并不明显。使用类型安全的 API,情况就清楚得多:
// 弹出到包括 Profile 屏幕在内的堆栈顶部实例
navController.popBackStack<Profile>(inclusive = true)
// 弹出到具有 ID 42 的 Profile 屏幕的确切实例
// 同时弹出任何其他位于其顶部的实例在后向堆栈中的目标
navController.popBackStack(Profile(42), inclusive = true)
因此,当您看到一个接受具体类的 API 时,您会知道它表示该类型的任何目标,而无论其参数如何。而当一个接受该类的实际实例时,它用于查找具有完全匹配参数的特定目标。
诸如getBackStackEntry()
或甚至图的 startDestination
这样的 API 就是技术上已经支持两种情况一段时间的例子,您甚至可能都不知道它们!
自定义类型
如果您确实在原始类型之外进行了某些自定义(例如 Parcelable
类型),您甚至可以将其用作 Serializable
类的字段,方法是编写自己的自定义 NavType
并在构建图时传递它:
// Search 屏幕需要更复杂的参数
@Parcelable
data class SearchParameters(
val searchQuery: String,
val filters: List<String>
)
@Serializable
data class Search(
val parameters: SearchParameters
)
val SearchParametersType = object : NavType<SearchParameters>(
isNullableAllowed = false
) {
// 请参阅上面链接的自定义 NavType 文档以了解如何实现此操作的示例
}
// 现在在您的目标中使用它
composable<Search>(
typeMap = mapOf(typeOf<SearchParameters>() to SearchParametersType)
) { backStackEntry ->
val searchParameters = backStackEntry.toRoute<Search>().parameters
}
注意:这应该是一个路障:认真考虑一下,是否不可变的、快照式的参数真的是这些数据的真实来源,或者是否这应该真的是从一个响应式源(例如从存储库公开的流)中检索的对象,如果您的数据发生更改,它将自动刷新。
放心使用
完整的类型安全 API 从 Navigation 2.8.0-alpha08 版本开始提供。除了支持我们支持的所有 Kotlin DSL 构建器(包括我们在这里讨论的 Navigation Compose 和带有 Fragment 的 Navigation)之外,它还包括其他一些可能会让您感兴趣的 API,例如 navDeepLink API,它接受一个 Serializable 类和一个前缀,可以让您轻松地将外部链接连接到相同的类型安全 API。
如果您发现任何问题或对我们遗漏的 API 有功能请求,请提交问题 —— 当这些 API 仍处于 alpha 阶段时,是请求更改的最佳时机。
结论
Navigation Compose 和类型安全系统的结合代表了 Android 开发中导航的一大飞跃。通过将 Kotlin Serialization 与 Navigation 整合,我们能够实现更轻松的编译时类型安全,减少手动编写代码的工作量,提高了代码的可维护性和健壮性。这一新特性让开发者能够更专注于构建高质量的 Android 应用程序,摆脱了手动管理字符串路由和参数的繁琐工作。随着 Navigation 组件的不断演进,我们可以期待更多功能和改进,以进一步改善 Android 应用程序的开发体验。在您的项目中尝试使用 Navigation Compose 和类型安全系统,体验其中带来的便利和优势,以便更高效地构建出色的 Android 应用程序!