为了账号安全,请及时绑定邮箱和手机立即绑定

探索一下 Jetpack Compose Multiplatform 领域中的安全导航功能

你是否曾在你的应用程序中遇到过导航复杂的用户界面流程的困难?这很常见,尤其是在处理动态和不断变化的用户界面时。根据我的经验,传统的导航图常常感觉像是在赌博——直到深入开发阶段,你才会知道事情会变得多么复杂。这就是为什么我通常选择实现自己的导航解决方案,更喜欢它们提供的控制和灵活性。

然而,Jetbrain 引入 Navigation 的出现让我非常感兴趣,特别是它对安全导航的官方支持。类型安全的承诺加上官方库的支持,使得它值得我们进一步探索。本文将探讨这个框架中的安全导航概念,旨在提供一个清晰和实用的指南,来实现可扩展的导航解决方案。在过程中,我们还将强调 Koin 的依赖注入带来的好处,确保您的应用保持模块化和易于维护。

项目搭建

当然,在开始编码之前,设置项目、依赖项和配置是必要的。本节将介绍本 Jetpack Compose 多平台项目中使用的版本和库,重点介绍安全导航和序列化所必需的关键组件。

版本号

本节列出了项目中用到的各种依赖库和SDK的版本信息。

    [版本]
    # Android SDK
    android-compileSdk = "34"
    android-minSdk = "29"
    android-targetSdk = "34"

    # 目标 JVM 版本
    commonsLogging = "1.3.3"
    jvmTarget = "17"

    # Gradle 插件版本
    agp = "8.2.0"  # Android Gradle 插件版本
    compose-plugin = "1.7.0-dev1750"

    # AndroidX 库版本
    androidx-activityCompose = "1.9.0"

    # JetBrains 和 Kotlin 库版本
    kotlin = "2.0.0"
    coroutines = "1.8.1"
    kotlinx-serialization = "1.6.3"

    # 生命周期视图模型库
    lifecycleViewmodelCompose = "2.8.0"

    # 其他依赖项
    koin = "4.0.0-RC1"
    navigationCompose = "2.8.0-alpha08"
图书馆部分

定义该项目所需的依赖关系,并引用上面定义的版本。

    [库依赖]  
    androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" }  // 指定的AndroidX组件  
    koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" }  
    koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }  
    koin-coroutines = { module = "io.insert-koin:koin-core-coroutines", version.ref = "koin" }  
    koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" }  
    koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" }  
    Kotlin kotlin-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }  
    kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }  
    lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" }  
    navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
插件.

项目中用到的Gradle插件的配置设置。

[插件配置]  
androidApplication = { id = "com.android.application", version.ref = "agp" }  
androidLibrary = { id = "com.android.library", version.ref = "agp" }  
jetbrainsCompose = { id = "org.jetbrains.compose", version.ref = "compose-plugin" }  
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }  
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }  
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

Updated translation incorporating expert suggestions:

[插件设置]  
安卓应用 = { id = "com.android.application", version.ref = "agp" }  
安卓库 = { id = "com.android.library", version.ref = "agp" }  
Compose插件 = { id = "org.jetbrains.compose", version.ref = "compose-plugin" }  
Compose编译器 = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }  
Kotlin多平台插件 = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }  
Kotlin序列化插件 = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
主要依赖项
  • navigation-compose: 此库简化了 Jetpack Compose 中的导航任务,提供了一个类型安全的 API 来定义和处理导航路由。
  • kotlinx-serialization-json: 用于在传递数据或保存状态时序列化和反序列化数据对象,特别有用。
应用启动设置

设置应用程序包括将关键组件注册到 Koin 并配置导航架构。本节详细介绍了如何将导航与 Koin 集成的步骤、做出这些选择的原因以及代码简述。

Koin模块定义
    val 模块 = module {  
        viewModel { p -> MainViewModel(p[0], getAll<INavigationItem<Any>>().sortedBy { it.order }, getAll()) }  
        single { HomeNavigationItem() } bind INavigationItem::class  
        single { TestNavigationItem() } bind INavigationItem::class  
        single { HomeNavigationArea() } bind INavigationArea::class  
        single { TestMainNavigationArea() } bind INavigationArea::class  
        factory { p -> TestViewModel(get()) }  
        single { TestSecondArea() } bind INavigationArea::class  
        factory { p -> TestSecondScreenViewModel(get(), p[0]) }  
    }

定义模块配置,包含视图模型、单例等

  • ViewModel 注册: MainViewModel 通过 Koin 进行注册,参数为 NavHostController。此 ViewModel 管理应用的导航逻辑,并跟踪不同的导航区域和项目。它与 Jetbrains 的多平台 ViewModelStore 进行关联。
  • 导航项和区域的单例: 各种导航项和区域作为单例进行注册,以确保一致的行为和状态在整个应用程序中保持一致。
  • ViewModel 工厂: 工厂根据需要创建类似于 Voyager 的 ScreenViewModel 的 ComposableViewModel,保证每个 ComposableViewModel 实例可以基于必要的依赖项重新创建。
可组合函数应用
    @Composable  
    @Preview  
    fun App() {  
        MaterialTheme {  
            val navController = rememberNavController()  
            val mainViewModel = koinViewModel<MainViewModel> { parametersOf(navController) }  

            Surface(Modifier.fillMaxWidth()) {  
                Row {  
                    NavigationRail {  
                        mainViewModel.navigationAreas.forEach { item ->  
                            NavigationRailItem(  
                                selected = mainViewModel.selectedItem.collectAsState().value == item,  
                                onClick = { mainViewModel.onInteraction(MainInteractions.NavigateTo(item)) },  
                                icon = item.icon,  
                            )  
                        }  
                    }  
                    Box(Modifier.weight(1f).fillMaxHeight()) {  
                        NavHost(  
                            mainViewModel.navController,  
                            startDestination = mainViewModel.selectedItem.value.route,  
                        ) {  
                            mainViewModel.navigatables.forEach { it.display(this) }  
                        }  
                    }  
                }  
            }  
        }  
    }
  • NavController 初始化: 使用 rememberNavController() 初始化一个 NavController。此控制器负责管理应用内的导航,并可以在应用的各处访问到。
  • MainViewModel 集成: 使用 koinViewModel 获取 MainViewModel,并将 NavHostController 对象作为参数传递。这一步将导航控制器与 ViewModel 关联起来,让 ViewModel 能够控制导航。
  • UI 设置: App 函数定义了基本的 UI 结构,包括用于侧面导航的 NavigationRail 和用于显示屏幕的 NavHostNavigationRail 会根据 mainViewModel.navigationAreas 中的内容动态显示项目,让用户轻松在应用的不同部分之间切换。
主视图模型
    class MainViewModel(  
        internal val navController: NavHostController,  
        internal val navigationAreas: List<INavigationItem<Any>>,  
        internal val navigatables: List<INavigationArea>,  
    ) : ViewModel(), KoinComponent {  
        private val module = module {  
            single { navController }  
        }  

        private val _selectedItem = MutableStateFlow(navigationAreas.first())  
        val selectedItem = _selectedItem.asStateFlow()  

        init {  
            getKoin().loadModules(listOf(module))  
        }  

        internal fun onInteraction(interactions: MainInteractions) {  
            when (interactions) {  
                is MainInteractions.NavigateTo -> {  
                    _selectedItem.value = interactions.route  
                    navController.navigate(interactions.route.route)  
                }  
            }  
        }  

        override fun onCleared() {  
            super.onCleared()  
            getKoin().unloadModules(listOf(module))  
        }  
    }
  • NavController 依赖项: NavHostController 在 Koin 中被注册,使其可以跨应用注入。
  • 选中项状态管理: _selectedItem 状态流跟踪当前选中的导航项,并让 UI 能够响应这些变化。
  • 交互处理: onInteraction 函数处理导航事件、更新选中的项以及触发导航操作。
  • 生命周期管理: MainViewModel 在初始化时将 NavHostController 注册到 Koin,并在 ViewModel 清除时卸载它,确保了正确的生命周期管理。
可扩展性的接口组件改为可扩展的接口组件

将各种组件进行抽象和解耦对于维护 Jetpack Compose Multiplatform 中可扩展且可维护的架构至关重要。本节介绍了项目中使用的接口以及它们的作用,详细解释这些接口的作用及其如何促进模块化设计。通过为导航项和区域定义清晰的契约,我们确保应用程序中的行为一致性,并简化新功能的集成,从而使系统更加灵活。

INavigationItem 接口

The INavigationItem 接口定义了应用导航项的约定。它包括图标、标签、路由和排序的属性,使得应用可以动态显示这些导航元素。

    interface INavigationItem<T : Any> {  
        // 图标是一个可组合函数
        val icon: @Composable () -> Unit  
        // 标签字符串
        val label: String  
        // 路由路径
        val route: T  
        // 排序顺序
        val order: Int  
    }
  • icon: 一个可组合的函数,用于返回导航项的图标。
  • label: 导航项的标签字符串。
  • route: 与导航项相关联的类型安全路由。
  • order: 定义导航项在导航列表中显示顺序的整数。
示例:HomeNavigationArea
    class HomeNavigationItem : INavigationItem<Home> {  
        override val icon: @Composable () -> Unit = {  
            Icon(Icons.Default.Home, contentDescription = "主页")  
        }  
        override val label: String = "主页"  
        override val route: Home = Home  
        override val order: Int = 0  
    }

在这个实现中,HomeNavigationItem 类为“主页”页面提供特定的图标、标签和路由。order 属性设为 0,表示它在导航列表中的顺序。

INavigationArea: 接口

INavigationArea接口定义了导航区域的契约,这些区域负责在应用中显示特定的屏幕或部分内容。它包含一个display函数,该函数接受一个NavGraphBuilder参数作为输入。

    接口INavigationArea {  
        fun 显示导航图(navGraphBuilder: NavGraphBuilder)  
    }
  • 显示: 利用 NavGraphBuilder 注册可组合项和路由来定义导航区域的显示方式。
示例:HomeNavigationArea
    class HomeNavigationArea : INavigationArea {  
        override fun display(navGraphBuilder: NavGraphBuilder) {  
            navGraphBuilder.composable<Home> {  
                Home()  
            }  
        }  

        @Composable  
        private fun Home() {  
            Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {  
                Column(horizontalAlignment = Alignment.CenterHorizontally) {  
                    Icon(Icons.Default.Home, contentDescription = "主页", modifier = Modifier.size(96.dp))  
                    Text(text = "主页", style = MaterialTheme.typography.headlineLarge)  
                }  
            }  
        }  
    }

HomeNavigationArea 类实现了 INavigationArea 接口的功能,并定义了用于显示的 display 函数。它使用 NavGraphBuilder 注册了一个可组合函数 Home,该函数负责渲染“首页”屏幕。

应用程序的初始状态

使用密封类和序列化扩展导航能力

在本节中,我们将探讨该库如何利用密封类(sealed class)和序列化技术来扩展 Jetpack Compose Multiplatform 中的导航功能。这种方法允许我们封装和定义复杂的导航场景,并在不同屏幕之间安全传递数据。密封类定义了一个封闭的导航目标集合,确保导航逻辑的安全性和清晰性。

导航相关的封闭类

密封类提供了一种以类型安全的方式表示不同状态或类型的强大方法。在我们的导航配置中,我们使用密封类来定义应用程序“Test”部分中的不同屏幕。

    @Serializable  
    密封类 TestScreens {  
        @Serializable  
        数据对象 TestMainScreen : TestScreens()  

        @Serializable  
        内部数据类 TestSecondScreen(val text: String) : TestScreens()  
    }
  • TestMainScreen: 代表“测试主屏幕”。
  • TestSecondScreen: 该类代表测试第二屏,能够携带一个字符串参数,展示了在屏幕间传递数据的能力。该类是内部类,因为我们可能只想让这个软件包路由到这个屏幕,而不是让其他软件包使用它。
测试导航项: TestNavigationItem

为了让“测试”部分融入到应用的导航里,我们定义了一个名为TestNavigationItem的组件,它实现了INavigationItem接口。

class TestNavigationItem : INavigationItem<TestScreens.TestMainScreen> {  
    override val icon: @Composable () -> Unit = {  
        Icon(Icons.Default.Science, contentDescription = "示例")  
    }  
    override val route = TestScreens.TestMainScreen  
    override val label: String = "示例"  
    override val order: Int = 1  
}
导航栏: TestMainNavigationArea

TestMainNavigationArea 类决定了如何显示和切换“测试”部分的屏幕。

    class 测试主导航区域 : INavigationArea {  
        override fun display(navGraphBuilder: NavGraphBuilder) {  
            navGraphBuilder.composable<测试屏幕.主屏幕> {  
                TestMain()  
            }  
        }  

        @Composable  
        private fun TestMain() {  
            val koin = getKoin()  
            Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {  
                ComposableViewModel({ koin.get<测试视图模型>() }) { viewModel ->  
                    Column(horizontalAlignment = Alignment.CenterHorizontally) {  
                        Icon(Icons.Default.Science, contentDescription = "科学", modifier = Modifier.size(96.dp))  
                        Text(text = viewModel.titleText, style = MaterialTheme.typography.headlineLarge)  
                        Button(onClick = { viewModel.onInteraction(TestMainInteractions.跳转到第二个屏幕) }) {  
                            Text("去第二个屏幕")  
                        }  
                    }  
                }  
            }  
        }  
    }
视图模型 (TestViewModel)

TestViewModel(处理“测试”板块的逻辑,包括但不限于导航动作)

    class TestViewModel(private val navController: NavHostController) : ComposeViewModel() {  
        internal val titleText = "测试屏幕"  

        internal fun onInteraction(interactions: TestMainInteractions) {  
            when (interactions) {  
                TestMainInteractions.GoToSecondScreen -> {  
                    navController.navigate(TestScreens.TestSecondScreen("发送此内容到第二屏幕"))  
                }  
            }  
        }  

        override suspend fun onClear() {  
            println("TestViewModel 清除完毕")  
        }  
    }
  • onInteraction: 处理交互,例如导航到第二个界面。它使用 NavHostController 导航到 TestSecondScreen 并传递数据给路由。
  • onClear: 当 ViewModel 被销毁时调用的清理方法,确保资源管理正确。
处理 TestSecondArea 中的导航参数(例如在该区域中)
实现: TestSecondArea

TestSecondArea 类实现了 INavigationArea 接口,并定义了 "TestSecondScreen" 的行为。此屏幕可以从前一个屏幕接收参数,展示了高级导航能力。

    class TestSecondArea : INavigationArea {  
        override fun display(navGraphBuilder: NavGraphBuilder) {  
            navGraphBuilder.composable<TestScreens.TestSecondScreen> {  
                TestSecond(it.toRoute())  
            }  
        }  

        @Composable  
        private fun TestSecond(args: TestScreens.TestSecondScreen) {  
            val koin = getKoin()  
            Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {  
                ComposableViewModel({ koin.get<TestSecondScreenViewModel> { parametersOf(args.text) } }) { viewModel ->  
                    Column(horizontalAlignment = Alignment.CenterHorizontally) {  
                        Icon(Icons.Default.BackHand, contentDescription = "Test", modifier = Modifier.size(96.dp))  
                        Text(text = viewModel.title, style = MaterialTheme.typography.headlineLarge)  
                        Button(  
                            onClick = { viewModel.onInteraction(TestSecondScreenInteractions.GoToBack) },  
                            enabled = viewModel.goBackAvailable,  
                        ) {  
                            Text("返回上一页")  
                        }  
                    }  
                }  
            }  
        }  
    }
通过 toRoute() 获取路由参数:

toRoute() 扩展函数通过将 NavBackStackEntry 转换成你指定的类型,使这一过程更简便。

toRoute()是怎么工作的

toRoute() 函数是 导航后退栈条目 的一个扩展函数,用于获取目的地参数并将其映射到密封类层次结构中定义的数据类上。此过程确保在屏幕之间传递的数据类型正确,并在目标可组合项中可用。

导航模块化设置的优点

本文中概述的导航设置采用了优良的封装设计,带来了几个重要的好处。这种方法不仅简化了导航逻辑处理,还增强了应用程序的扩展性和维护性。让我们来聊聊几个关键优势:

1. 关注分离

通过将导航逻辑封装在 INavigationAreaINavigationItem 接口中,导航设置保持了清晰的职责划分。MainViewModel 不包含对特定导航目的地的硬依赖,使其更专注于状态管理和交互,而不是处理导航逻辑。这种分离让:

  • 简化视图模型: MainViewModel 仅负责管理导航项和区域,而不涉及具体的导航路线和屏幕管理。
  • 模块化导航区域: 每个 INavigationArea 定义的导航区域负责自己的路线和屏幕。这种模块化方法使得添加或修改屏幕变得轻松,而不会影响应用程序的其他部分。
2. 可伸缩性

随着应用程序的发展,封装的导航设置使得扩展导航结构变得简单直接。开发人员可以轻松添加新的屏幕和导航项而无需修改现有代码。

  • 要引入新功能: 开发人员只需创建一个新的 INavigationArea 实现并将其注册到 Koin 中。这个新区域可以定义自己的路由和屏幕,并与现有的导航框架无缝集成。
  • 如果需要添加一个新的导航图标: 可以通过实现 INavigationItem 接口将其注册,这样可以确保导航 UI 动态地适应包含新的项,保持一致和连贯的用户体验。
3. 简化代码

代码库保持整洁和易于管理,通过避免在NavHost中设置一个大型单体导航图。导航区域的分离使得NavGraphBuilder不会因为包含大量路由定义而显得杂乱无章。

  • 有组织的导航逻辑: 每个导航区域管理自己的路由,减少了对一个中央位置来处理所有导航逻辑的需求。这种组织使查找和修改特定的路由更加方便。
  • 提升可读性: 封装的设计使代码更易读,开发人员可以快速理解应用程序的结构和流程。导航逻辑被限定在相关的区域,减少了在更改时的认知负担。
4. 灵活性和可重复使用性

这种封装设计在应用程序的各个部分提高了灵活性和重用性。

  • 可复用组件: 通过定义可复用的接口并将导航逻辑抽象化,这些组件可以在不同的上下文中甚至跨不同项目复用。
  • 灵活的导航调整: 导航逻辑可以调整或扩展,而不影响应用程序的其他部分。例如,可以独立调整导航项的顺序或修改特定区域的功能。
5. 更易测试

这样,导航逻辑和UI之间有了明确的界限,使得设置更加容易测试。

  • 单独测试各个导航区域和项目: 可以单独测试各个导航区域和项目,确保每个组件正常工作。这种独立性也使得更精确的单元测试成为可能,从而增强测试覆盖率和可靠性。
  • 模拟依赖和注入: 使用 Koin 进行依赖注入使得依赖的模拟变得简单,从而支持强大的测试环境。开发人员可以在不依赖实际导航组件的前提下模拟不同状态和交互。

错误与观察

虽然封装的导航设置提供了许多好处,但仍存在一些细微的问题和潜在的隐患,特别是考虑到一些库仍处于测试版状态。以下是我们在实施过程中发现的一些问题和观察结果:

1. 过渡时间问题

主要遇到的一个问题是,在当前区域过渡还没有完成的时候就导航到另一个区域。这可能会导致一些意想不到的行为,比如:

  • 过渡不完善: 应用程序可能无法正确完成过渡到目标区域,导致视觉错误或屏幕部分加载不全。
  • 导航冲突问题: 快速启动多个导航操作可能会导致冲突,尝试同时处理多个导航,可能导致崩溃或状态不明。

这些情况是在预料之中的,因为在目前的状态下,导航库可能还不能很好地应对同时发出的导航命令或快速导航指令。

2. 不可挂起的导航调用

另一个令人惊讶的发现是,一些导航指令,如popBackStack,并不是挂起函数。也就是说,这些操作期望立即执行且无需挂起,这在处理异步操作或动画时可能导致问题,

  • 立即执行的同步: 立即执行的导航调用可能导致问题,如果其他异步操作还未完成,可能会导致状态不一致或竞态条件问题。
  • 没有协程支持: 在不使用挂起函数时,将导航逻辑与协程和异步工作流集成更困难。开发者需要手动确保导航命令和其他任务同步。
3. 主线程依赖

如果从后台线程触发导航命令,可能会导致问题,因为NavHostController非常依赖于在主线程上运行。

  • UI 线程强制: 如果导航命令意外从后台线程调用,可能会导致应用程序崩溃或产生未定义的行为。
  • 线程管理: 开发人员必须确保所有与导航相关的逻辑都在主线程上调度。这有时会使代码变得更加复杂,特别是在多任务并行处理的复杂应用中。
其他发现和结果

或其他发现

  • 不要使用相同的“SerialName”,因为这会导致相同的屏幕无法确定应该显示哪一个,因为这基于 Kotlin 序列化。
结尾

尽管遇到了一些bug和挑战,我仍然坚持在我的项目中使用这个导航库。它提供的封装设计、类型安全和模块化特性十分宝贵,我确信等到我准备发布时,这个库会变得更加稳定。Jetpack Compose Multiplatform及其导航组件的快速发展预示着一个光明的未来,我期待看到它将如何继续改进。

我特别期待介绍NavGraph ViewModel,这将增强在导航图中保留状态的能力。此功能可以简化状态的管理,并提供更好的用户体验。此外,我也很兴奋地展望未来,探索实现多个返回栈的可能性,提供更复杂的导航模式,并改善用户的交互流程。

尽管还有一些不尽如人意的地方,使用这个库的好处远远超过了它的缺点。其灵活性让它成为了开发现代多平台应用的强大工具。随着持续的发展和社区的反馈,我对它会继续成熟保持乐观,这使得它成为开发者更好的选择。

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消