JetpackCompose的路由管理实践 | 经验分享

前言

在上一篇Compose的分享文章中,我们实现了食选页面的大致骨架以及导航,本期本应该就内部业务和MVI结合做分析,但任何事情都不是那么简单的,我在上一期的路由实践就发现了问题。 上一期文章:

JetpackCompose实践-MVI业务开发 | 经验分享 - 掘金 (juejin.cn)

问题提出

初期问题

还记得吗?我们当时实现了食选 的APP首页,我们看看首页和设置界面是可以左右切换的对吧,不知道大家是不是还记得我们当时是如何完成它的,我们回顾一下代码,我们对NavHost进行了封装,目的是让NavHost来展示界面的所有内容,像是下面这样。

再看看底部导航的Item代码实现,我们抽离核心的代码。

当我们切换的时候就需要这样做,当NavigationBarItem被点击后就去看看当前的Index,找到对应的界面进去即可。

kotlin 复制代码
onClick = {
    mainActivityViewModel.sendIntent(
        MainActivityIntent.SelectNavItem(
            index,
        ),
    )
    when (index) {
        0 -> navController.navigateToHome()
        1 -> navController.navigateToSetting()
    }
},

但这个东西就像是Activity的栈管理,假设我们直接写跳转界面就会发生这种情况。

这是因为当我们直接跳转界面,会把界面加入管理栈内,并没有释放后面的,那么假设你点击了底部的设置按钮,界面确实切换到设置了,但是这导致你按下返回键后发现会跳转回首页。

为了解决这个问题,我将代码修改为下面这样,每次跳转首页或者设置时,先向后退一级,再跳转新的界面,那么去设置时就会关闭首页了,按下返回键就会关闭APP,而不是切换回首页。

kotlin 复制代码
fun NavController.navigateToHome() {
    this.popBackStack()
    this.navigate(route = homeRoute)
}

问题升级

看到这里就发现不是这么简单的,当我们点击设置页,再切换回首页,此时发现了一个比较大的问题!界面状态发生了丢失,这不简简单单是列表的滚动进度,而是其他组件的状态也如此。

这不合理,因为我在日常使用其他APP时发现不会这样,例如我们常用的ViewPage2,我只是切换了界面并不希望丢失其状态。

因此,本期文章就分享一下我如何解决这个问题的。

问题解析

为什么会这样?

我们前面的代码用了this.popBackStack(),这导致原来的界面被弹出去了,那么再一次加入时相当于新创建了这个界面给我们展现。

解决设想

那么我们想办法保留界面的状态不就好了?确实可以想办法存储后下次再恢复,类似我们处理旋转屏幕 那样,rememberSaveable就可以办到,像是下面这样,Activity重启后也会保持。

kotlin 复制代码
    var showDetails by rememberSaveable { mutableStateOf(false) }

但首页承载了很多内容,我们不可能做到这样,当然我们可以直接储存viewModel,但有一些列表状态还是得自己控制,为每个控件都这么做太可怕了。

因此我在想NavController本身是否可以保持状态,即使我关闭了界面,回答是可以的,但并不完全可以。

kotlin 复制代码
fun NavController.navigateToHome() {
    this.popBackStack()
    this.navigate(homeRoute) {
        popUpTo(homeRoute) {
            saveState = true
        }
        launchSingleTop = true
        restoreState = true
    }
}

我们可以靠saveStaterestoreState来恢复或者保存的界面状态,但问题在popBackStack()上,它清空了回退栈,导致了状态无法保存下来,这样我切换了界面还是没办法保存状态,而我没办法控制NavController在保存后关闭原来的界面,这不现实。

事实上这在xml上比较好实现,官方本身的例子就具备这样的功能,但可惜的是,他也无法保持状态。

当然也可能是我没有找到一个合理的办法去解决这个问题,如果有读者有其他方案欢迎提出。

确定方向

那么我不打算在NavController上继续坚持了,我们有另外的选项,我发现Compose有一个接近ViewPage的组件,叫做HorizontalPager,听名字大家就知道是什么了,它可以做轮播图以及满足我们的导航界面切换的需求,那就是它了!

实践解决

导航设计

这是我们一开始设计的方式,NavHost展示全部的界面,但是我们需要控制底部导航所需要展示的几个特殊界面,使得他们直接的切换是同级的,而其他更深的界面则不需要再添加额外跳转信息,正常出入栈即可。

但我们出现了刚刚的问题后就不能这样了,让我们换一种办法,组合NavControllerHorizontalPager让他们共同发挥作用,于是我们改为了下面这样的结构。

简单来说,就是首页里有NavHost,然后NavHostIndex 页面是具备了HorizontalPagerHorizontalPager则来展示首页,设置页等等,然后通过这些HorizontalPager中的界面,可以让NavHost展示其他界面,也就是相当于进入了这些界面。

就类似这样,当从Index跳转展示其他界面时,就会隐藏顶部和底部的导航,使用各个页面自己的导航,这样就不会有什么较大的冲突了。

落地实现

下部分内容建议配合源代码阅读,不过我尽可能的粘贴了一些核心代码

食选,解决生活中每天吃饭,吃什么,做什么,怎么做的问题 (github.com)

Index界面落地

Index实际上就是要承载HorizontalPager的,其实它的界面代码看上去和我们之前写NavController差不多,只不过它的内部需要写的是一个判断,用来确定当前的page展示哪个页面,page返回的就是index,那么现在我们设置0为首页,1为设置页。

kotlin 复制代码
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun IndexScreen(
    modifier: Modifier,
    viewModel: IndexViewModel,
    viewState: IndexState,
    navController: NavHostController,
    pageState: PagerState,
) {
    Column(modifier = modifier) {
        Spacer(modifier = Modifier.width(10.dp))

        HorizontalPager(
            userScrollEnabled = false,
            state = pageState,
            modifier = Modifier.fillMaxSize(),
        ) { pager ->
            when (pager) {
                0 -> {
                    HomeRoute(navController = navController)
                }

                1 -> {
                    SettingRoute(navController = navController)
                }
            }
        }
    }
}

这显然就完成了,那么接下来为这个组合我们稍微进行一下封装,也就是和我们之前一样,写一个IndexRoute,供NavHost初始化当前的界面,但是代码我就不贴啦,大家可以去看看源码,我并不想占用篇幅展示这个。

导航引入

上面我们只是写了Index这个界面,可还没有引入NavHost,下面我们在其中引入它,不要忘记修改初始界面为app_index,尽管我的截图没有截取到,但是要注意startDestination的值现在是app_index了。

Main界面改造

我们前面提到,Main界面有自己的底部和顶部导航栏,它只在Index这个界面展示,当NavHost打开其他界面时就需要隐藏掉,这也是上一节漏掉的内容。 我们需要监听路由的变化,在我们需要的界面做应该做的事情,那让我们在Main页面,也就是FoodAppScreen()中为navController添加监听器。

当抵达app_index 时我们发送一个意图到ViewModelSetShowBottomBar会携带一个参数,用来通知当前导航栏的显示和隐藏,我们在上一节展示了,这个展示和隐藏还带有显示动画。

kotlin 复制代码
navController.addOnDestinationChangedListener { _, destination, _ ->
    when (destination.route) {
        "app_index" -> {
            viewModel.sendIntent(MainActivityIntent.SetShowBottomBar(true))
        }
        else -> viewModel.sendIntent(MainActivityIntent.SetShowBottomBar(false))
    }
}

我们看看MainActivityViewModel中的handleEvent,它就是把viewState的属性值改了,所以才引起了界面的改变,这也是我们前一节解释过的。

kotlin 复制代码
override fun handleEvent(event: MainActivityIntent, state: MainActivityState) {
    when (event) {
        is MainActivityIntent.SelectNavItem -> selectNavItem(event.index)
        is MainActivityIntent.SetShowBottomBar -> {
            updateState { copy(isShowBottomBar = event.state) }
        }
    }
}

其中updateState就是帮我们省去一个写法 viewState = viewState.copy()

它的封装比较简单,就是套一层函数。

但是还没完,我们还需要初始化一个Page控制器,因为我们的导航在MainPageIndex,所以需要在Main页面进行初始化,你可以直接合并到一起。

下面这段代码就是进行初始化的,怎么样?和我们之前rememberNavController初始化路由控制器是一样的。

kotlin 复制代码
val pageState = rememberPagerState(
    initialPage = 0,
    initialPageOffsetFraction = 0f,
) { 2 }

这个lambda返回的的就是这个HorizontalPager总共的页数,我们现在有2个,那就是2了。

底部导航绑定

刚刚我们只是初始化了Page的控制器,现在我们需要用它,类似ViewPage2,我们设置它跳转到哪里就可以了。

kotlin 复制代码
scope.launch { pageState.scrollToPage(index) }

代码太多我就不粘贴了,大家可以看着源代码理解,上面是核心部分的截图。

这样,我们大体上实现了整个路由的管理,我们看看下面的流程图,界面跳转大致如下,需要注意的是首页和设置页等同属Index展示,他们不需要NavHost来切换,我想大家也应该在前面的文章中体会到了。

graph TD Main\n包含NavHost --navHost--> Index Index --page--> 首页 Index --page--> 设置 首页 --navHost--> 子功能页...

很感谢读者看到这里,如果感觉上面有待优化或者有更好想法的都可以在评论区分享。

重构小课堂

这部分内容与文章主题无关,没有兴趣的可以不用看了。

之前我另一个作品BILIBILIAS让我认识到了及时重构的重要性,食选 虽然才写了一点内容,但就需要迭代很多次,我不希望后面写食选这个APP是非常痛苦的。

因此后面可能每篇文章都会有重构之前内容的分享。

传参重构

传递参数是一个相当麻烦的内容,特别是在Compose里,我们的可组合项(函数)可能有很多,但大部分时候他们传参是差不多的,因此我们就需要一个全局统一的东西。

这个东西最严重的就是可能需要为每个组合都传入ViewModel、ViewState、NavController这几个东西,你应该不希望为每个组件都传入这些,像是下面这样的。

因此,谷歌为了解决也提供了一些想法

使用 CompositionLocal 将数据的作用域限定在局部 | Jetpack Compose | Android Developers (google.cn)

其实我们也一直在用了,看看下面这个在Compose中拿上下文就需要这样写,他就是一个被限定区域的参数,这样就隐式的把参数传递到了我们的函数里,

kotlin 复制代码
val context = LocalContext.current

当然,我们还可以可以使用compositionLocalOf这个方法来自定义我们自己的CompositionLocal,也将参数限定起来,实现隐式传参。

实践

我们以Main中的FoodApp为例,我们在FoodApp文件中顶部写上

kotlin 复制代码
private val LocalViewModel = compositionLocalOf<MainActivityViewModel> { error("No init!") }
private val LocalViewState = compositionLocalOf<MainActivityState> { error("No init!") }
private val LocalNavController = compositionLocalOf<NavHostController> { error("No init!") }

目前它没有初始化值,相当于是桩代码,因为待会我们要使用它。

定义了CompositionLocal后我们就需要为他赋值了。

kotlin 复制代码
@Composable
fun FoodApp(
    mainActivityViewModel: MainActivityViewModel,
) {
    val viewStates = mainActivityViewModel.viewStates

    // 全局路由
    val navController = rememberNavController()

    CompositionLocalProvider(
        LocalViewModel provides mainActivityViewModel,
        LocalViewState provides viewStates,
        LocalNavController provides navController,
    ) {
        FoodAppScreen()
    }
}

这里我们用了CompositionLocalProvider,它需要传入限定的参数和限定的内容,其中provides是个中缀表达式,从上面我们不难发现,LocalViewModel的值就是mainActivityViewModel

而在FoodAppScreen()内就可以使用刚刚限定范围的参数,就像是LocalContext那样,下面我们看一则例子。

kotlin 复制代码
val navController = LocalNavController.current
val viewModel = LocalViewModel.current
val viewStates = LocalViewState.current

就像是这样,我们拿到了刚刚设置的值,假设在FoodAppScreen()内部的组件也需要他们,那么也是这样获取即可,因为他们最终都在CompositionLocalProvider中。

文末

如果大家发现文章有内容错误欢迎指正,目前采用的导航设计其实也并不一定完全正确,但它确实较好的解决了我遇到的问题,因此我想把这个分享给大家,希望大家能在其中得到一些启发。

对项目有兴趣的记得Star呀!

食选,解决生活中每天吃饭,吃什么,做什么,怎么做的问题 (github.com)

相关推荐
闲暇部落1 小时前
‌Kotlin中的?.和!!主要区别
android·开发语言·kotlin
诸神黄昏EX3 小时前
Android 分区相关介绍
android
大白要努力!4 小时前
android 使用SQLiteOpenHelper 如何优化数据库的性能
android·数据库·oracle
Estar.Lee4 小时前
时间操作[取当前北京时间]免费API接口教程
android·网络·后端·网络协议·tcp/ip
Winston Wood4 小时前
Perfetto学习大全
android·性能优化·perfetto
Dnelic-7 小时前
【单元测试】【Android】JUnit 4 和 JUnit 5 的差异记录
android·junit·单元测试·android studio·自学笔记
Eastsea.Chen9 小时前
MTK Android12 user版本MtkLogger
android·framework
长亭外的少年17 小时前
Kotlin 编译失败问题及解决方案:从守护进程到 Gradle 配置
android·开发语言·kotlin
JIAY_WX17 小时前
kotlin
开发语言·kotlin
建群新人小猿19 小时前
会员等级经验问题
android·开发语言·前端·javascript·php