公众号「稀有猿诉」
除了一些玩具性的Demo以外,相信任何一个应用程序不可能只有一个页面,最为极简的应用也至少会有两个页面,一个主页和一个设置页。对于传统的View系统来说对于导航这块没有专门的API,一般都是自己写逻辑跳Activity,或者跳到Fragment,然后再反向的Back,所以有了很多三方的各种Router类库(如大阿里的ARouter,货拉拉的TheRouter)。其实谷歌已经提供了解决方案,在Jetpack中提供了Navigation组件,专门用于解决应用内部各种页面之间跳转的问题。
对于Jetpack Compose来说,因为是全新的框架,在设计之初就考虑到了导航的问题,但也不是重新开发了一套新库,而是把Jetpack中的组件Navigation深度的结合了进来。换句话说,在Jetpack Compose中可以直接使用Navigation组件来进行页面之间的跳转,并且有非常符合Compose的粘合API,使用起来非常的丝滑顺手。
基本概念
在深入之前有必要先澄清Navigation中的一些概念,了解了一些基本的概念和术语之后,会有助于理解组件的设计理念,也会更容易上手使用。
术语 | 用途 | 具体的Composable |
---|---|---|
Host | 包含了当前导航页面的容器。应该把它理解成为导航的容器,包含着当前的页面, 以及一个NavController。 | NavHost |
Graph | 静态的数组结构,定义着一个应用中的所有页面,以及它们之间应该如何跳转。 | NavGraph |
Controller | 页面之间导航的核心管理者。它封装着如何在页面之间跳转的方法,处理链接的方法,以及返回堆栈的方法。 | NavController |
Destination | 在Graph中的一个节点。当跳转到这个节点时,Host中就包含并展示它的页面。在实际项目中,往往是一个Fragment���者一个Composable,也就是一个页面。 | NavDestination |
Route | Destination的全局唯一标识,包括其所需要的参数。大部分时候,特别是在Compose中,这就是一坨类似于Uri一样的String |
还需要说明一下的就是导航的基本的操作对象是一个页面,一个页面可以理解为一个全屏的,逻辑上内聚,内容上互相关联,自成一家的一个UI页面,比如说一个应用的主页是一个页面,文章列表是一个页面,文章详情是一个页面,设置是一个页面,用户页又是一个页面。当然,这里全屏并不是直观的全屏,意思是说(特别是对于Compose)一个页面的大小是受系统控制的,并不能像普通的Composable那样随意设置大小,对于手机就是全屏的,对于平板可能会一个占据三分之一(列表页),一个占据三分之二(详情页)。
使用Navigation
Jetpack Compose是声明式UI,是函数式编程,每一个Composable都是一个函数,所以在Compose中使用Navigation略微的有点不一样。核心原理和核心的规则肯定与Navigation是一样一样的,只是使用上的API略不一样,其实是更简单更方便了(这是声明式UI带来的收益)。
添加依赖
在使用之前先要添加Navigation库作为项目的依赖:
Kotlin
dependencies {
val navVersion = "2.7.7"
implementation("androidx.navigation:navigation-runtime-ktx:$navVersion")
implementation("androidx.navigation:navigation-compose:$navVersion")
}
使用Navigation的方法
可以通过以下步骤来使用Navigation:
- 创建NavHost,并设置为应用的入口,通过Composable函数NavHost。
- 创建NavController,可以直接创建,但推荐的方式是使用Compose提供的状态构造函数rememberNavController,它的好处在于当前导航会提升为一个状态。
- 定义Destination和Route,其实对于Compose来说都是用类似于Uri的String来作为Destination,每一个Destition唯一对应着一个页面。
- 添加页面,通过函数NavHost的尾部lambda,它实际上是一个NavGraphBuilder的扩展函数,这里调用函数composable来添加页面。
- 配置跳转,通过前面创建的navController来实现跳转,用navController.navigate来跳转到指定的Destination,用navController.popBackStack来返回到前一个页面。而触发的入口肯定是在具体的页面之中,所以页面要把其跳转函数作为参数,在NavGraphBuilder时,再用NavController去实现,这样所有的跳转逻辑就都在NavGraph中,便于管理。
具体实例
说了那么多貌似挺烦杂的,让我们看一个实例就会瞬间明白。
一个简单的应用有4个页面,先定义Destinations:
Kotlin
object Destinations {
const val APP_URI = "http://toughcoder.net/chronos"
const val HOME_ROUTE = "home" // 主页
const val HISTORY_ROUTE = "history" // 历史记录页面
const val SETTINGS = "settings" // 设置页
const val ARTICLES = "articles" // 文章页
}
那么就可以如此配置Navigation:
Kotlin
@Composable
fun ChronosNavGraph(
modifier: Modifier = Modifier,
navController: NavHostController = rememberNavController(),
start: String = Destinations.HOME_ROUTE // 默认的初始页面为主页
) {
NavHost(
modifier = modifier,
navController = navController,
startDestination = start
) {
composable(
route = Destinations.HOME_ROUTE,
deepLinks = listOf(
navDeepLink { uriPattern = "${Destinations.APP_URI}/${Destinations.HOME_ROUTE}" }
)
) {
ChronosScreen(
gotoSettings = { navController.navigate(Destinations.SETTINGS) },
gotoHistory = { navController.navigate(Destinations.HISTORY_ROUTE) },
gotoArticles = { navController.navigate(Destinations.ARTICLES) }
)
}
composable(
route = Destinations.HISTORY_ROUTE,
deepLinks = listOf(
navDeepLink { uriPattern = "${Destinations.APP_URI}/${Destinations.HISTORY_ROUTE}" }
)
) {
HistoryScreen(
viewModel = viewModel()
) {
navController.popBackStack()
}
}
composable(
route = Destinations.SETTINGS,
deepLinks = listOf(
navDeepLink { uriPattern = "${Destinations.APP_URI}/${Destinations.SETTINGS}" }
)
) {
SettingsScreen(
viewModel = viewModel()
) {
navController.popBackStack()
}
}
composable(
route = Destinations.ARTICLES,
deepLinks = listOf(
navDeepLink { uriPattern = "${Destinations.APP_URI}/${Destinations.ARTICLES}" }
)
) {
ArticlesScreen(
viewModel = viewModel()
) {
navController.popBackStack()
}
}
}
}
可以看到每一个composable函数用以创建一个导航页面,里面有其Route,具体的页面,以及跳转的入口函数。deepLinks是每个页面的Uri式的链接,后面会详细的讲解。
最后就是把这个NavGraph作为应用的入口页面:
Kotlin
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ChronosTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
ChronosNavGraph()
}
}
}
}
在页面之间传递参数
页面跳转还必然会涉及参数的传递,比如具有递进关系的两个页面,核心参数肯定要由前一个页传递过去,最为典型的场景就是列表类页面到详情页面的跳转,比如文章列表要把文章的Id传给详情页,这样详情页才知道去展示哪个文章,用户列表要把用户Id传给详情页,详情页才知道展示哪个用户。
Navigation提供了传递参数的方法,在创建导航页面时传入的Route可以加入占位符形参,然后在跳转navController.navigate时可以传入实参,只不过参数的类型有限制,只能是基础数据类型如字串或者数字。目标页面使用时通过backStackEntry.arguments来获得参数。来具体看一下,比如说传递用户Id的场景:
Kotlin
NavHost() {
composable(
"profile/{userId}",
arguments = listOf(navArgument("userId") { type = NavType.StringType } // 这句可以省略,因为默认类型都当成是字符串
) { backStackEntry ->
Profile(navController, backStackEntry.arguments?.getString("userId"))
}
}
上面参数类型的声明,其实可以省略,因为默认的类型都当成String来解析和处理,如果是其他类型则需要显式地声明。这样目标页面的参数就声明好了,我们在跳转的时候传入实参就可以了:
Kotlin
navController.navigate("profile/user1234")
大部分时候参数都是必填参数 ,像上面这样写userId是必填的参数。但有些时候一些非核心的参数,可能不是每次跳转都会传,这就需要页面把参数声明为可选参数。可选参数在声明的时候Uri中必须使用查询式语句,如("?argName={argName}"),另外必须 设置默认值,或者类型是nullable的。这也意味着我们不能省略导航页面构建composable函数中的arguments参数:
Kotlin
composable(
"profile?userId={userId}",
arguments = listOf(navArgument("userId") { defaultValue = "user1234" }) // 注意这里的默认值,当调用navigate时如果不传userId就用这个默认值
) { backStackEntry ->
Profile(navController, backStackEntry.arguments?.getString("userId"))
}
注意: 要尽可能的使用基本的数据类型,如String,Int或者Long,而不传递复杂的数据。复杂的数据通常都是业务逻辑数据,而业务逻辑数据应该使用基本的参数,再从数据源处(通常是通过ViewModel从Repo处)去主动获取,这样才能保证数据的真实有效。这是设计原则中的『单一数据源原则Single Source of Truth』。复杂数据从Repo处获取后,可能会变得过时或者失真,而且在页面之间传递会有拷贝,效率也不高,因此要避免在页面之间传递复杂数据。
处理DeepLinks
DeepLinks是Uri式的链接跳转范式,能够以字符串形式的Uri精准的定位到某个应用的具体某个页面,就犹如互联网中的Uri一样。它的好处在于形成了一个统一的标准,形式简单方便,一个字符串就能定位到一个页面。
使用导航页面构建函数composable在构建页面的时候可以传入NavDeepLink对象,更为方便的是使用其构建函数navDeepLink:
Kotlin
val uri = "https://www.example.com"
composable(
"profile?id={id}",
deepLinks = listOf(navDeepLink { uriPattern = "$uri/{id}" })
) { backStackEntry ->
Profile(navController, backStackEntry.arguments?.getString("id"))
}
从示例中可以看出Uri中还可以带有参数,形参的声明,以及参数的获取与前面提到的页面参数是一样一样的,如果实际传过来的Uri是"www.example.com/user123",到此...
正常情况下这些DeepLinks只能在应用内部使用,如果要对应用外开放,则需要在应用的AndroidManifest文件中进行声明,声明为intent filter:
xml
<activity ...>
<intent-filter>
...
<data android:scheme="https" android:host="www.example.com" />
</intent-filter>
</activity>
页面跳转过渡动画
页面跳转可以指定具体的过渡动画,具体的可以参考前面专门讲动画的那篇文章,这里就不再重复了。
Route的类型安全
通常情况下Route都是使用Uri式的String,但这明显不够安全,因为调用navController#navigate的时候,可能会传一个不认识的页面Route,或者参数传错了(比如数字参数传了String),等等。轻则跳转失败,因为找不到Destination页面,重则会Crash。要想类型安全,就不能使用String式的Uri,需要把Ruote定义为类型(也即class),但要使用注解@Serializeable标记一下:
Kotlin
// 主页面,不带任何参数
@Serializable
object Home
// 用户页面,参数是用户Id,其类型是一个String
@Serializable
data class Profile(val id: String)
然后在构建导航页面的时候,函数composable其实是一个泛型函数,它可以指定Route的参数类型:
Kotlin
NavHost(navController, startDestination = Home) {
composable<Home> { // 泛型函数,可以指定参数类型
HomeScreen(onNavigateToProfile = { id ->
navController.navigate(Profile(id)) // 跳转的时候传入的实参是一个对象,类型就是上面定义的Route
})
}
composable<Profile> { backStackEntry ->
val profile: Profile = backStackEntry.toRoute() // 获取参数的时候,用toRoute来获得Route对象,类型就是我们定义的那个
ProfileScreen(profile.id)
}
}
然后在跳转的时候就可以把Route对象作为实参传进去:
Kotlin
navController.navigate(Profile(id = 123))
这样因为都是定义的类型,所以编译器会做编译时检查,虚拟机也会做运行时的类型检查,保证类型安全。
注意: 不要混淆,这里Route虽然是自定义类型,但并不算是在页面之间传递复杂的业务数据,因为具体的参数仍是诸如String和Int之类的基础数值。把Route定义为类型(class),而不是直接使用String,是为了让编译器帮忙我们保证类型安全,减少出错。
总结
使用Navigation可以非常轻松的把应用的各个页面组织连接起来,形成一个完整的交互闭环。谷歌也提供了相应的CodeLab可以学习一下。此外,谷歌的一些Sample app,像Sunflower和JetNews也是使用Navigation来实现导航的,是非常好的学习案例。
References
欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!
保护原创,请勿转载!