【NowInAndroid架构拆解】番外篇1之Jetpack Compose Navigation

176 阅读6分钟

【NowInAndroid架构拆解】系列文章


前言

导航(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" 之上的路由
}

为什么需要区分两者?

  1. 关注点分离:
    • 路由关注 “去哪里”(目的地和参数)。
    • 导航关注 “怎么去”(跳转逻辑、状态管理)。
  2. 灵活性:
    • 同一路由可以对应多种跳转方式(如带动画或不带动画),而导航负责实现这些差异。
  3. 类型安全:
    • 路由的格式(如 "profile/{userId:Int}")强制参数类型,而导航确保参数在跳转时被正确解析。

对“路由”和“导航”的总结

  • 路由:是导航的“目标地址”,定义了界面标识和参数格式。
  • 导航:是管理路由跳转的“引擎”,处理跳转逻辑、返回栈和状态同步。
  • 二者协作:路由提供跳转目标,导航实现跳转行为。这种分离使代码更清晰、可维护性更高,同时支持类型安全和复杂的导航场景。

传统路由方式的缺点

传统的路由通过Intent来设置跳转页面,有显式和隐式两种途径:

  • 显式跳转:通过Intent.setClassName()指明要跳转的目标页面具体类名
  • 隐式跳转:通过Intent.setCategory()Intent.setData()来隐式关联要跳转的页面

在传递参数方面,传统的方式通过putExtra()来保存要传递的参数,实际上是通过Bundle来临时保存的。

虽然使用Intent进行跳转的代码相对简洁,但缺点也很明显:

  1. 模块耦合:例如从A类跳转到B类时,必须显式指明B的类名作为参数,在模块层面,A需要依赖B。
  2. 参数安全性差:通过Bundle的Key-Value形式传递参数,类型、数值安全缺乏保障。
  3. 代码难以复用:不能与Jetpack Compose组件配合使用,兼容性不足。
  4. 传递返回值代码繁琐:太多startActivityForResultonActivityResult的模板代码。

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
}

参考资料