11、Navigation

69 阅读2分钟

使用 Compose 进行导航

Navigation 组件支持 Jetpack Compose 应用,允许在利用 Navigation 组件基础架构和功能的同时,在可组合项之间进行导航。

添加依赖项

在应用模块的 build.gradle 文件中添加以下依赖项:

dependencies {
    def nav_version = "2.8.9"
    implementation "androidx.navigation:navigation-compose:$nav_version"
}

Kotlin DSL 版本:

dependencies {
    val nav_version = "2.8.9"
    implementation("androidx.navigation:navigation-compose:$nav_version")
}

基础概念

实现导航时,需要实现三个核心组件:导航宿主、导航图和导航控制器。与 View 系统不同,Compose 使用 NavHost 作为导航宿主,使用可组合函数定义导航图,使用 NavController 进行导航操作。

创建导航控制器

使用 rememberNavController() 创建并记住 NavController 实例:

@Composable
fun MyApp() {
    val navController = rememberNavController()
    
    NavHost(navController, startDestination = "profile") {
        // 导航图
    }
}

设计导航图

使用 NavHost 组件和 composable 函数构建导航图。每条路线定义一个可组合项,当导航到该路线时会显示:

@Composable
fun MyApp() {
    val navController = rememberNavController()
    
    NavHost(navController, startDestination = "profile") {
        composable("profile") { ProfileScreen() }
        composable("friendslist") { FriendsListScreen() }
    }
}

带参数的导航

可以使用类型安全的参数定义路线:

@Serializable
data class Profile(val id: String)

@Serializable
data class FriendsList(val type: String)

@Composable
fun MyApp() {
    val navController = rememberNavController()
    
    NavHost(navController, startDestination = Profile("user123")) {
        composable<Profile> { backStackEntry ->
            // 获取传递的参数
            val profile = backStackEntry.toRoute<Profile>()
            ProfileScreen(profile.id)
        }
        composable<FriendsList> { backStackEntry ->
            val friendsList = backStackEntry.toRoute<FriendsList>()
            FriendsListScreen(friendsList.type)
        }
    }
}

导航到可组合项

使用 NavControllernavigate 方法在目的地之间导航:

navController.navigate("profile")

// 类型安全的导航
navController.navigate(Profile("username"))

在目的地之间传递数据

强烈建议不要在导航时传递复杂数据对象,而是传递最少必要信息(如唯一标识符),然后在目标屏幕中从数据层加载完整数据:

// 仅传递用户ID
navController.navigate(Profile(id = "user1234"))

在目标 ViewModel 中,使用 SavedStateHandle 检索参数:

class UserViewModel(
    savedStateHandle: SavedStateHandle,
    private val userInfoRepository: UserInfoRepository
) : ViewModel() {
    private val profile = savedStateHandle.toRoute<Profile>()
    
    // 从数据层获取用户信息
    private val userInfo: Flow<UserInfo> = userInfoRepository.getUserInfo(profile.id)
    // ...
}

深层链接

Navigation Compose 支持深层链接,可定义为 composable() 函数的一部分:

@Serializable
data class Profile(val id: String)

val uri = "https://www.example.com"
composable<Profile>(
    deepLinks = listOf(
        navDeepLink<Profile>(basePath = "$uri/profile")
    )
) { backStackEntry ->
    ProfileScreen(id = backStackEntry.toRoute<Profile>().id)
}

要向外部应用公开深层链接,需在 manifest.xml 中添加 intent-filter:

<activity ...>
    <intent-filter>
        ...
        <data android:scheme="https" android:host="www.example.com" />
    </intent-filter>
</activity>

与底部导航集成

NavController 与底部导航关联,可通过选择底部栏中的图标进行导航:

dependencies {
    implementation "androidx.compose.material:material:1.7.8"
}

Kotlin DSL 版本:

dependencies {
    implementation("androidx.compose.material:material:1.7.8")
}

实现底部导航:

data class TopLevelRoute<T : Any>(val name: String, val route: T, val icon: ImageVector)

val topLevelRoutes = listOf(
    TopLevelRoute("Profile", Profile, Icons.Profile),
    TopLevelRoute("Friends", Friends, Icons.Friends)
)

val navController = rememberNavController()
Scaffold(
    bottomBar = {
        BottomNavigation {
            val navBackStackEntry by navController.currentBackStackEntryAsState()
            val currentDestination = navBackStackEntry?.destination
            
            topLevelRoutes.forEach { topLevelRoute ->
                BottomNavigationItem(
                    icon = { Icon(topLevelRoute.icon, contentDescription = topLevelRoute.name) },
                    label = { Text(topLevelRoute.name) },
                    selected = currentDestination?.hierarchy?.any { it.hasRoute(topLevelRoute.route::class) } == true,
                    onClick = {
                        navController.navigate(topLevelRoute.route) {
                            // 避免返回堆栈过大
                            popUpTo(navController.graph.findStartDestination().id) {
                                saveState = true
                            }
                            // 避免同一目的地的多个副本
                            launchSingleTop = true
                            // 重新选择时恢复状态
                            restoreState = true
                        }
                    }
                )
            }
        }
    }
) { innerPadding ->
    NavHost(navController, startDestination = Profile, Modifier.padding(innerPadding)) {
        composable<Profile> { ProfileScreen(...) }
        composable<Friends> { FriendsScreen(...) }
    }
}

将 Navigation Compose 与 fragment 集成

有两种选择将 Navigation 与 Compose 配合使用:

  1. 使用基于 fragment 的 Navigation 组件定义导航图
  2. 在 Compose 中通过 NavHost 定义导航图

对于混合应用,建议使用基于 Fragment 的 Navigation 组件,然后将每个 fragment 的内容迁移到 Compose。最后,将所有界面与 Navigation Compose 关联并移除所有 fragment。

在 fragment 中与 Compose 交互:

override fun onCreateView( /* ... */ ) {
    setContent {
        MyScreen(onNavigate = { dest ->
            findNavController().navigate(dest)
        })
    }
}

最佳实践

将导航代码与可组合项分离,以便独立测试:

@Composable
fun ProfileScreen(
    userId: String,
    navigateToFriendProfile: (friendUserId: String) -> Unit
) { ... }

// 导航图中
composable<Profile> { backStackEntry ->
    val profile = backStackEntry.toRoute<Profile>()
    ProfileScreen(userId = profile.id) { friendUserId ->
        navController.navigate(route = Profile(id = friendUserId))
    }
}

测试导航

添加导航测试依赖项:

dependencies {
    androidTestImplementation "androidx.navigation:navigation-testing:$navigationVersion"
}

封装 NavHost 以便测试:

@Composable
fun AppNavHost(navController: NavHostController){
    NavHost(navController = navController){
        // ...
    }
}

编写测试:

class NavigationTest {
    @get:Rule
    val composeTestRule = createComposeRule()
    
    lateinit var navController: TestNavHostController
    
    @Before
    fun setupAppNavHost() {
        composeTestRule.setContent {
            navController = TestNavHostController(LocalContext.current)
            navController.navigatorProvider.addNavigator(ComposeNavigator())
            AppNavHost(navController = navController)
        }
    }
    
    @Test
    fun appNavHost_verifyStartDestination() {
        composeTestRule
            .onNodeWithContentDescription("Start Screen")
            .assertIsDisplayed()
    }
}

其他资源