降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,像Sunflower和JetNews也是使用Navigation来实现导航的,是非常好的学习案例。

References

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

保护原创,请勿转载!

相关推荐
千里马学框架4 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台4 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone4 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc4 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo4 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077004 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
ai2work4 天前
ch23 综合复刻:从零做一个最小可用版本(capstone)
kotlin
其实防守也摸鱼4 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
ai2work4 天前
ch21 签名、校验与发版
kotlin
AFinalStone4 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui