降Compose十八掌之『密云不雨』| Navigation

公众号「稀有猿诉」

除了一些玩具性的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是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,像SunflowerJetNews也是使用Navigation来实现导航的,是非常好的学习案例。

References

欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!

保护原创,请勿转载!

相关推荐
戏谑36 分钟前
Android 常用布局
android·view
拭心12 小时前
Google 提供的 Android 端上大模型组件:MediaPipe LLM 介绍
android
带电的小王15 小时前
WhisperKit: Android 端测试 Whisper -- Android手机(Qualcomm GPU)部署音频大模型
android·智能手机·whisper·qualcomm
梦想平凡15 小时前
PHP 微信棋牌开发全解析:高级教程
android·数据库·oracle
元争栈道15 小时前
webview和H5来实现的android短视频(短剧)音视频播放依赖控件
android·音视频
阿甘知识库16 小时前
宝塔面板跨服务器数据同步教程:双机备份零停机
android·运维·服务器·备份·同步·宝塔面板·建站
元争栈道17 小时前
webview+H5来实现的android短视频(短剧)音视频播放依赖控件资源
android·音视频
MuYe17 小时前
Android Hook - 动态加载so库
android
居居飒17 小时前
Android学习(四)-Kotlin编程语言-for循环
android·学习·kotlin
Henry_He20 小时前
桌面列表小部件不能点击的问题分析
android