【NowInAndroid架构拆解】系列文章
- 【NowInAndroid架构拆解】(1)分层设计与模块化
- 【NowInAndroid架构拆解】(2)数据层的设计和实现之model与database
- 【NowInAndroid架构拆解】(3)数据层的设计和实现之network
- 【NowInAndroid架构拆解】(4)数据层的设计和实现之data
- 【NowInAndroid架构拆解】(5)VM层的设计和实现之ForYouViewModel
- 【NowInAndroid架构拆解】(6)View层的设计和实现之Navigation路由
- 【NowInAndroid架构拆解】(7)UI层解析——MainActivity构建过程
- 【NowInAndroid架构拆解】(8)UI层解析——ForYou页面展示
- 【NowInAndroid架构拆解】(9)重新审视NowInAndroid架构设计
- 【NowInAndroid架构拆解】番外篇1之Jetpack Compose Navigation
- 【NowInAndroid架构拆解】番外篇2之Bottom Navigation底部导航
- 【NowInAndroid架构拆解】番外篇3之给xml布局者最佳的Jetpack Compose介绍文章
前言
导航(Navigation) 是APP中非常重要的功能,不论任何APP一定都会使用到应用内、应用间的跳转。广义的打开新页面、狭义的展示Dialog、Toast,都属于导航的表现形式。
导航功能并不仅仅是打开一个新页面这么简单,其中涉及到路由寻址、参数的安全传递、栈状态管理等知识。本文将对比传统的Intent跳转与JetpackCompose中引入的Navigation导航之间的差异,旨在拆解Navigation设计的底层思想,以及它是如何与Compose组件协同发挥作用的。
概念:“导航”与“路由”的区别
在导航体系的设计中,导航(Navigation)和路由(Route)是紧密相关但职责不同的两个概念。Jetpack Compose中对此进行了明确的区分,事先了解这两者之间的区别,有助于更好领会Navigation框架设计的意图。
1.路由(Route)
- 定义:
- 路由是导航的“地址”,是一个唯一标识符(字符串),用于表示应用中的某个界面(目的地)。
- 例如:"home"、"profile/{userId}"、"settings/notifications"。
- 核心作用:
- 唯一标识目的地:每个可组合界面(Composable)必须对应一个路由,通过路由可以跳转到目标界面。路由就像是页面的身份证。
- 参数传递:支持在路由中嵌入参数(如 "profile/{userId}"),并自动解析为类型安全的值(如 Int、String)。
- 深层链接:路由可以与深层链接(DeepLink)直接关联,例如 "app://details/{id}"。
- 示例:
composable(route = "profile/{userId}") { backStackEntry ->
val userId = backStackEntry.arguments?.getInt("userId")
ProfileScreen(userId)
}
2.导航(Navigation)
- 定义:
- 导航是管理路由之间跳转的逻辑,包括跳转、返回栈管理、参数解析、动画等。
- 其核心组件是 NavController 和 NavHost。
- 核心作用:
- 跳转逻辑:通过 NavController.navigate(route) 触发路由跳转。
- 返回栈管理:自动处理返回栈(如 popBackStack() 或 navigateUp())。
- 状态同步:导航状态(当前路由、参数)与 Compose UI 自动同步,触发重组。
- 导航图(NavGraph):将多个路由组织成一个有向图,定义应用的整体导航结构。
- 示例:
// 定义导航图
NavHost(navController, startDestination = "home") {
composable("home") { HomeScreen() }
composable("profile/{userId}") { ... }
}
// 触发导航
navController.navigate("profile/123")
关键区别
特性 | 路由(Route) | 导航(Navigation) |
---|---|---|
角色 | 目的地的唯一标识符(地址) | 管理路由之间的跳转和状态 |
职责 | 定义界面地址和参数格式 | 处理跳转逻辑、返回栈、动画、深层链接等 |
数据载体 | 仅包含地址和参数(如 "profile/123") | 包含路由集合、跳转行为、状态管理 |
实现方式 | 通过 composable(route) 定义 | 通过 NavController 和 NavHost 实现 |
代码样例
1. 定义路由
// 使用密封类集中管理路由(推荐)
sealed class Route(val route: String) {
object Home : Route("home")
object Profile : Route("profile/{userId}")
object Settings : Route("settings")
}
2. 使用导航跳转
// 跳转到 Profile 界面并传递参数
navController.navigate(Route.Profile.route.replace("{userId}", "123"))
// 返回上一个界面
navController.popBackStack()
3. 处理复杂导航
// 跳转时添加动画
navController.navigate("details", NavOptions.Builder()
.setEnterAnim(R.anim.slide_in)
.build()
)
// 清空返回栈并跳转到登录页
navController.navigate("login") {
popUpTo("home") { inclusive = true } // 移除所有 "home" 之上的路由
}
为什么需要区分两者?
- 关注点分离:
- 路由关注 “去哪里”(目的地和参数)。
- 导航关注 “怎么去”(跳转逻辑、状态管理)。
- 灵活性:
- 同一路由可以对应多种跳转方式(如带动画或不带动画),而导航负责实现这些差异。
- 类型安全:
- 路由的格式(如 "profile/{userId:Int}")强制参数类型,而导航确保参数在跳转时被正确解析。
对“路由”和“导航”的总结
- 路由:是导航的“目标地址”,定义了界面标识和参数格式。
- 导航:是管理路由跳转的“引擎”,处理跳转逻辑、返回栈和状态同步。
- 二者协作:路由提供跳转目标,导航实现跳转行为。这种分离使代码更清晰、可维护性更高,同时支持类型安全和复杂的导航场景。
传统路由方式的缺点
传统的路由通过Intent来设置跳转页面,有显式和隐式两种途径:
- 显式跳转:通过
Intent.setClassName()
指明要跳转的目标页面具体类名 - 隐式跳转:通过
Intent.setCategory()
和Intent.setData()
来隐式关联要跳转的页面
在传递参数方面,传统的方式通过putExtra()来保存要传递的参数,实际上是通过Bundle来临时保存的。
虽然使用Intent进行跳转的代码相对简洁,但缺点也很明显:
- 模块耦合:例如从A类跳转到B类时,必须显式指明B的类名作为参数,在模块层面,A需要依赖B。
- 参数安全性差:通过Bundle的Key-Value形式传递参数,类型、数值安全缺乏保障。
- 代码难以复用:不能与Jetpack Compose组件配合使用,兼容性不足。
- 传递返回值代码繁琐:太多
startActivityForResult
和onActivityResult
的模板代码。
Jetpack Compose中的Navigation设计
如下引入依赖:
dependencies {
def nav_version = "2.7.6"
implementation "androidx.navigation:navigation-compose:$nav_version"
}
三个重要组成部分
Navigation组件由NavController、NavGraph和NavHost三部分组成。
NavHost
可以理解为“页面”,内部由Screen构成。每一个Screen都有唯一对应的Route路由。
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "login") { // login为route
// ...
composable("login") { // composable定义了jetpack compose中的一个节点
// 页面元素
LoginScreen()
}
}
NavController
管理composable页面跳转,维护页面栈Stack,可以通过rememberNavController
来获取状态。
val navController = rememberNavController()
...
navController.navigate("profile")
举例:页面跳转
以上是一个单Activity应用场景,NavHost对应这个Activity,它由两个Screen构成:Login和Home。当用户成功登录后,进入主页。
1. 声明可用于跳转的Screen
首先在AppRouter.kt
文件中,声明这两个Screen,利用seal密封类,进行有效性约束和校验。
// AppRouter.kt
private object Route {
const val LOGIN = "login"
const val HOME = "home"
}
sealed class Screen(val route: String) {
object Login: Screen(Route.LOGIN)
object Home: Screen(Route.HOME)
}
2. 设计实现每个Screen
使用@Composable
注解声明函数,对应登录页面和首页。
//1. LoginScreen.kt
@Composable
fun LoginScreen() {
// login content
}
//2. HomeScreen.kt
@Composable
fun HomeScreen() {
// home content
}
3. 使用NavHost来管理Screen
作为容器,包装以上两个Screen,初始页面为Login:
// RootNavHost.kt
@Composable
fun RootNavHost() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = AppRouter.Screen.Login.route // 初始route
) {
composable(AppRouter.Screen.Login.route) { // 由2个Screen组成
LoginScreen()
}
composable(AppRouter.Screen.Home.route) {
HomeScreen()
}
}
}
4. 在Activity里关联RootNavHost
这里采用单一Activity的设计,在onCreate里调用RootNavHost()函数。
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
TestArticleTheme {
// ....
RootNavHost()
}
}
}
}
5. HomeScreen具体实现
这个页面相对简单,不涉及导航,因此直接采用compose组件实现。
// HomeScreen.kt
@Composable
fun HomeScreen() {
Surface(
modifier = Modifier
.fillMaxSize()
.background(Color.Green),
color = Color.Green
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "Home Screen", style = MaterialTheme.typography.h4, color = Color.White)
}
}
}
6. LoginScreen具体实现
留出一个按钮,用于跳转到HomeScreen。
// LoginScreen.kt
@Composable
fun LoginScreen() {
Surface(
modifier = Modifier
.fillMaxSize()
.background(Color.Gray),
color = Color.Gray
) {
... // login content
... // add your login email/password ui here
Button(
onClick = {
// todo login button click here
},
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text("Login")
}
}
}
7. 使用navigation实现Login->Main跳转
7.1 简易方法,直接传入NavController用作参数
实现简单,但缺点是不够灵活,如果下次需求改为跳转Home以外的页面,需要再修改LoginScreen函数。
@Composable
fun LoginScreen(navController: NavController) {
Button(onClick = {
navController.navigate(AppRouter.Screen.Home)
}) {
...
}
}
7.2 更好地实现,使用高阶函数/lambda
将跳转的逻辑抽出作为高阶函数,当点击LoginScreen中的Button时触发。这样即使以后要跳转其它页面,也无需改动LoginScreen。
RootNavHost(
navController = navController,
startDestination = AppRouter.Screen.Login.route
) {
composable(AppRouter.Screen.Login.route) {
LoginScreen {
// 跳转逻辑作为参数传入
navController.navigate(AppRouter.Screen.Home.route)
}
}
composable(AppRouter.Screen.Home.route) {
HomeScreen(navigateToLogin =
navController.navigate(AppRouter.Screen.Login.route)
}
}
@Composable
fun LoginScreen(navigateToHome: () -> Unit) { // 接收高阶函数作为参数
// ... LoginScreen content
// 触发高阶函数进行跳转
Button(onClick = { navigateToHome() }) {
Text("Login")
}
}
@Composable
fun HomeScreen(navigateToLogin: () -> Unit) {
// ... HomeScreen content
Button(onClick = { navigateToLogin() }) {
Text("Logout")
}
}
导航到一个Dialog
对于展示Dialog的场景,同样可以通过Navigation来实现。
RootNavHost(
navController = navController,
startDestination = AppRouter.Screen.Login.route
) {
composable(AppRouter.Screen.Login.route) {
LoginScreen {
navigateTo(AppRouter.Screen.Home.route)
}
}
// dialog composable
dialog(
route = AppRouter.Screen.ProductDetail.route // 注册为dialog route
) {
ProductDetail() // 显示商品详情
}
}
设置Dialog为全屏
使用theme.xml
来设置Dialog的显示样式。
dialog(
route = AppScreen.Main.ProductDetail.route,
dialogProperties = DialogProperties(usePlatformDefaultWidth = false)
) {
// 读取参数,也可以从ViewModel中获取
val productId = rootNavBackStackEntry?.arguments?.getString("productId")
if (productId != null){
ProductDetailScreen(productId = productId) {
navController.navigateUp()
}
}
}
Navigation过程中的参数传递
在传递参数时,对于基本类型,可以在route中加入占位符,类似传统的deeplink写法。
例如,我们从HomeScreen跳转到商品详情页ProductDetailsPage,此时使用productId
作为参数。
对于跳转参数,创建ArgParams
单例类进行封装。
AppRouter.kt
private object Routes { // 路由汇总
const val LOGIN = "login"
const val HOME = "home"
// product details
const val PRODUCT_DETAIL = "productDetail/{${ArgParams.PRODUCT_ID}}"
}
sealed class Screen(val route: String) {
object ProductDetail: Screen( // 加入到Screen密封类中
route = Routes.PRODUCT_DETAIL,
navArguments = listOf(navArgument(ArgParams.PRODUCT_ID) {
type = NavType.Companion.StringType}){
fun createRoute(productId: String) =
Routes.PRODUCT_DETAIL.replace(
ArgParams.toPath(ArgParams.PRODUCT_ID), productId)
}
}
private object ArgParams {
const val PRODUCT_ID = "productId"
fun toPath(param: String) = "{${param}}"
}
// 默认参数为空列表
sealed class Screen(val route: String, val navArguments: List<NamedNavArgument> = emptyList()) {
object Login: Screen(AppRoute.LOGIN)
object Home: Screen(AppRoute.HOME)
object ProductDetail : Screen(
Routes.PRODUCT_DETAIL,
navArguments = listOf(navArgument(ArgParams.PRODUCT_ID) {
type = NavType.Companion.StringType
})
) {
fun createRoute(productId: String) =
Routes.PRODUCT_DETAIL
.replace(ArgParams.toPath(ArgParams.PRODUCT_ID), productId
)
}
}
修改HomeScreen函数,使点击商品缩略图时,Navigation到详情页。
RootNavHost(
navController = navController,
startDestination = AppRouter.Screen.Login.route
) {
HomeScreen(onProductClick = {
val route = AppRouter.ProductDetail.createRoute(productId = it)
navController.navigate(route)
})
}
对于详情页ProductDeailScreen,可以从backStackEntry中读取到productId参数。
//RootNavHost.kt
RootNavHost(
navController = navController,
startDestination = AppRouter.Screen.Login.route
) {
composable(route = AppScreen.Main.ProductDetail.route) { // 加入详情页路由
backStackEntry ->{
val productId = backStackEntry.arguments?.getString("productId")
ProductDetail(productId) {
}
}
}
// ProductDetail.kt
@Composable
fun ProductDetail(
productId: String,
onBackPressed: () -> Unit,
) {
// 展示productId对应的商品详情UI
}